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, 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 (POST /v1/users/{userId}/links) instead.
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).
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. |
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) 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. 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 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), 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. |
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.
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). Then:
- In the console, open App details and choose Go live beside Card Handoff API under Add-ons.
- Say how your app collects cards and the person's consent, and send the request. Layout emails you when it decides.
- Once approved, open 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.
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.