Docs
API 2026-05-27.dahlia Dashboard

Payments

Take card payments with one API: how a payment moves, brands, 3D Secure, holds and declines.

A card is a credit, debit or prepaid card the buyer's bank issued. Every Fluveo store takes cards, with no setup, through the same PaymentIntent as every other method: you send the amount, a currency your account accepts, and the card as a PaymentMethod. The answer, the statuses, the errors and the events are the same whatever the currency.

How a card payment moves#

Four things happen between the buyer's click and the money in your balance. Each one has its place in the PaymentIntent:

StepWhat happensWhat you see
CheckFluveo checks the card's details: a number of the right form, an expiry in the future. A valid form does not mean the bank will approve it.A bad card stops here with a 402 card_error.
AuthenticateThe buyer's bank may ask the buyer to prove the card is theirs (3D Secure), on a page of the bank.requires_action, with a redirect_to_url to send the buyer to. See 3D Secure.
AuthorizeThe bank checks the funds and reserves the amount on the buyer's card. It can say no.A decline answers 402; see Declines. With capture_method=manual the PaymentIntent stops in requires_capture.
CaptureThe reserved amount is taken and lands in your balance.succeeded. By default the capture follows the authorization at once; see Authorize now, capture later to wait.

A card payment is usually decided while your request waits: the answer to the confirm already says succeeded, requires_capture, requires_action, or a 402. Still fulfil orders from the payment_intent.succeeded webhook, as for any method.

How the card reaches Fluveo#

Card numbers have strict security rules, so keep them off your server: name the card with a PaymentMethod instead of sending its number.

  • The hosted checkout page. Send the buyer to a Checkout Session: the buyer types the card on a Fluveo page, and Fluveo confirms the PaymentIntent.
  • A PaymentMethod id. Confirm with a pm_... id: one a Checkout Session saved for a Customer, or, in test mode, a test PaymentMethod such as pm_card_visa.

Raw card numbers are refused#

Unless Fluveo has turned on raw card access for your account, a request that carries card numbers in card on POST /v1/payment_methods, or in payment_method_data[card] on a PaymentIntent, is refused with a 402 before anything is charged:

402 response (test mode)
{
  "error": {
    "type": "invalid_request_error",
    "message": "Sending credit card numbers directly to the Fluveo API is generally unsafe. We suggest you use test tokens that map to the test card you are using, see https://docs.fluveo.com/start/testing. To enable testing raw card data APIs, see https://docs.fluveo.com/payments/cards#raw-card-numbers."
  }
}

The refusal never repeats the card fields you sent.

A card number typed into text Fluveo stores, such as description, a shipping address or metadata, is not refused. Fluveo stores it with each digit written X (see Metadata). An error that repeats a value you sent shows it the same way, such as "Invalid currency: XXXXXXXXXXXXXXXX".

