---
title: "Cards"
description: "Take card payments with one API: how a payment moves, brands, 3D Secure, holds and declines."
url: https://docs.fluveo.com/payments/cards/
---

> Index of every page: https://docs.fluveo.com/llms.txt. Integrating Fluveo? Read https://docs.fluveo.com/agents/integrate.md first.

> Too long? This page with code in one language only: https://docs.fluveo.com/payments/cards.curl.md (cURL), https://docs.fluveo.com/payments/cards.node.md (Node.js), https://docs.fluveo.com/payments/cards.python.md (Python), https://docs.fluveo.com/payments/cards.php.md (PHP).

# Cards

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:

| 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](https://docs.fluveo.com/payments/cards.md#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](https://docs.fluveo.com/payments/cards.md#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](https://docs.fluveo.com/payments/cards.md#manual-capture) 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](https://docs.fluveo.com/payments/checkout.md): 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)*

```json
{
  "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](https://docs.fluveo.com/start/metadata.md)). An error that repeats a value you sent shows it the same way, such as "Invalid currency: XXXXXXXXXXXXXXXX".

> **For AI agents:**
>
> Never put a card number, expiry or CVC in a /v1 request, not even a test number. In tests, pay with payment_method=pm_card_visa. To take real cards, send the buyer to a Checkout Session (/payments/checkout).

## Pay with a card

### Step 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:

```bash
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
```

Node.js:

```javascript
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);
```

Python:

```python
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:

```php
<?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"];
```

### Step 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)*

```json
{
  "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"
}
```

### Step 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)*

```json
{
  "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:

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](https://docs.fluveo.com/payments/accept-a-payment.md#3d-secure)).

*requires_action (trimmed)*

```json
{
  "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**

- `payment_method_options` (object, optional)
  - `card` (object, optional)
    - `request_three_d_secure` (string, optional): `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.
- `error_on_requires_action` (boolean, optional): `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_url` (string, optional): Where the buyer lands after the bank's page. Only with `confirm=true`.

*402 with error_on_requires_action=true (trimmed)*

```json
{
  "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](https://docs.fluveo.com/payments/capture-and-cancel.md) covers the whole task, with partial captures, cancels and the errors of each call.

### Step 1: Authorize the card

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

**Authorize only**

cURL:

```bash
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
```

Node.js:

```javascript
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);
```

Python:

```python
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:

```php
<?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"];
```

### Step 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)*

```json
{
  "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"
}
```

### Step 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`.

```bash
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
```

### Step 4: Read the capture

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

*200 response (trimmed)*

```json
{
  "id": "pi_HlVEYVCiSyrStoNyzFXp3CAV",
  "object": "payment_intent",
  "amount": 5000,
  "amount_capturable": 0,
  "amount_received": 3000,
  "capture_method": "manual",
  "currency": "brl",
  "status": "succeeded"
}
```

### Step 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`).

```bash
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:

| 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`.

*402 response (trimmed)*

```json
{
  "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}` and `GET /v1/charges/{id}` show its status, amount and card.
- **Refund it.** In full or in part. See [Refund a payment](https://docs.fluveo.com/payments/refunds.md).
- **Answer disputes.** A buyer can dispute a card payment with their bank. It appears as a dispute. See [Handle disputes](https://docs.fluveo.com/payments/disputes.md).

## 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:

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](https://docs.fluveo.com/start/testing.md). Never send card numbers to `/v1`, test numbers included.

## Next

- [Accept a payment](https://docs.fluveo.com/payments/accept-a-payment.md), the one call for every method.
- [Hosted checkout](https://docs.fluveo.com/payments/checkout.md), the Fluveo page that takes the card.
- [Refund a payment](https://docs.fluveo.com/payments/refunds.md) and [Handle disputes](https://docs.fluveo.com/payments/disputes.md).
- [Errors](https://docs.fluveo.com/errors.md), every `code` and `decline_code`.
