A saved card is a card PaymentMethod (pm_...) attached to a Customer (cus_...), which you can charge again while the buyer is not on your site. Keep a card on file when you know a later payment is coming: a balance due at delivery, a top-up the buyer agreed to, a one-click repeat order. A one-off payment does not need any of this.
There are two ways to save a card. A SetupIntent (seti_...) saves it and charges nothing. A PaymentIntent with setup_future_usage saves the card it is paid with. Either way the card ends up attached to the Customer, and the later payment is the same call:
| Step | Call | What you get |
|---|---|---|
| Keep the buyer | POST /v1/customers | a cus_ |
| Save the card | POST /v1/setup_intents with confirm=true | a succeeded SetupIntent whose payment_method is attached to the Customer |
| See what is saved | GET /v1/customers/{customer}/payment_methods | the Customer's cards |
| Charge it later | POST /v1/payment_intents with off_session=true | a succeeded PaymentIntent, or a 402 when the bank wants the buyer |
| Remove it | POST /v1/payment_methods/{payment_method}/detach | the card with customer: null |
Every request on this page runs on a fresh test store in the order shown, and each id comes from an earlier answer. Every amount is in the smallest unit of the currency: 2500 is 25.00. Send an Idempotency-Key with each POST. For retries, reuse the same key and request within the key's retention window (see Idempotency).
Create a Customer#
A card is saved to a Customer, so start with one. The Customers guide covers every field.
curl https://api.fluveo.com/v1/customers \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: crm-a2001-create" \
-d email=grace@example.com \
-d name="Grace Hopper"const response = await fetch("https://api.fluveo.com/v1/customers", {
method: "POST",
headers: {
Authorization: "Bearer sk_test_...",
"Idempotency-Key": "crm-a2001-create",
},
body: new URLSearchParams({
email: "grace@example.com",
name: "Grace Hopper",
}),
});
const customer = await response.json();
console.log(customer.id);import requests
response = requests.post(
"https://api.fluveo.com/v1/customers",
headers={
"Authorization": "Bearer sk_test_...",
"Idempotency-Key": "crm-a2001-create",
},
data={
"email": "grace@example.com",
"name": "Grace Hopper",
},
)
customer = response.json()
print(customer["id"])<?php
$ch = curl_init("https://api.fluveo.com/v1/customers");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer sk_test_...",
"Idempotency-Key: crm-a2001-create",
],
CURLOPT_POSTFIELDS => http_build_query([
"email" => "grace@example.com",
"name" => "Grace Hopper",
]),
]);
$customer = json_decode(curl_exec($ch), true);
echo $customer["id"];{
"id": "cus_...",
"object": "customer",
"email": "grace@example.com",
"name": "Grace Hopper",
"livemode": false
}Save a card without charging#
Create a SetupIntent with the Customer, the card and usage=off_session, which says you will charge the card while the buyer is away. confirm=true sets the card up in the same call. Here the card is the test PaymentMethod pm_card_visa; in live mode it is a pm_ your checkout collected. Send return_url too: it is where the buyer comes back if their bank asks them to authenticate (see When the bank asks the buyer).
curl https://api.fluveo.com/v1/setup_intents \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: a2001-save-card-1" \
-d customer=cus_... \
-d payment_method=pm_card_visa \
-d "payment_method_types[]=card" \
-d usage=off_session \
-d confirm=true \
--data-urlencode "return_url=https://shop.example.com/account/card-saved"const response = await fetch("https://api.fluveo.com/v1/setup_intents", {
method: "POST",
headers: {
Authorization: "Bearer sk_test_...",
"Idempotency-Key": "a2001-save-card-1",
},
body: new URLSearchParams({
customer: "cus_...",
payment_method: "pm_card_visa",
"payment_method_types[]": "card",
usage: "off_session",
confirm: "true",
return_url: "https://shop.example.com/account/card-saved",
}),
});
const setupIntent = await response.json();
console.log(setupIntent.status, setupIntent.payment_method);import requests
response = requests.post(
"https://api.fluveo.com/v1/setup_intents",
headers={
"Authorization": "Bearer sk_test_...",
"Idempotency-Key": "a2001-save-card-1",
},
data={
"customer": "cus_...",
"payment_method": "pm_card_visa",
"payment_method_types[]": "card",
"usage": "off_session",
"confirm": "true",
"return_url": "https://shop.example.com/account/card-saved",
},
)
setup_intent = response.json()
print(setup_intent["status"], setup_intent["payment_method"])<?php
$ch = curl_init("https://api.fluveo.com/v1/setup_intents");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer sk_test_...",
"Idempotency-Key: a2001-save-card-1",
],
CURLOPT_POSTFIELDS => http_build_query([
"customer" => "cus_...",
"payment_method" => "pm_card_visa",
"payment_method_types" => ["card"],
"usage" => "off_session",
"confirm" => "true",
"return_url" => "https://shop.example.com/account/card-saved",
]),
]);
$setupIntent = json_decode(curl_exec($ch), true);
echo $setupIntent["status"] . " " . $setupIntent["payment_method"];The card is set up. status is succeeded, and payment_method names the saved card, a new pm_ now attached to the Customer. Keep that pm_: it is what you charge later. Nothing was charged and no Charge exists.
{
"id": "seti_...",
"object": "setup_intent",
"customer": "cus_...",
"payment_method": "pm_...",
"payment_method_types": ["card"],
"usage": "off_session",
"status": "succeeded",
"next_action": null,
"latest_attempt": "setatt_...",
"last_setup_error": null
}Your endpoint gets setup_intent.created and setup_intent.succeeded. A card the bank declines answers 402 instead, with the SetupIntent inside the error in requires_payment_method and the reason in its last_setup_error; see Errors you may see.
Confirm in a second call#
You can also create the SetupIntent without confirm=true, for instance before the buyer has picked a card. It waits in requires_payment_method, or in requires_confirmation when it already names a card. Confirm it with POST /v1/setup_intents/{intent}/confirm, sending payment_method if it has none yet and the return_url of the buyer's session. return_url belongs on the confirm in that case: a create that sends it without confirm=true is refused with 400 setup_intent_invalid_parameter.
When the bank asks the buyer to authenticate#
Some banks want the cardholder to prove it is them before a card is saved, with 3D Secure. The confirm then answers 200 with status requires_action, and next_action holds a page for the buyer:
{
"id": "seti_...",
"object": "setup_intent",
"customer": "cus_...",
"status": "requires_action",
"next_action": {
"type": "redirect_to_url",
"redirect_to_url": {
"url": "https://api.fluveo.com/v1/card_actions/ca_...",
"return_url": "https://shop.example.com/account/card-saved"
}
}
}- Send the buyer to
next_action.redirect_to_url.url. Their bank's check opens there. - When they finish, they land on your
return_url, with thesetup_intentid and aredirect_statusin the query. - Do not trust the query alone. Read the SetupIntent with
GET /v1/setup_intents/{intent}, or wait for its event:setup_intent.succeededwhen the card is saved,setup_intent.setup_failedwhen the check failed. A failed check leaves the SetupIntent inrequires_payment_methodwithlast_setup_error, and no card is attached.
Your endpoint gets setup_intent.requires_action while the SetupIntent waits. Send return_url on every confirm made while the buyer is with you, so next_action always has a page to send them to.
Cancel a SetupIntent#
A SetupIntent you no longer need, such as one the buyer left in requires_action, is canceled with POST /v1/setup_intents/{intent}/cancel, with an optional cancellation_reason of abandoned, duplicate or requested_by_customer. It answers status canceled, sends setup_intent.canceled, and the page in its next_action no longer opens. Only a SetupIntent that has not succeeded can be canceled: a succeeded or already canceled one answers 400. To stop using a card that is already saved, remove it instead.
Save a card while charging#
When the buyer pays now and you will charge them again later, save the card with that first payment instead of a separate SetupIntent. Add customer and setup_future_usage=off_session to the card PaymentIntent you already create (see Accept a payment). Once the payment succeeds, its card is attached to that Customer, the PaymentIntent shows setup_future_usage, and its payment_method is the pm_ you charge later. on_session saves the card for payments the buyer makes on your site; use off_session when you will charge it without them.
See the saved cards#
GET /v1/customers/{customer}/payment_methods lists the cards attached to a Customer, newest first. Narrow it with type=card, and page it with limit, starting_after and ending_before.
curl -G https://api.fluveo.com/v1/customers/cus_.../payment_methods \
-H "Authorization: Bearer sk_test_..." \
-d type=cardconst response = await fetch("https://api.fluveo.com/v1/customers/cus_.../payment_methods?type=card", {
headers: { Authorization: "Bearer sk_test_..." },
});
const cards = await response.json();
console.log(cards.data.map((pm) => pm.id));import requests
response = requests.get(
"https://api.fluveo.com/v1/customers/cus_.../payment_methods",
headers={"Authorization": "Bearer sk_test_..."},
params={"type": "card"},
)
cards = response.json()
print([pm["id"] for pm in cards["data"]])<?php
$ch = curl_init("https://api.fluveo.com/v1/customers/cus_.../payment_methods?type=card");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer sk_test_...",
],
]);
$cards = json_decode(curl_exec($ch), true);
echo implode(", ", array_column($cards["data"], "id"));{
"object": "list",
"data": [
{
"id": "pm_...",
"object": "payment_method",
"type": "card",
"customer": "cus_...",
"card": { "brand": "visa", "last4": "4242" }
}
],
"has_more": false
}Show the buyer card.brand and card.last4 so they know which card is on file.
Charge the card later#
When the payment is due, create and confirm a PaymentIntent with the Customer, the saved pm_, off_session=true and confirm=true. off_session=true tells Fluveo the buyer is not there to answer anything, so no authentication page is offered. Use a new Idempotency-Key for each payment you mean to take.
curl https://api.fluveo.com/v1/payment_intents \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: a2001-order-2002" \
-d amount=2500 \
-d currency=usd \
-d customer=cus_... \
-d payment_method=pm_... \
-d "payment_method_types[]=card" \
-d off_session=true \
-d confirm=trueconst response = await fetch("https://api.fluveo.com/v1/payment_intents", {
method: "POST",
headers: {
Authorization: "Bearer sk_test_...",
"Idempotency-Key": "a2001-order-2002",
},
body: new URLSearchParams({
amount: "2500",
currency: "usd",
customer: "cus_...",
payment_method: "pm_...",
"payment_method_types[]": "card",
off_session: "true",
confirm: "true",
}),
});
const paymentIntent = await response.json();
console.log(paymentIntent.status);import requests
response = requests.post(
"https://api.fluveo.com/v1/payment_intents",
headers={
"Authorization": "Bearer sk_test_...",
"Idempotency-Key": "a2001-order-2002",
},
data={
"amount": 2500,
"currency": "usd",
"customer": "cus_...",
"payment_method": "pm_...",
"payment_method_types[]": "card",
"off_session": "true",
"confirm": "true",
},
)
payment_intent = response.json()
print(payment_intent["status"])<?php
$ch = curl_init("https://api.fluveo.com/v1/payment_intents");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer sk_test_...",
"Idempotency-Key: a2001-order-2002",
],
CURLOPT_POSTFIELDS => http_build_query([
"amount" => 2500,
"currency" => "usd",
"customer" => "cus_...",
"payment_method" => "pm_...",
"payment_method_types" => ["card"],
"off_session" => "true",
"confirm" => "true",
]),
]);
$paymentIntent = json_decode(curl_exec($ch), true);
echo $paymentIntent["status"];{
"id": "pi_...",
"object": "payment_intent",
"amount": 2500,
"amount_received": 2500,
"currency": "usd",
"customer": "cus_...",
"payment_method": "pm_...",
"latest_charge": "ch_...",
"status": "succeeded"
}The payment went through: payment_intent.succeeded and charge.succeeded follow. A decline answers 402 card_error as on any card payment (see Cards), and the PaymentIntent goes back to requires_payment_method.
When the bank wants the buyer present#
Even a saved card can meet a bank that insists on 3D Secure for this payment. Off session there is nobody to authenticate, so the confirm fails at once with 402, code and decline_code both authentication_required:
{
"error": {
"type": "card_error",
"code": "authentication_required",
"decline_code": "authentication_required",
"payment_intent": {
"id": "pi_...",
"status": "requires_payment_method",
"latest_charge": null
},
"payment_method": { "id": "pm_...", "type": "card" }
}
}No Charge is made and no money moves. The PaymentIntent keeps the reason in last_payment_error, and the only events are payment_intent.created and payment_intent.payment_failed. Do not send the same off-session payment again: the bank will answer the same way. Instead:
- Tell the buyer a payment needs them, by email or in your app, with a link back to your site.
- When they are there, confirm the same PaymentIntent with
POST /v1/payment_intents/{intent}/confirm, sending the saved card inpayment_methodand areturn_url, and withoutoff_session. Use a newIdempotency-Key: this is a new attempt. - The answer is
requires_actionwith anext_actionpage, as when saving. Send the buyer there; once they pass the check the PaymentIntent goes on tosucceeded, or the bank declines it with its own reason.
The buyer can also pick another card on that visit: confirm with its pm_ in payment_method.
Remove a card#
Detach a card when the buyer removes it from their account or asks you to forget it. The card leaves the Customer.
curl -X POST https://api.fluveo.com/v1/payment_methods/pm_.../detach \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: a2001-remove-card-1"const response = await fetch("https://api.fluveo.com/v1/payment_methods/pm_.../detach", {
method: "POST",
headers: {
Authorization: "Bearer sk_test_...",
"Idempotency-Key": "a2001-remove-card-1",
},
});
const paymentMethod = await response.json();
console.log(paymentMethod.customer);import requests
response = requests.post(
"https://api.fluveo.com/v1/payment_methods/pm_.../detach",
headers={
"Authorization": "Bearer sk_test_...",
"Idempotency-Key": "a2001-remove-card-1",
},
)
payment_method = response.json()
print(payment_method["customer"])<?php
$ch = curl_init("https://api.fluveo.com/v1/payment_methods/pm_.../detach");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer sk_test_...",
"Idempotency-Key: a2001-remove-card-1",
],
]);
$paymentMethod = json_decode(curl_exec($ch), true);
var_dump($paymentMethod["customer"]);{
"id": "pm_...",
"object": "payment_method",
"type": "card",
"customer": null,
"card": { "brand": "visa", "last4": "4242" }
}Your endpoint gets payment_method.detached, and the card no longer shows in the Customer's list. To charge this buyer again later, save a card again.
Parameters#
The
cus_the card is saved to. Once the SetupIntent succeeds, itspayment_methodis attached to this Customer.The card to save: a
pm_of your store, or in test mode a test PaymentMethod such aspm_card_visa.Use
cardto restrict this SetupIntent to cards, as in the examples above.off_session(the default) when you will charge the card without the buyer, oron_session.truesets the card up in the same call.Where the buyer comes back after their bank's check. Only with
confirm=true, or onPOST /v1/setup_intents/{intent}/confirm.
In the smallest unit of the currency:
2500is 25.00.A currency your account accepts.
The
cus_the card is saved to.The saved
pm_.truewhen the buyer is not there. Only withconfirm=true. No authentication page is offered: a card that needs one fails with402authentication_required.truecharges in the same call.off_sessionoron_session: saves the card of a payment that succeeds to itscustomer. See Save a card while charging.
The SetupIntents reference lists every parameter and field of the SetupIntent.
Statuses and events#
SetupIntent status | Meaning |
|---|---|
requires_payment_method | No card yet, or the last one was declined or failed its check (see last_setup_error). |
requires_confirmation | It names a card and waits for a confirm. |
requires_action | The buyer must pass their bank's check at next_action. |
processing | The result is not known yet. Read the SetupIntent again or wait for its event. |
succeeded | The card is saved and attached to the Customer. |
canceled | Canceled; nothing was saved. |
| Event | When |
|---|---|
setup_intent.created | A SetupIntent was created. |
setup_intent.requires_action | The buyer must pass 3D Secure before the card is set up. |
setup_intent.succeeded | The card was set up and attached to the SetupIntent's Customer. |
setup_intent.setup_failed | Setting up the card failed, by a decline or a failed check; last_setup_error says which. |
setup_intent.canceled | A SetupIntent was canceled. |
payment_method.attached | A PaymentMethod was attached to a Customer. |
payment_method.updated | A PaymentMethod changed. |
payment_method.detached | A PaymentMethod was detached from a Customer. |
Errors you may see#
| Error | Why |
|---|---|
402 card_declined on a SetupIntent | The bank refused to save the card. The error carries the SetupIntent in requires_payment_method, with the reason in last_setup_error. Ask for another card. |
402 authentication_required on an off-session charge | The bank wants the buyer for this payment. See When the bank wants the buyer present. |
400 setup_intent_invalid_parameter | return_url on a create without confirm=true. |
400 payment_method_unexpected_state | The card belongs to a deleted Customer. Ask the buyer for a card again. |
400 parameter_unknown | A parameter the call does not take. param names it. |
400 or 404 resource_missing | The seti_, pm_ or cus_ is not one of your store's. The status depends on the operation and whether the id is in the path or a parameter. |
See Errors for the shape of every error.
Test it#
Use a sk_test_... key.
- Create a Customer and save
pm_card_visato it as above: the SetupIntent answerssucceededwith a newpm_, and your endpoint getssetup_intent.createdandsetup_intent.succeeded. - List the Customer's cards: the
pm_is there,visaending in4242. - Charge it with
off_session=true:200,statussucceeded. - Save
pm_card_authenticationRequiredOnSetupwith areturn_url:requires_actionwith anext_actionpage. Open it, press Complete, and you land on yourreturn_url; the SetupIntent is nowsucceeded. Press Fail instead and it goes back torequires_payment_method. - Save
pm_card_chargeDeclined:402card_declined, the SetupIntent inrequires_payment_method, andsetup_intent.setup_failed. - Create a PaymentIntent with
pm_card_threeDSecure2Required,off_session=trueandconfirm=true:402authentication_required, and no Charge. - Detach the card from step 1:
customerisnull, and the Customer's list no longer shows it. - Create a SetupIntent with no
confirm, then cancel it withcancellation_reason=abandoned:statuscanceled. Cancel it again:400.