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:
| Step | What happens | What you see |
|---|---|---|
| Check | Fluveo 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. |
| Authenticate | The 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. |
| Authorize | The 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. |
| Capture | The 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 aspm_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:
{
"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#
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 areturn_urltoo, so a card whose bank asks for 3D Secure can come back to your site. Here2000is $20.00.Pay with a cardcurl 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/returnconst 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"];Read the answer#
An approved card answers
200withstatussucceeded.amount_receivedis what you were paid, andlatest_chargeis 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" }Read the card on the Charge#
GET /v1/charges/{id}shows the card the buyer paid with, underpayment_method_details.card: itsbrand,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.
brand | Network | Test PaymentMethod |
|---|---|---|
visa | Visa | pm_card_visa, pm_card_visa_debit |
mastercard | Mastercard | pm_card_mastercard, pm_card_mastercard_debit, pm_card_mastercard_prepaid |
amex | American Express | pm_card_amex |
discover | Discover | pm_card_discover |
diners | Diners Club | pm_card_diners |
jcb | JCB | pm_card_jcb |
unionpay | UnionPay | pm_card_unionpay |
unknown | A network outside this list | none |
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:
- Confirm with a
return_url, the page of your site where the buyer lands afterwards. - When the bank asks, the answer is
200withstatusrequires_actionand anext_actionof typeredirect_to_url. - 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. - The buyer comes back to your
return_urlwithpayment_intentandredirect_statusin the query. That visit proves nothing: read the PaymentIntent, or wait forpayment_intent.succeededorpayment_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).
{
"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#
1 child field
1 child field
automatic(the default) asks only when the bank requires it.anyandchallengeask for 3D Secure whenever the card supports it, even when the bank would not.
truerefuses to wait for the buyer: a card that needs 3D Secure fails at once with a402,codeauthentication_requiredanddecline_codeauthentication_not_handled, and the PaymentIntent goes back torequires_payment_method. Use it when no buyer is present to authenticate.Where the buyer lands after the bank's page. Only with
confirm=true.
{
"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.
Authorize the card#
Add
capture_method=manualto the confirm. Here5000is R$ 50,00.Authorize onlycurl 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=trueconst 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"];Read the hold#
The PaymentIntent is
requires_capture,amount_capturableis the amount on hold andamount_receivedis0. You getpayment_intent.amount_capturable_updatedandcharge.succeeded; the Charge hascaptured: 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" }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 withamount_to_capture; the rest goes back to the buyer. Send anIdempotency-Key.cURLcurl https://api.fluveo.com/v1/payment_intents/pi_.../capture \ -H "Authorization: Bearer sk_test_..." \ -H "Idempotency-Key: order-2002-capture" \ -d amount_to_capture=3000Read the capture#
The PaymentIntent is
succeeded,amount_receivedis what you captured, andamount_capturableis0.200 response (trimmed){ "id": "pi_HlVEYVCiSyrStoNyzFXp3CAV", "object": "payment_intent", "amount": 5000, "amount_capturable": 0, "amount_received": 3000, "capture_method": "manual", "currency": "brl", "status": "succeeded" }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_reasonis optional (requested_by_customer,duplicate,fraudulentorabandoned).cURLcurl 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:
| Capture | error.code |
|---|---|
amount_to_capture above amount_capturable | amount_too_large |
| A PaymentIntent already captured | payment_intent_unexpected_state |
| A canceled PaymentIntent | payment_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.
{
"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.code | error.decline_code | What it means | Test PaymentMethod |
|---|---|---|---|
card_declined | generic_decline | The bank gave no reason. | pm_card_chargeDeclined |
card_declined | insufficient_funds | Not enough money or credit on the card. | pm_card_chargeDeclinedInsufficientFunds |
card_declined | lost_card | The card was reported lost. | pm_card_chargeDeclinedLostCard |
card_declined | stolen_card | The card was reported stolen. | pm_card_chargeDeclinedStolenCard |
card_declined | card_velocity_exceeded | The card's limit or number of uses was reached. | pm_card_chargeDeclinedVelocityLimitExceeded |
card_declined | fraudulent | The payment looks fraudulent. | pm_card_chargeDeclinedFraudulent |
card_declined | card_not_supported | Your account cannot take this card's brand. | none |
expired_card | expired_card | The card is past its expiry date. | pm_card_chargeDeclinedExpiredCard |
incorrect_cvc | incorrect_cvc | The security code is wrong. | pm_card_chargeDeclinedIncorrectCvc |
processing_error | processing_error | The card could not be processed. Trying again may work. | pm_card_chargeDeclinedProcessingError |
authentication_required | authentication_required | The 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}andGET /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_method | Result |
|---|---|
pm_card_visa | Approved. |
pm_card_mastercard, pm_card_amex, pm_card_visa_debit | Approved, with that brand or funding. |
pm_card_chargeDeclined | 402 card_declined, generic_decline. |
pm_card_chargeDeclinedInsufficientFunds | 402 card_declined, insufficient_funds. |
pm_card_threeDSecure2Required | Asks for 3D Secure on every payment. |
pm_card_createDispute | Approved, then disputed. |
Then try each case of this page:
- Pay with
payment_method_types[]=card,pm_card_visaandconfirm=true:200,succeeded, andpayment_intent.succeededreaches your endpoint. - Pay with
pm_card_chargeDeclined:402card_declined. Confirm the same PaymentIntent withpm_card_visaand a new key:succeeded. - Pay with
pm_card_threeDSecure2Requiredand areturn_url:requires_action. Opennext_action.redirect_to_url.url, press Complete, and read the PaymentIntent:succeeded. - Authorize with
capture_method=manual, then capture less than the amount:succeeded, andamount_receivedis what you captured.
Every test PaymentMethod and what it does is on Testing. Never send card numbers to /v1, test numbers included.
Next#
- Accept a payment, the one call for every method.
- Hosted checkout, the Fluveo page that takes the card.
- Refund a payment and Handle disputes.
- Errors, every
codeanddecline_code.