Pay with a card#

  1. Create and confirm the PaymentIntent#

    Send the amount in the smallest unit of the currency, the currency, the card's PaymentMethod and confirm=true. Send a return_url too, so a card whose bank asks for 3D Secure can come back to your site. Here 2000 is $20.00.

    Pay with a card
    curl https://api.fluveo.com/v1/payment_intents \
      -H "Authorization: Bearer sk_test_..." \
      -H "Idempotency-Key: order-2001-pay" \
      -d amount=2000 \
      -d currency=usd \
      -d "payment_method_types[]=card" \
      -d payment_method=pm_card_visa \
      -d confirm=true \
      -d return_url=https://shop.example.com/orders/2001/return
    const response = await fetch("https://api.fluveo.com/v1/payment_intents", {
      method: "POST",
      headers: {
        Authorization: "Bearer sk_test_...",
        "Idempotency-Key": "order-2001-pay",
      },
      body: new URLSearchParams({
        amount: "2000",
        currency: "usd",
        "payment_method_types[]": "card",
        payment_method: "pm_card_visa",
        confirm: "true",
        return_url: "https://shop.example.com/orders/2001/return",
      }),
    });
    const paymentIntent = await response.json();
    console.log(response.status, paymentIntent.status);
    import requests
    
    response = requests.post(
        "https://api.fluveo.com/v1/payment_intents",
        headers={
            "Authorization": "Bearer sk_test_...",
            "Idempotency-Key": "order-2001-pay",
        },
        data={
            "amount": 2000,
            "currency": "usd",
            "payment_method_types[]": "card",
            "payment_method": "pm_card_visa",
            "confirm": "true",
            "return_url": "https://shop.example.com/orders/2001/return",
        },
    )
    print(response.status_code, response.json()["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: order-2001-pay",
        ],
        CURLOPT_POSTFIELDS => http_build_query([
            "amount" => 2000,
            "currency" => "usd",
            "payment_method_types" => ["card"],
            "payment_method" => "pm_card_visa",
            "confirm" => "true",
            "return_url" => "https://shop.example.com/orders/2001/return",
        ]),
    ]);
    $paymentIntent = json_decode(curl_exec($ch), true);
    echo curl_getinfo($ch, CURLINFO_HTTP_CODE) . " " . $paymentIntent["status"];
  2. Read the answer#

    An approved card answers 200 with status succeeded. amount_received is what you were paid, and latest_charge is the Charge that holds the card's details.

    200 response (trimmed)
    {
      "id": "pi_zDL0pM0Pz3EhtI03ts1wbVxA",
      "object": "payment_intent",
      "amount": 2000,
      "amount_capturable": 0,
      "amount_received": 2000,
      "capture_method": "automatic_async",
      "currency": "usd",
      "latest_charge": "ch_5doKB5hoXIhPURb4UCdDEolX",
      "last_payment_error": null,
      "next_action": null,
      "payment_method": "pm_X12eOMYzlU9vvokcZ1LKFmDJ",
      "payment_method_options": {
        "card": {
          "installments": null,
          "mandate_options": null,
          "network": null,
          "request_three_d_secure": "automatic"
        }
      },
      "payment_method_types": [
        "card"
      ],
      "status": "succeeded"
    }
  3. Read the card on the Charge#

    GET /v1/charges/{id} shows the card the buyer paid with, under payment_method_details.card: its brand, funding, last4, expiry and the bank's checks. Show the brand and the last 4 digits on your receipt; never store more.

    payment_method_details on the Charge (trimmed)
    {
      "type": "card",
      "card": {
        "amount_authorized": 5000,
        "brand": "visa",
        "checks": {
          "address_line1_check": null,
          "address_postal_code_check": null,
          "cvc_check": "pass"
        },
        "country": "US",
        "exp_month": 11,
        "exp_year": 2027,
        "funding": "credit",
        "last4": "4242",
        "network": "visa",
        "three_d_secure": null
      }
    }

Card brands#

A card's network shows in card.brand on its PaymentMethod and in payment_method_details.card.brand on its Charge. card.funding says whether it is credit, debit or prepaid. A brand your account cannot take is declined like any other refusal of the card: a 402 with code card_declined and decline_code card_not_supported, the PaymentIntent back in requires_payment_method, so the buyer can pay with another card.

brandNetworkTest PaymentMethod
visaVisapm_card_visa, pm_card_visa_debit
mastercardMastercardpm_card_mastercard, pm_card_mastercard_debit, pm_card_mastercard_prepaid
amexAmerican Expresspm_card_amex
discoverDiscoverpm_card_discover
dinersDiners Clubpm_card_diners
jcbJCBpm_card_jcb
unionpayUnionPaypm_card_unionpay
unknownA network outside this listnone

Turn a brand or a funding type away#

Fluveo has no setting that blocks a brand. When you do not want a brand, or prepaid cards, read the PaymentMethod before you confirm: GET /v1/payment_methods/{id} returns card.brand and card.funding, and you skip the confirm for the cards you refuse.

3D Secure#

