# Save a card and charge it later

Take a card payment on your own checkout, keep the card with the customer's consent, and charge it again later without the customer being present: a no-show fee, extras added after check-out, a deposit topped up. This page walks through the whole flow, from creating the first checkout to charging the saved card, with the requests and responses at each step.

> 🔑 Switched on by Yavin. Saving a card and charging a saved card both need the saved-cards feature enabled on your account. Ask your Yavin contact to enable it; until then the requests below are refused.

---

## How it works

Two separate moments, linked by one id. The first payment saves the card and gives you stored_card.id. Any time later, your server charges that id on a new checkout. The card number never reaches your servers in either moment.

```mermaid
sequenceDiagram
    autonumber
    participant B as Your backend
    participant A as Yavin API
    participant P as Your page
    participant W as Yavin card widget

    Note over B,W: Moment 1, the customer pays and agrees to save the card
    B->>A: POST /checkouts/ with features.enrol_card and a customer
    A-->>B: 201 with checkout_token
    B->>P: checkout_token
    P->>W: mount(element, checkoutToken)
    P->>W: submit({ enrolConsent: true })
    W->>A: card payment with the consent
    A-)B: webhook payment_received, transactions carry stored_card
    Note over B: you keep stored_card.id against your customer or booking

    Note over B,A: Moment 2, later, no customer present
    B->>A: POST /checkouts/ for the new amount
    A-->>B: 201 with checkout_token
    B->>A: POST /checkouts/{checkout_token}/pay/ with customer_card_id
    A-->>B: 200 with the checkout, status ok
    A-)B: webhook payment_received
```

---

## Before you start

