# Card Handoff API

Save a card the person enters in your app to their Layout account. Your server sends it to `secure.layout.link`, which turns it into a token before Layout sees the request, so Layout never receives the card number.

Send only a card that:

- the person enters in your app, in a flow that shows Layout's [consent text](#consent), for the purpose of saving it with Layout;
- is theirs, or one the cardholder lets them use;
- goes to the Layout account of that same person.

Never send a card you collected for something else, kept from an earlier purchase, or got from anyone but the person. Do not keep the security code after you send it.

Collect cards with Stripe Elements or another processor? You never see the number, so use [card links](https://docs.layout.link/card-links) (`POST /v1/users/{userId}/links`) instead.

> A card number must never pass through a model: not in an LLM prompt, a tool call, a transcript, a log or analytics. Collect it in your own form and send it from your server.

## Sandbox

The Card Handoff API is on in sandbox for every application, with nothing to ask for. Use your `sk_test_` key and a test card number. A sandbox handoff checks everything a live one does up to the card itself, the person, the consent and the rate limits, and then answers `201` with a stand-in card. Nothing is saved, and nothing is billed.

```
→ 201
{
  "card": {
    "id": "card_sandbox",
    "brand": "visa",
    "last4": "4242",
    "isDefault": false,
    "createdAt": "2026-11-06T17:02:12.000Z"
  },
  "sandbox": true
}
```

A sandbox key takes only these numbers. Any other number is `400` `invalid_request`, *"Sandbox keys take test card numbers only."* Never send a real card number to sandbox.

| Brand | Test numbers |
| --- | --- |
| Visa | `4242424242424242`, `4000056655665556`, `4000000000000002`, `4000000000009995` |
| Mastercard | `5555555555554444`, `2223003122003222`, `5200828282828210` |
| American Express | `378282246310005`, `371449635398431` |
| Discover | `6011111111111117` |
| Diners Club | `3056930009020004`, `36227206271667` |
| JCB | `3566002020360505` |
| UnionPay | `6200000000000005` |

In sandbox every test number succeeds with the same stand-in card; there is no decline path, whichever number you send. Listing a person's cards always answers an empty `data`, and removing a card always answers `404` `not_found`.

## Send a card

Send the card from your server to `secure.layout.link`, never to `api.layout.link`. The person must have verified their number with your application first (see [Verify their phone](https://docs.layout.link/provisioning#verify-their-phone)).

```
POST https://secure.layout.link/v1/users/usr_4b8e9a01c2d3/cards
Authorization: Bearer sk_live_…
Content-Type: application/json

{
  "card": { "number": "4242424242424242", "expMonth": 12, "expYear": 2030, "cvc": "123" },
  "consent": { "method": "checkbox", "textVersion": "2026-11-05", "at": "2026-11-06T17:02:11Z" }
}

→ 201
{
  "card": {
    "id": "card_3bf6b498-b614-4b58-8f04-36c3b2635a3c",
    "brand": "visa",
    "last4": "4242",
    "isDefault": false,
    "createdAt": "2026-11-06T17:02:12.000Z"
  }
}
```

| Field | Notes |
| --- | --- |
| `Authorization` | Your application's secret key as a bearer token, `sk_test_` or `sk_live_`. The key picks the environment, as on every other call. |
| `card.number` | Required. A string of digits. Spaces and dashes are removed. |
| `card.expMonth` | Required. An integer, 1 to 12. |
| `card.expYear` | Required. A four-digit integer, such as `2030`. |
| `card.cvc` | Required. A string of 3 or 4 digits. |
| `consent` | Required. How the person agreed to save the card with Layout. See [Consent](#consent). |

Put card details only inside `card`. A card number anywhere else in the body is refused with *"Send card details only inside card."* `secure.layout.link` answers only `POST /v1/users/{userId}/cards`; list and remove cards on `api.layout.link`.

The answer never carries the card number, expiry or security code: only `id`, `brand`, `last4`, `isDefault` and `createdAt`. Keep `id` to remove the card later.

`brand` is Stripe's display brand for the card, in lowercase with underscores: `american_express`, `cartes_bancaires`, `diners_club`, `discover`, `eftpos_australia`, `interac`, `jcb`, `mastercard`, `union_pay`, `visa` or `other`. Treat a value you do not know as `other`. `brand` and `last4` are `null` when Layout could not read them. Sandbox always answers `visa` and `4242`.

### Retrying

This call does not honour `Idempotency-Key`. A retry after a timeout can save the card a second time, and each save is billed. Before you retry, list the person's cards ([below](#list-and-remove-cards)) and send the card again only if it is not there.

### What happens to the card

- **One per person per application.** A new card for the same person replaces your last one. A replacement is a new card saved, and is billed.
- **It pays for your orders.** An order built through your application bills your card first. It never pays another application's orders unless the person makes it their default.
- **It is the person's.** Layout shows it to them as added by your application, and they can make it their default or remove it. It never becomes the default on its own, and it does not count toward their plan's card limit.
- **Layout tells them.** Every saved card sends the person a text, and an email when Layout has one, naming your application and the card's brand and last four.

## Consent

Before you send a card, show or read the person Layout's consent text and get their agreement. Report how in `consent`.

| Field | Notes |
| --- | --- |
| `method` | Required. One of `checkbox`, `button`, `sms_reply` or `voice`. |
| `textVersion` | Required. The version of Layout's wording you showed them. Today the only version is `2026-11-05`. |
| `at` | Required. When they agreed, ISO 8601, such as `2026-11-06T17:02:11Z`. No more than 30 days ago, not in the future, and not before they verified their number with your application. |

Version `2026-11-05` reads, with its placeholders filled in:

*"Save {brand} ending {last4} with Layout to pay for orders you place through {app}. Layout charges this card only when you place an order. You can remove it anytime."*

`{app}` is your application's name as registered with Layout. Show it in full and do not reword it.

| Method | What the person does |
| --- | --- |
| `checkbox` | Checks an unchecked box next to the text. Send the card only once it is checked. |
| `button` | Presses a button with the text beside or above it, labelled with what it does, such as Save card with Layout. |
| `sms_reply` | Replies to a text containing the consent text, from their own number, agreeing to it, such as YES. |
| `voice` | Agrees out loud after the text is read in full on a call. Keep a recording or transcript. |

A consent Layout cannot accept is `400` `invalid_request`, and the message says which part:

- *"consent is required: { method, textVersion, at }."*
- *"consent.method must be one of checkbox, button, sms_reply, voice."*
- *"consent.textVersion must name Layout's consent wording: 2026-11-05."*
- *"consent.at must be an ISO 8601 time, when the person agreed."*
- *"consent.at is in the future."*
- *"consent.at is more than 30 days ago; ask the person again."*
- *"consent.at is before this person verified their number with your application; ask them again."*

## List and remove cards

Both go to `api.layout.link` with your secret key, and see only the cards your application handed over, never the rest of the person's wallet.

```
GET /v1/users/usr_4b8e9a01c2d3/cards

→ 200
{ "data": [ { "id": "card_3bf6b498-b614-4b58-8f04-36c3b2635a3c", "brand": "visa", "last4": "4242", "isDefault": false, "createdAt": "2026-11-06T17:02:12.000Z" } ] }

DELETE /v1/users/usr_4b8e9a01c2d3/cards/card_3bf6b498-b614-4b58-8f04-36c3b2635a3c

→ 200
{ "id": "card_3bf6b498-b614-4b58-8f04-36c3b2635a3c", "removed": true }
```

When a person asks you to remove their card, or takes back their consent, remove it. A card paying an order still in progress cannot be removed yet: that is `409` `card_in_use`, so try again when the order finishes. A card id your application did not hand over for this person is `404` `not_found`. Disconnecting a person from your application removes your cards from their account too.

## Errors

Errors have the usual [shape](https://docs.layout.link/errors#error-shape). Switch on `code`. These come from `secure.layout.link` before the card reaches Layout:

| Error | Meaning |
| --- | --- |
| 400 `invalid_request` | `card` is missing, a card number is outside `card`, or a sandbox key sent a number that is not a test number. |
| 401 `unauthorized` | No secret key, or one that is not shaped like `sk_test_` or `sk_live_`. |
| 404 `invalid_request` | Anything but `POST /v1/users/{userId}/cards`. |
| 422 `invalid_request` | *"The card could not be accepted. Check the number, expiry and security code."* |

And these from Layout's API:

| Error | Meaning |
| --- | --- |
| 400 `invalid_request` | A card number was sent to `api.layout.link`, which refuses it before reading the request: *"Card numbers never go to api.layout.link. Send the card to https://secure.layout.link with the same path and key; Layout never receives the number."* Also: the request did not come through `secure.layout.link`, the [consent](#consent) was not accepted, or the same card request was sent twice (send it to `secure.layout.link` again). |
| 402 `billing_required` | Live only. Billing is not set up in the console (billing details, the signed agreement and a connected bank), or it is paused for an unpaid balance. Nothing was saved. Sandbox works without it. |
| 402 `billing_limit_reached` | Live only. Your account's unpaid balance is at its limit (see [Pricing](#pricing)), so cards are not saved until a payment clears. Nothing was saved. |
| 404 `not_found` | No such user for this application and environment, or one who disconnected from it. Also the answer when the Card Handoff API is off or paused for your application in this environment, or the method and path are wrong: *"No route answers…"*. On `DELETE`, no card your application handed over with that id. |
| 409 `person_not_ready` | The person has not verified their number with your application, or their Layout account is not ready to hold a card. Text them a code with `POST /v1/users/{userId}/verify/start`. |
| 409 `card_not_claimable` | That card is already on another Layout account, or on too many, so it was not saved. |
| 409 `card_in_use` | Your card for this person is paying an order still in progress. Send the new card when it finishes. |
| 422 `card_not_saved` | Layout did not save this card, and nothing was saved. The answer is the same whatever the reason, including a decline. |
| 429 `rate_limited` | Too many cards for this person this hour (10), for your application this hour (300 per environment), or for this person in the last 30 days (10 from your application). The hourly limits send `Retry-After` and `retryInSeconds`; the 30-day limit sends neither. See [Handling a 429](https://docs.layout.link/limits#handling-a-429). |
| 503 `unavailable` | Layout could not reach the person's account, or check your billing, right now. Nothing was saved. Retry shortly. |

A key Layout does not accept gets the usual authentication errors; see [Errors and status](https://docs.layout.link/errors).

### Paused for declines

Layout counts your application's live handoffs since the later of 24 hours ago and when live was last turned on for it. Handoffs refused for billing are not counted. Each time a card issuer declines a card, Layout checks that window: at 200 or more declines, or at least 20 handoffs with more than 30% of them declined, it pauses the Card Handoff API for your application until Layout reviews it. While paused, every Card Handoff call answers `404` `not_found`. Only issuer declines count; a mistyped number, security code or expiry, an expired card, and Layout's own failures do not.

## Going live

Live needs production access for your application first (see [Going to production](https://docs.layout.link/environments#going-to-production)). Then:

1. In the console, open [App details](https://console.layout.link/app-details) and choose **Go live** beside Card Handoff API under **Add-ons**.
2. Say how your app collects cards and the person's consent, and send the request. Layout emails you when it decides.
3. Once approved, open [Billing](https://console.layout.link/billing): add your billing details, sign the agreement, and connect your business checking account.

Live turns on for your application the moment you sign. Live cards are saved once the bank is connected; until then a live handoff answers `402` `billing_required`. If Layout does not approve, the email and **App details** say why, and you can ask again.

## Pricing

US$0.50 for each card saved in live, billed monthly for the month before by debit from your business checking account. Refused or failed requests, listing and removing cards, and sandbox are free. See [layout.link/pricing](https://layout.link/pricing).

Your account can owe up to US$250 at a time, counting debits from the last 60 days that a bank could still return. At the limit, live saves answer `402` `billing_limit_reached` until a payment clears.