3D Secure is the check where the buyer's bank asks the buyer to prove the card is theirs, often with a code sent to their phone. The bank decides when to ask. Fluveo runs it as a redirect:

  1. Confirm with a return_url, the page of your site where the buyer lands afterwards.
  2. When the bank asks, the answer is 200 with status requires_action and a next_action of type redirect_to_url.
  3. Send the buyer's browser to next_action.redirect_to_url.url. In test mode it opens a test page with two buttons, Complete and Fail.
  4. The buyer comes back to your return_url with payment_intent and redirect_status in the query. That visit proves nothing: read the PaymentIntent, or wait for payment_intent.succeeded or payment_intent.payment_failed.

Always send return_url. On a PaymentIntent created with payment_method_types, a card that needs 3D Secure and was confirmed without it answers requires_action with a next_action of type use_fluveo_sdk, meant for an in-page check that Fluveo does not offer, so that PaymentIntent cannot finish. On one created without payment_method_types, a confirm without return_url is refused with 400 and charges nothing (see 3D Secure).

requires_action (trimmed)
{
  "id": "pi_YwrvFvoofp7mFxsPcKA7S7ii",
  "object": "payment_intent",
  "amount": 2000,
  "currency": "usd",
  "latest_charge": null,
  "status": "requires_action",
  "next_action": {
    "type": "redirect_to_url",
    "redirect_to_url": {
      "url": "https://api.fluveo.com/v1/card_actions/ca_072950e24e815dd9d3902d3e172b76510dbd0e7cab104673",
      "return_url": "https://shop.example.com/orders/2001/return"
    }
  }
}

A buyer who fails or abandons the check leaves the PaymentIntent in requires_payment_method, with last_payment_error.code payment_intent_authentication_failure. Confirm it again with another card.

Choose how 3D Secure behaves#

Confirm parameters
  • 1 child field
    • cardobjectOptional
      1 child field
      • automatic (the default) asks only when the bank requires it. any and challenge ask for 3D Secure whenever the card supports it, even when the bank would not.

  • true refuses to wait for the buyer: a card that needs 3D Secure fails at once with a 402, code authentication_required and decline_code authentication_not_handled, and the PaymentIntent goes back to requires_payment_method. Use it when no buyer is present to authenticate.

  • return_urlstringOptional

    Where the buyer lands after the bank's page. Only with confirm=true.

402 with error_on_requires_action=true (trimmed)
{
  "error": {
    "type": "card_error",
    "code": "authentication_required",
    "decline_code": "authentication_not_handled",
    "message": "This payment required an authentication action to complete, but `error_on_requires_action` was set. When you're ready, you can upgrade your integration to handle actions at https://docs.fluveo.com/payments/payment-intents/upgrade-to-handle-actions.",
    "payment_intent": {
      "id": "pi_AKMVHB27BiVgz6EZhkCeaEb9",
      "currency": "brl",
      "status": "requires_payment_method"
    }
  }
}

Authorize now, capture later#

Place a hold when you only charge once you know you can deliver, such as an item you still need to check in stock. Confirm with capture_method=manual: the bank reserves the amount on the card, and nothing reaches your balance until you capture.

