Docs
API 2026-05-27.dahlia Dashboard

Payments

Save a card with a SetupIntent and charge it later, with the buyer away.

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:

StepCallWhat you get
Keep the buyerPOST /v1/customersa cus_
Save the cardPOST /v1/setup_intents with confirm=truea succeeded SetupIntent whose payment_method is attached to the Customer
See what is savedGET /v1/customers/{customer}/payment_methodsthe Customer's cards
Charge it laterPOST /v1/payment_intents with off_session=truea succeeded PaymentIntent, or a 402 when the bank wants the buyer
Remove itPOST /v1/payment_methods/{payment_method}/detachthe 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.

Create a Customer
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"];
200 response (shortened)
{
  "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).

Save a card to the Customer
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.

200 response (shortened)
{
  "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:

200 response, the buyer must authenticate (shortened)
{
  "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"
    }
  }
}
  1. Send the buyer to next_action.redirect_to_url.url. Their bank's check opens there.
  2. When they finish, they land on your return_url, with the setup_intent id and a redirect_status in the query.
  3. Do not trust the query alone. Read the SetupIntent with GET /v1/setup_intents/{intent}, or wait for its event: setup_intent.succeeded when the card is saved, setup_intent.setup_failed when the check failed. A failed check leaves the SetupIntent in requires_payment_method with last_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.

List the Customer's cards
curl -G https://api.fluveo.com/v1/customers/cus_.../payment_methods \
  -H "Authorization: Bearer sk_test_..." \
  -d type=card
const 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"));
200 response (shortened)
{
  "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.

Charge 25.00 on the saved card
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=true
const 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"];
200 response (shortened)
{
  "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:

402 response (trimmed)
{
  "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:

  1. Tell the buyer a payment needs them, by email or in your app, with a link back to your site.
  2. When they are there, confirm the same PaymentIntent with POST /v1/payment_intents/{intent}/confirm, sending the saved card in payment_method and a return_url, and without off_session. Use a new Idempotency-Key: this is a new attempt.
  3. The answer is requires_action with a next_action page, as when saving. Send the buyer there; once they pass the check the PaymentIntent goes on to succeeded, 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.

Remove the saved card
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"]);
200 response (shortened)
{
  "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#

Save a card (POST /v1/setup_intents)
  • customerstringOptional

    The cus_ the card is saved to. Once the SetupIntent succeeds, its payment_method is attached to this Customer.

  • payment_methodstringOptional

    The card to save: a pm_ of your store, or in test mode a test PaymentMethod such as pm_card_visa.

  • Use card to restrict this SetupIntent to cards, as in the examples above.

  • usagestringOptional

    off_session (the default) when you will charge the card without the buyer, or on_session.

  • confirmbooleanOptional

    true sets the card up in the same call.

  • return_urlstringOptional

    Where the buyer comes back after their bank's check. Only with confirm=true, or on POST /v1/setup_intents/{intent}/confirm.

Charge it later (POST /v1/payment_intents)
  • amountintegerRequired

    In the smallest unit of the currency: 2500 is 25.00.

  • currencystringRequired

    A currency your account accepts.

  • customerstringOptional

    The cus_ the card is saved to.

  • payment_methodstringOptional

    The saved pm_.

  • off_sessionstringOptional

    true when the buyer is not there. Only with confirm=true. No authentication page is offered: a card that needs one fails with 402 authentication_required.

  • confirmbooleanOptional

    true charges in the same call.

  • setup_future_usagestringOptional

    off_session or on_session: saves the card of a payment that succeeds to its customer. See Save a card while charging.

The SetupIntents reference lists every parameter and field of the SetupIntent.

Statuses and events#

SetupIntent statusMeaning
requires_payment_methodNo card yet, or the last one was declined or failed its check (see last_setup_error).
requires_confirmationIt names a card and waits for a confirm.
requires_actionThe buyer must pass their bank's check at next_action.
processingThe result is not known yet. Read the SetupIntent again or wait for its event.
succeededThe card is saved and attached to the Customer.
canceledCanceled; nothing was saved.
EventWhen
setup_intent.createdA SetupIntent was created.
setup_intent.requires_actionThe buyer must pass 3D Secure before the card is set up.
setup_intent.succeededThe card was set up and attached to the SetupIntent's Customer.
setup_intent.setup_failedSetting up the card failed, by a decline or a failed check; last_setup_error says which.
setup_intent.canceledA SetupIntent was canceled.
payment_method.attachedA PaymentMethod was attached to a Customer.
payment_method.updatedA PaymentMethod changed.
payment_method.detachedA PaymentMethod was detached from a Customer.

Errors you may see#

ErrorWhy
402 card_declined on a SetupIntentThe 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 chargeThe bank wants the buyer for this payment. See When the bank wants the buyer present.
400 setup_intent_invalid_parameterreturn_url on a create without confirm=true.
400 payment_method_unexpected_stateThe card belongs to a deleted Customer. Ask the buyer for a card again.
400 parameter_unknownA parameter the call does not take. param names it.
400 or 404 resource_missingThe 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.

  1. Create a Customer and save pm_card_visa to it as above: the SetupIntent answers succeeded with a new pm_, and your endpoint gets setup_intent.created and setup_intent.succeeded.
  2. List the Customer's cards: the pm_ is there, visa ending in 4242.
  3. Charge it with off_session=true: 200, status succeeded.
  4. Save pm_card_authenticationRequiredOnSetup with a return_url: requires_action with a next_action page. Open it, press Complete, and you land on your return_url; the SetupIntent is now succeeded. Press Fail instead and it goes back to requires_payment_method.
  5. Save pm_card_chargeDeclined: 402 card_declined, the SetupIntent in requires_payment_method, and setup_intent.setup_failed.
  6. Create a PaymentIntent with pm_card_threeDSecure2Required, off_session=true and confirm=true: 402 authentication_required, and no Charge.
  7. Detach the card from step 1: customer is null, and the Customer's list no longer shows it.
  8. Create a SetupIntent with no confirm, then cancel it with cancellation_reason=abandoned: status canceled. Cancel it again: 400.

Next#