# Card links

A short Layout link where a person you provisioned adds a card, with no Layout sign-in, and then goes straight back to your app.

The card page texts the person no code. Layout's own texts are on [Texts Layout sends](https://docs.layout.link/texts).

## Mint a link

```
POST /v1/users/usr_4b8e9a01c2d3/links
{
  "purpose": "card",
  "returnUrl": "com.acme.assistant:/layout/card",
  "state": "c1f9e2"
}

→ 201
{
  "url": "https://layout.link/h/3q2Xw7…",
  "expiresAt": "2026-10-21T17:15:00.000Z",
  "purpose": "card"
}
```

| Field | Notes |
| --- | --- |
| `purpose` | Required. `"card"`, the only purpose today. |
| `returnUrl` | Optional. Where the page sends the person when they finish. It must be exactly one of the [return URLs](#return-urls) registered on your application's **App details** page in the console. See [What comes back](#what-comes-back). |
| `state` | Optional. 1 to 512 characters, at most 1536 once percent-encoded, with no control characters. Layout hands it back to you on the return URL, so you can match the person who comes back to the link you sent. |

It takes your secret key and works for every application. The person must have verified their number with your application first (see [Verify their phone](https://docs.layout.link/provisioning#verify-their-phone)), or approved a connect link. A person you have not verified is `409` `person_not_ready`.

- **Single use.** The first open spends the link. Once open, the page keeps working for 30 minutes, reloads included.
- **15 minutes to open.** `expiresAt` says when. A link that expired or was already opened shows a page that tells the person to go back to your app and try again. Mint a new one then.
- **Mint it when you send it.** Layout keeps no copy of the url, so it cannot be read back later. Each call mints a new link.

## When to send one

Send a card link when Layout asks for a card. A build fails with `failure.code` `no_card` or `card_not_orderable` and `failure.next.step` `card_required`, or a confirm answers `403 build_only` with `error.next.step` `card_required`. For a person you provisioned neither carries a `url`, so mint a card link. You can also send one sooner, so the first order does not stop for a card: the verify answer and [GET /v1/users/:id](https://docs.layout.link/api#get-v1-users-id) carry `card.orderable`, and `false` means send one. See [When a card is needed](https://docs.layout.link/provisioning#adding-a-card).

## What the person sees

A full-screen page titled Pay with Layout, with your application's logo and name, which says *"{your app} partners with Layout to pay for your orders. You're only charged for orders you confirm, and {your app} never sees your card."* They enter a card, or use Apple Pay or Google Pay when their browser offers it. They do not sign in to Layout and do not make an account: verify already made it. A card link adds a card. On an account that already holds as many cards as it can, it replaces one. The page shows the card on file above the form before anything is typed, lets the person choose which card when the account holds more than one, and asks again before it saves. The new card takes the replaced card's place everywhere Layout charges, not only in your application.

When a card link replaces a card your application's card links did not add, Layout emails the person to say which card was replaced, by which application.

When the card is saved, or the person goes back, the page sends them to your return URL by itself. Without a return URL it stays there and says *"Card added. Go back to {your app}. You won't need to add a card there again."*, or *"No card added. Go back to {your app}."*, with no button.

The name and logo are the ones on your application's **App details** page in the console, shown once Layout approves your application. Before approval, a sandbox link shows your application's name with no logo. The card is entered on Layout's page and held by Layout. It never touches your servers, and you never see it.

## Return URLs

A return URL is where the card page sends the person when they finish. It is optional. A link can name only a return URL you registered for your application first, and the link must name it exactly, byte for byte. A trailing slash or a different port is a different URL.

### Register one in the console

1. Open your application in the console and choose [App details](https://console.layout.link/app-details) in the sidebar.
2. Under **Return URLs**, choose **Add return URL**.
3. Enter the URL in **Return URL** and choose **Add**. It shows in the list at once.

An application holds up to 10, and the same list serves your sandbox and production keys. The console takes:

- `https` with a host, such as a universal link (`https://acme.example/layout/card`).
- Your app's own custom scheme, such as `com.acme.assistant:/layout/card`.
- `http` on `localhost`, `127.0.0.1` or `[::1]`. The list marks these **Sandbox only**, and a live key cannot name one.

A return URL cannot carry a fragment, a wildcard, a user name or password, or a `state` or `status` parameter. It must be in its exact form, so `https://Acme.example/cb` is refused with *"Register the URL in its exact form: https://acme.example/cb"*. To remove one, choose the bin icon on its row, then **Remove** to confirm. Every link that names it stops sending people back, including links already sent. A link that names a URL you have not registered is `400` `invalid_request` when you mint it.

### What comes back

The card page sends the person's browser to your return URL with `state`, when you sent one, and then `status` added to its query. Any query your URL already has stays as you registered it, in front. Returns from a sandbox link look like this:

```
http://localhost:48123/layout/card?state=abc123&status=card_added
http://localhost:48123/layout/card?state=back-1&status=canceled
http://localhost:48123/layout/card?status=card_added
http://localhost:48123/layout/card?from=app&state=q1&status=canceled
```

| Parameter | Notes |
| --- | --- |
| `status` | `card_added` when the page saved a card. `canceled` when the person chose **Back** or closed the form without saving one, when an order was being paid for with the card the page would replace, or, rarely, when the account filled up while the page was open and nothing was saved. In that last case the return also carries `reason=account_full`, and the page says *"No room for another card"* with *"Your Layout account already holds all the cards it can, so nothing was added. Manage your cards at account.layout.link."* Only `status=canceled` ever carries a `reason`. |
| `state` | Exactly the `state` you sent when you minted the link, percent-encoded. A space is `%20`. Absent when you sent none. |

Nothing else is added, except `reason=account_full` on that one canceled return: no user id, no token, no card details and no signature. Read them with your platform's URL parser, such as `URLComponents` on iOS or `URLSearchParams` on the web, rather than matching the raw string.

- **It can come more than once.** If the person opens the same link again in the same browser within 30 minutes, the page sends them back with `status=card_added` again. Handle a repeat as a no-op.
- **It may never come.** The person can close the tab, or have no app installed for your custom scheme. When the page cannot leave, it shows **Back to {your app}** instead. Never wait on the return to move on.
- **It is not a webhook.** It is the person's own browser opening your URL. It reaches your server only when your return URL is a page on your server, and Layout sends no webhook when a card is added.

### Check it on your side

Anyone can open your return URL with any `status`, so treat it as a hint about where the person is, never as proof that a card is on file or that it will pay.

1. When you mint the link, send a fresh random `state` and keep it with the person and what they were doing, such as their cart.
2. When a return arrives, look up its `state`. Ignore one you did not send or have already handled.
3. On `card_added`, read [GET /v1/users/:id](https://docs.layout.link/api#get-v1-users-id) from your server. `card.orderable: true` means the card is there and Layout can order with it, so carry on with what they were doing. `false` means it did not save or cannot be used: mint a new link. If the person never comes back to your return URL, the same read tells you next time they open your app.
4. On `canceled`, say no card was added and offer to try again. The link is spent, so mint a new one. Keep the cart: a ready cart lasts 15 minutes.

## Open it in your app

- **iOS app.** Open the link as a sheet, never in your own `WKWebView`: a webview your app controls can read what the person types. Use `ASWebAuthenticationSession` with a custom scheme, or `SFSafariViewController` with a universal link.
- **Web app.** Open it in the same tab or a new one, with an `https` return URL on your site. Your page reads `state` and `status` from its own URL.
- **Agent, chat or SMS app.** Send the url in the conversation, with one line that says what it is. Register an `https` page on your site as the return URL, which says the card is added and to go back to the chat. Use its `state` to tell your agent the person is back. With no return URL, Layout's page tells them to go back to your app.

### iOS with a custom scheme

Register `com.acme.assistant:/layout/card` as a return URL and name it on the link. Pass only the scheme, with no colon, as `callbackURLScheme`. The session closes the sheet when the page sends the person back, and hands you the URL. It needs no URL type in your `Info.plist`. If the person closes the sheet, nothing comes back and the error is `.canceledLogin`: treat it as `canceled`.

```
let session = ASWebAuthenticationSession(url: cardLinkURL, callbackURLScheme: "com.acme.assistant") { callbackURL, error in
    guard let callbackURL,
          let items = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems else {
        handleCardLinkReturn(status: "canceled", state: nil)
        return
    }
    let status = items.first { $0.name == "status" }?.value
    let state = items.first { $0.name == "state" }?.value
    handleCardLinkReturn(status: status, state: state)
}
session.presentationContextProvider = self
session.start()
```

### iOS with a universal link

Register an `https` return URL on a domain your app claims in its associated domains, such as `https://acme.example/layout/card`, and open the link in `SFSafariViewController`. When iOS hands the return to your app, read it in `onOpenURL`, or in `scene(_:continue:)` for an `NSUserActivityTypeBrowsingWeb` activity, and dismiss the Safari view yourself. Serve a real page at that URL too. If iOS opens it in the browser instead, the person lands on that page, so give it a button back into your app.

Before you send the first link, tell the person about Layout in your own words. See [Introduce Layout first](https://docs.layout.link/provisioning#introduce-layout-first).

## In sandbox

A sandbox key mints a sandbox link. Its page collects no card and saves nothing: **Add test card** sends the person back with `status=card_added`, and **Back** with `status=canceled`. Sandbox builds and confirms never need a card, except for a person verified on a number in `+1 500 555 0050` to `0099`: that person has no card until they press **Add test card**, and then reads as a Visa ending 4242 and a confirm places. The person's own `card` reads that test Visa, while a sandbox cart's `card` is always `null`. Use it to test the round trip through your return URL, including a `localhost` one such as `http://localhost:3000/layout/card`. A sandbox build never answers `card_required`, so it cannot tell you whether a card is there.

## Errors

| Status | Code | Meaning |
| --- | --- | --- |
| 400 | `invalid_request` | `purpose` is not `"card"`, `state` breaks its rule, `returnUrl` is not exactly one of your registered return URLs, a `localhost` `returnUrl` was sent with a live key, or `returnUrl` with `state` and `status` added would pass 4000 characters. The message says which. |
| 404 | `not_found` | No such user for this application in this environment, or you disconnected them. |
| 409 | `person_not_ready` | The person has not verified their number with your application, or cannot hold a card on Layout yet. `error.next.step`, when present, names the step. Verify them, then mint the link. |
| 429 | `rate_limited` | More than 10 links for one person in 10 minutes, or 600 for one credential in an hour. Wait `Retry-After`. |
| 503 | `unavailable` | Layout could not check the person or the return URL just now. Nothing was minted. Retry shortly. |

> The card link is how a person adds a card. If you need card entry inside your own UI, write to [developer@layout.link](mailto:developer@layout.link).