| You need | Why |
| --- | --- |
| The feature enabled on your account | One switch covers saving a card through the card widget and charging a saved card through the API. Ask Yavin to turn it on. |
| The card widget | The cardholder's consent travels with the payment, so the card is saved from your own page through the widget (submit({ enrolConsent: true })). Yavin's hosted payment page does not offer it. See [Online payment drop-in integration](https://docs.yavin.com/docs/online-payment-drop-in-integration). |
| Your own consent notice | Yavin does not collect the consent for you. You show your own notice and pass enrolConsent: true only for a customer who accepted it. |
| A webhook_url | The saved card's id reaches you in the payment_received webhook. Keep it on every checkout of the flow. |
| A spending limit that fits | Saved-card charges are capped per customer, over the customer's lifetime, at an amount set on your account (300 € by default). A charge that would take the customer over it is refused with amount_over_limit. Ask Yavin to change it. |

Base URL: production https://api.yavin.com/api/v5/ecommerce, sandbox https://api.sandbox.yavin.com/api/v5/ecommerce. Authentication: your API key in the Yavin-Secret header.

---

## Moment 1 - Save the card

### Step 1 - Create the checkout that saves the card

Create the checkout server-side, exactly as in the drop-in integration, with two additions: features.enrol_card and a customer.

```bash
POST https://api.yavin.com/api/v5/ecommerce/checkouts/
Yavin-Secret: <your yavin_secret_key>
Content-Type: application/json

{ "checkout_external_id": "booking_88",
  "amount": 2900,
  "customer": { "first_name": "Ada", "last_name": "Lovelace",
                "telephone": "+33612345678", "email": "ada@example.com" },
  "features": { "enrol_card": true },
  "return_url_success": "https://shop.example.com/return",
  "return_url_cancelled": "https://shop.example.com/cancel",
  "webhook_url": "https://shop.example.com/hooks/yavin" }
```

```json
// 201 Created
{ "checkout_token": "Ab3f...",
  "payment_link": "https://..." }
```

- features.enrol_card is a boolean and defaults to false. Every value in features must be a real boolean

- With enrol_card: true, the customer is required: first_name, last_name and at least one of telephone (50 characters at most) or email. Yavin keeps this contact with the saved card, and your merchant sees it in MyYavin

- A request that breaks one of these rules, or that comes from an account without the feature, is rejected before any checkout exists: 422 on /checkouts/, with an error naming features or customer

- enrol_card is only acted on by the card widget. A checkout opened on Yavin's hosted payment page sends no consent, so nothing is saved there

### Step 2 - Send the consent with the payment

Mount the widget with the checkout_token, show your consent notice, and pass the consent when you take the payment:

```javascript
const widget = window.YavinPaymentsSDK.mount(
  document.getElementById('card'),
  { checkoutToken: 'Ab3f...' }
)

// the customer ticked your "keep my card for later payments" box
const result = await widget.submit({ enrolConsent: customerAgreed })
```

- enrolConsent is optional and defaults to false. It must be a real boolean: "true", 1 or null make submit() reject and nothing is sent

- The consent applies to that attempt only. A retry that does not pass it saves nothing, even if an earlier attempt did

- After a 3-D Secure step-up the consent given when the payment started is the one that counts

- Saving the card never fails the payment: if the card cannot be saved, the payment still succeeds and no card is returned

### Step 3 - Read the saved card

Once the payment is authorised, the payment_received webhook arrives with the checkout. Each transaction carries stored_card:

```json
// webhook POST to your webhook_url
{ "action": "payment_received",
  "checkout_external_id": "booking_88",
  "requested_amount": 2900,
  "asked_amount": 2900,
  "paid_amount": 2900,
  "service_fee": 0,
  "status": "ok",
  "transactions": [
    { "transaction_id": "trs_7f3a92c5",
      "total_amount": 2900,
      "gift_amount": 0,
      "currency_code": "EUR",
      "date_of_payment": "2026-11-03 14:35:15",
      "gateway": "NEPTING_ECOMMERCE",
      "issuer": "VISA",
      "pan": "4970********0001",
      "status": "ok",
      "stored_card": { "id": "9b2f4c1e-7a3d-4f55-8a61-2d9c3b7e1a10",
                       "expiration_date": "2028-12" } } ] }
```

| Field | Meaning |
| --- | --- |
| stored_card | null when no saved card is linked to the transaction, which is the case whenever nothing was saved. |
| stored_card.id | The card's id (a UUID). Keep it with your customer or booking: it is the customer_card_id you send to charge the card. |
| stored_card.expiration_date | YYYY-MM, the card's expiry month, as printed on the card. A card is valid through the end of that month. null when the expiry is not known. |

> 💡 Check it before relying on it. If the webhook shows stored_card: null the customer paid but no card is on file, for example because the consent was not sent. Handle that case in your flow: ask for another payment method rather than assuming a card exists.

> ⏱️ The card is saved when the payment is authorised, before any capture, so stored_card is already in the payment_received webhook. Only card payments are saved: a wallet payment such as Apple Pay never is. You can read the same object at any time with /get_cart_information/.

---

## Moment 2 - Charge the saved card

When you need to charge, create a new checkout for the new amount, then pay it with the saved card. Both calls are made by your server, with no customer and no browser.

### Step 4 - Create the checkout to charge

```bash
POST https://api.yavin.com/api/v5/ecommerce/checkouts/
Yavin-Secret: <your yavin_secret_key>
Content-Type: application/json

{ "checkout_external_id": "booking_88_noshow",
  "amount": 1500,
  "webhook_url": "https://shop.example.com/hooks/yavin" }
```

```json
// 201 Created
{ "checkout_token": "Zk9q...",
  "payment_link": "https://..." }
```

- Give it a new checkout_external_id: an id is claimed permanently, even by a cancelled checkout

- Leave the capture mode at its default (instant). A checkout created with is_instant_capture: false cannot be paid with a saved card

- You do not need enrol_card or a customer here

### Step 5 - Pay with the saved card

POST https://api.yavin.com/api/v5/ecommerce/checkouts/{checkout_token}/pay/

```bash
POST https://api.yavin.com/api/v5/ecommerce/checkouts/Zk9q.../pay/
Yavin-Secret: <your yavin_secret_key>
Content-Type: application/json

{ "customer_card_id": "9b2f4c1e-7a3d-4f55-8a61-2d9c3b7e1a10" }
```

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| customer_card_id | String (UUID) | yes | The stored_card.id you kept in Step 3 |

The card is charged for the whole amount still to pay on the checkout. The body takes only customer_card_id: do not send card details with it.

```json
// 200 OK, the Checkout object
{ "checkout_external_id": "booking_88_noshow",
  "payment_link": "https://...",
  "requested_amount": 1500,
  "asked_amount": 1500,
  "paid_amount": 1500,
  "service_fee": 0,
  "gift_amount": 0,
  "status": "ok",
  "transactions": [
    { "transaction_id": "trs_91c4d0aa",
      "total_amount": 1500,
      "gift_amount": 0,
      "currency_code": "EUR",
      "date_of_payment": "2026-11-20 09:12:44",
      "gateway": "NEPTING_ECOMMERCE",
      "issuer": "VISA",
      "pan": "4970********0001",
      "status": "ok",
      "stored_card": { "id": "9b2f4c1e-7a3d-4f55-8a61-2d9c3b7e1a10",
                       "expiration_date": "2028-12" } } ] }
```

The response is the [Online payment: Ecommerce API](https://docs.yavin.com/docs/online-payment-ecommerce-api), returned directly (not wrapped in data). The same checkout is then sent to your webhook_url as payment_received, and the transaction is linked to the saved card through stored_card.

> ⚠️ Set a client-side timeout of at least 30 seconds, and never retry a charge blindly. If you did not get an answer, read the checkout with /get_cart_information/ first: the payment may have gone through.

### Step 6 - Confirm

Treat the 200 response and the payment_received webhook as the confirmation, as for any other payment. To reconcile, read the checkout:

```bash
POST https://api.yavin.com/api/v5/ecommerce/get_cart_information/
{ "checkout_external_id": "booking_88_noshow" }
```

---

## Errors

A refused charge returns an error body, the same shape for every code:

```json
{ "detail": "The payment was declined.",
  "code": "payment_declined",
  "errors": {},
  "transaction_id": "trs_91c4d0aa" }
```

code is the stable value to branch on. transaction_id is present when the attempt reached the payment gateway.

### The card cannot be charged

| code | HTTP | Meaning and what to do |
| --- | --- | --- |
| card_not_found | 404 | No saved card with this id on your account. Check the id you kept; a card of another merchant looks the same. |
| card_not_consented | 422 | The card was never saved with the cardholder's consent, so it cannot be charged. |
| card_disabled | 422 | The card was disabled, for example by your merchant in MyYavin. |
| card_expired | 422 | The card's expiry month has passed. Ask the customer for a new card. A card saved without an expiry date never expires on Yavin's side. |
| amount_over_limit | 422 | This charge would take the total charged to the customer through saved cards over the limit set on your account. Ask Yavin to raise it, or collect another way. |
| amount_invalid | 422 | The amount to charge is not above zero. |
| company_not_configured | 422 | The account is not set up to charge saved cards: the feature is off, or a payment contract is missing. Contact Yavin. |

### The checkout cannot be paid

| code | HTTP | Meaning and what to do |
| --- | --- | --- |
| invalid_payload | 422 | The body is not a valid customer_card_id (a UUID), or it mixes it with card details. errors names the field. |
| checkout_not_found | 404 | No checkout with this token on your account. |
| checkout_cancelled | 409 | The checkout was cancelled. Create a new one. |
| checkout_already_paid | 409 | The checkout is already paid. Do not charge again. |
| checkout_not_payable | 409 | The checkout is not waiting for a payment. |
| attempt_in_progress | 409 | Another payment attempt is running on this checkout. Wait, then read the checkout before trying again. |
| checkout_amount_changed | 409 | The amount to pay moved while the call was in flight. Read the checkout and decide. |
| checkout_requires_capture | 422 | The checkout uses deferred capture, which a saved card cannot pay. Create a checkout with the default instant capture. |
| checkout_version_incompatible | 422 | This checkout cannot be paid server-to-server. Create it with POST /checkouts/. |

### The payment was attempted

| code | HTTP | Meaning and what to do |
| --- | --- | --- |
| payment_declined | 402 | The issuer declined the charge. Nothing was taken. Ask the customer for another way to pay. |
| insufficient_funds | 402 | The card does not hold enough funds. Nothing was taken. |
| gateway_connection_error | 503 | The payment gateway could not be reached, so the charge was not made. It is safe to try again. |
| acquirer_timeout | 504 | The acquirer did not answer in time. The outcome is unknown. Read the checkout before you retry. |
| gateway_error | 502 | The gateway answered with something Yavin could not conclude from. The outcome is not certain. Read the checkout before you retry. |

The codes invalid_auth, challenge_in_progress and challenge_not_completable are rare: treat the payment as not taken, and contact support if they persist.

> 🧯 An unknown outcome is not a failed one. After acquirer_timeout or gateway_error, or any call that gave you no answer, read the checkout with /get_cart_information/ and look at its status and paid_amount before you charge again. Replaying a charge unattended is how a customer gets charged twice.

---

## Good to know

- You never handle card data. You keep an id and an expiry date. The card number and CVC stay inside Yavin's card field and never reach your page or your servers

- Keep the consent evidence. The notice you showed and the moment the customer accepted it are yours to record

- Your merchant sees the cards in MyYavin. The saved-cards section lists the cards saved from your checkouts, with the customer's contact, and lets them disable one. A disabled card is then refused with card_disabled

- Cards saved in store can be charged the same way. A card saved on a Yavin terminal is charged with the same call and its id, provided the saved-cards feature is enabled on your account

- Several payments, one customer. The spending limit adds up every successful saved-card charge of the same customer, across all their saved cards

---

## Related pages

[Online payment drop-in integration](https://docs.yavin.com/docs/online-payment-drop-in-integration)

[Online payment: Ecommerce API](https://docs.yavin.com/docs/online-payment-ecommerce-api)

[Webhooks Management](https://docs.yavin.com/docs/webhooks-management)
