# 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://developer.layout.link/reference/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://developer.layout.link/reference/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://developer.layout.link/reference/api#get-v1-users-id) carry `card.orderable`, and `false` means send one. See [When a card is needed](https://developer.layout.link/reference/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, and the page names that card and asks before it saves:

- **An account nobody signs in to Layout with** (every account a verify made, until the person signs in): it replaces the default card. A card link is the only way to change the card on such an account.
- **An account the person signs in to**: it replaces only a card your own card links added, never one the person or another application added. If there is none, nothing is saved and going back returns `status=canceled`.

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://developer.layout.link/console/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, or when their account already held all the cards it can 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://developer.layout.link/reference/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://developer.layout.link/reference/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. 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).