The steps below are the short form. Capture or cancel covers the whole task, with partial captures, cancels and the errors of each call.

  1. Authorize the card#

    Add capture_method=manual to the confirm. Here 5000 is R$ 50,00.

    Authorize only
    curl https://api.fluveo.com/v1/payment_intents \
      -H "Authorization: Bearer sk_test_..." \
      -H "Idempotency-Key: order-2002-authorize" \
      -d amount=5000 \
      -d currency=brl \
      -d "payment_method_types[]=card" \
      -d payment_method=pm_card_visa \
      -d capture_method=manual \
      -d confirm=true
    const response = await fetch("https://api.fluveo.com/v1/payment_intents", {
      method: "POST",
      headers: {
        Authorization: "Bearer sk_test_...",
        "Idempotency-Key": "order-2002-authorize",
      },
      body: new URLSearchParams({
        amount: "5000",
        currency: "brl",
        "payment_method_types[]": "card",
        payment_method: "pm_card_visa",
        capture_method: "manual",
        confirm: "true",
      }),
    });
    const paymentIntent = await response.json();
    console.log(paymentIntent.status, paymentIntent.amount_capturable);
    import requests
    
    response = requests.post(
        "https://api.fluveo.com/v1/payment_intents",
        headers={
            "Authorization": "Bearer sk_test_...",
            "Idempotency-Key": "order-2002-authorize",
        },
        data={
            "amount": 5000,
            "currency": "brl",
            "payment_method_types[]": "card",
            "payment_method": "pm_card_visa",
            "capture_method": "manual",
            "confirm": "true",
        },
    )
    payment_intent = response.json()
    print(payment_intent["status"], payment_intent["amount_capturable"])
    <?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: order-2002-authorize",
        ],
        CURLOPT_POSTFIELDS => http_build_query([
            "amount" => 5000,
            "currency" => "brl",
            "payment_method_types" => ["card"],
            "payment_method" => "pm_card_visa",
            "capture_method" => "manual",
            "confirm" => "true",
        ]),
    ]);
    $paymentIntent = json_decode(curl_exec($ch), true);
    echo $paymentIntent["status"] . " " . $paymentIntent["amount_capturable"];
  2. Read the hold#

    The PaymentIntent is requires_capture, amount_capturable is the amount on hold and amount_received is 0. You get payment_intent.amount_capturable_updated and charge.succeeded; the Charge has captured: false.

    200 response (trimmed)
    {
      "id": "pi_HlVEYVCiSyrStoNyzFXp3CAV",
      "object": "payment_intent",
      "amount": 5000,
      "amount_capturable": 5000,
      "amount_received": 0,
      "capture_method": "manual",
      "currency": "brl",
      "latest_charge": "ch_DP5sHrKZgGVutSuMcBpGHopK",
      "status": "requires_capture"
    }
  3. Capture before the hold ends#

    A bank keeps a hold open for a limited time: for most online card payments, 7 days. The Charge says until when in payment_method_details.card.capture_before, a Unix timestamp. Capture all of it with no body, or less with amount_to_capture; the rest goes back to the buyer. Send an Idempotency-Key.

    cURL
    curl https://api.fluveo.com/v1/payment_intents/pi_.../capture \
      -H "Authorization: Bearer sk_test_..." \
      -H "Idempotency-Key: order-2002-capture" \
      -d amount_to_capture=3000
  4. Read the capture#

    The PaymentIntent is succeeded, amount_received is what you captured, and amount_capturable is 0.

    200 response (trimmed)
    {
      "id": "pi_HlVEYVCiSyrStoNyzFXp3CAV",
      "object": "payment_intent",
      "amount": 5000,
      "amount_capturable": 0,
      "amount_received": 3000,
      "capture_method": "manual",
      "currency": "brl",
      "status": "succeeded"
    }
  5. Or cancel the hold#

    If you will not ship, cancel the PaymentIntent: the hold is released to the buyer and the PaymentIntent is canceled. cancellation_reason is optional (requested_by_customer, duplicate, fraudulent or abandoned).

    cURL
    curl https://api.fluveo.com/v1/payment_intents/pi_.../cancel \
      -H "Authorization: Bearer sk_test_..." \
      -H "Idempotency-Key: order-2002-cancel" \
      -d cancellation_reason=requested_by_customer

A capture makes the PaymentIntent succeeded and sends charge.captured and payment_intent.succeeded. One capture per PaymentIntent. These captures are refused with a 400 invalid_request_error, and nothing moves:

Captureerror.code
amount_to_capture above amount_capturableamount_too_large
A PaymentIntent already capturedpayment_intent_unexpected_state
A canceled PaymentIntentpayment_intent_unexpected_state

Declines#

A card payment can fail for three kinds of reasons, and each kind answers differently.

The bank says no#

The buyer's bank refuses the authorization: not enough funds, a card reported lost, a limit reached, or no reason at all. The answer is 402 with error.type card_error. error.code says what failed, error.decline_code gives the bank's reason, and error.message is a sentence you can show the buyer. error.payment_intent holds the PaymentIntent, back in requires_payment_method, and you get charge.failed and payment_intent.payment_failed.

402 response (trimmed)
{
  "error": {
    "type": "card_error",
    "code": "card_declined",
    "decline_code": "insufficient_funds",
    "advice_code": "try_again_later",
    "message": "Your card has insufficient funds. Use a different payment method to complete this purchase.",
    "charge": "ch_LR0E5QZBtPqDa7B7dALne1cm",
    "payment_intent": {
      "id": "pi_rK4EtlULtRJPFQHJsaJs4fjW",
      "currency": "brl",
      "status": "requires_payment_method"
    }
  }
}
error.codeerror.decline_codeWhat it meansTest PaymentMethod
card_declinedgeneric_declineThe bank gave no reason.pm_card_chargeDeclined
card_declinedinsufficient_fundsNot enough money or credit on the card.pm_card_chargeDeclinedInsufficientFunds
card_declinedlost_cardThe card was reported lost.pm_card_chargeDeclinedLostCard
card_declinedstolen_cardThe card was reported stolen.pm_card_chargeDeclinedStolenCard
card_declinedcard_velocity_exceededThe card's limit or number of uses was reached.pm_card_chargeDeclinedVelocityLimitExceeded
card_declinedfraudulentThe payment looks fraudulent.pm_card_chargeDeclinedFraudulent
card_declinedcard_not_supportedYour account cannot take this card's brand.none
expired_cardexpired_cardThe card is past its expiry date.pm_card_chargeDeclinedExpiredCard
incorrect_cvcincorrect_cvcThe security code is wrong.pm_card_chargeDeclinedIncorrectCvc
processing_errorprocessing_errorThe card could not be processed. Trying again may work.pm_card_chargeDeclinedProcessingError
authentication_requiredauthentication_requiredThe bank wants 3D Secure and the buyer cannot do it now.pm_card_threeDSecure2Required with off_session=true

Show the buyer error.message, never the decline_code: a reason such as lost_card or fraudulent is for you, not for a person who may be trying a stolen card. Ask for another card and confirm the same PaymentIntent again, with a new Idempotency-Key because it is a new attempt. Do not retry the same card again and again: banks count repeated attempts against it.

A refusal before the bank#

A request Fluveo cannot send to the bank is refused with a 400 invalid_request_error, and nothing reaches the bank: a missing or wrong parameter, an amount below the minimum (amount_too_small), a currency your account does not accept. error.param names the field when there is one. Fix the request; the same request will fail the same way.

The payer's tax id#

A card sale needs no tax id and no Customer. A card takes no billing_details[tax_id]: it is refused with invalid_request_error and code tax_id_prohibited.

After the payment#

A card payment is a PaymentIntent with a Charge, and you manage it like any other:

  • Read it. GET /v1/payment_intents/{id} and GET /v1/charges/{id} show its status, amount and card.
  • Refund it. In full or in part. See Refund a payment.
  • Answer disputes. A buyer can dispute a card payment with their bank. It appears as a dispute. See Handle disputes.

Test cards#

In test mode, put a test PaymentMethod name in payment_method. Each one gives a known result:

payment_methodResult
pm_card_visaApproved.
pm_card_mastercard, pm_card_amex, pm_card_visa_debitApproved, with that brand or funding.
pm_card_chargeDeclined402 card_declined, generic_decline.
pm_card_chargeDeclinedInsufficientFunds402 card_declined, insufficient_funds.
pm_card_threeDSecure2RequiredAsks for 3D Secure on every payment.
pm_card_createDisputeApproved, then disputed.

Then try each case of this page:

  1. Pay with payment_method_types[]=card, pm_card_visa and confirm=true: 200, succeeded, and payment_intent.succeeded reaches your endpoint.
  2. Pay with pm_card_chargeDeclined: 402 card_declined. Confirm the same PaymentIntent with pm_card_visa and a new key: succeeded.
  3. Pay with pm_card_threeDSecure2Required and a return_url: requires_action. Open next_action.redirect_to_url.url, press Complete, and read the PaymentIntent: succeeded.
  4. Authorize with capture_method=manual, then capture less than the amount: succeeded, and amount_received is what you captured.

Every test PaymentMethod and what it does is on Testing. Never send card numbers to /v1, test numbers included.

Next#