# Create hosted checkout pages with the Merchant API

> Create a payment page for an amount, serve it on your own domain in your own style, and learn when it is paid from the API or a webhook.

Source: https://www.coritan.com/docs/organizations/storefront/commerce-checkout-pages/

A *hosted checkout page* is a page you create for one amount. It has no products and no cart. We serve it on a domain of your own, and your customer sees the amount, a billing form and your own words and look. When the customer sends the form, they go on to your MAEF parent site, the *payment site*, and pay there. Nobody enters a card number on the page, and nothing on it names Coritan.

You style the whole page yourself with a set of style keys and your own CSS. Hosted checkout pages are optional, and our staff turn them on for each store.

The page is not a store checkout. It makes no order, no invoice and no entry on your balance. We tell you when the page is paid, as [Confirm the payment on your server](#confirm-the-payment-on-your-server) describes, and you refund at your payment site.

The routes are under `https://api.coritan.com/api/v1/orgs/{org_slug}/commerce`, and the API reference lists them under [Commerce](/docs/api/reference/organizations/commerce/commerce/). The dashboard does the same in **Checkout pages**, as [Create and style hosted checkout pages](/docs/organizations/commerce/checkout-pages/) describes.

## Before you begin

- Our staff turn hosted checkout pages on for a store: in the admin console they tick "Allow hosted checkout pages" on the store's Payments card. Ask [support](https://www.coritan.com/dashboard/support). Until then, every write answers `409` with `hosted_checkout_not_enabled`, and the two lists answer with `hosted_checkout_enabled: false`.
- The store needs to be in test or live mode. A live page needs a live store.
- Add at least one [payment page](/docs/organizations/storefront/commerce-payment-pages/) for the mode and currency you charge in, and turn it on. A store that has only hosted checkout pages can add payment pages without turning on payment pages at checkout.
- Have a hostname you can add DNS records to, such as `pay.example.com`. It serves checkout pages only.
- Creating and cancelling pages and managing domains takes an organization API key with the `commerce.checkout:write` scope, or a member's token with the Admin role or above. Reading them, and the preview, takes `commerce.checkout:read`, or the Billing role or above.

Send the key in the `X-API-Key` header, as [Authenticate](/docs/organizations/storefront/commerce-api/#authenticate) describes. The examples use `$ORG_API_KEY` and an organization called `acme`.

## Set up a checkout domain

A *checkout domain* is a hostname that serves checkout pages and nothing else. Any other address on it answers a plain "Page not found". It can sit on a different domain from the rest of your site.

> [!IMPORTANT]
> Never use the host of your storefront. A checkout host answers `/thank-you` itself, and your storefront's `/thank-you` belongs to your store's payment pages.

Add the hostname:

```bash
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/checkout-domains" \
  -H "X-API-Key: $ORG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"hostname": "pay.example.com"}'
```

The answer is `201` with the `checkout_domain`. Adding a name you already added answers with it as it is.

```json
{
  "checkout_domain": {
    "hostname": "pay.example.com",
    "verified": false,
    "verified_at": null,
    "records": [
      {"type": "TXT", "name": "_coritan-checkout.pay.example.com", "value": "3f9c1d7a5b2e4c80a6d1f0e9b8c7a654"},
      {"type": "CNAME", "name": "pay.example.com", "value": "edge.example.net"}
    ],
    "certificate": "none",
    "created_at": "2026-10-14T09:30:00Z"
  }
}
```

Publish the records at the DNS provider of the domain:

| Record | What it does |
| --- | --- |
| `TXT` at `_coritan-checkout.<hostname>` | Proves you own the name. Its `value` is a token we made for this hostname. |
| `CNAME` at `<hostname>` | Points the name at us. It is listed only when staff have set a target for it (the platform setting `commerce.checkout_cname_target`). Without one, `records` holds the TXT record alone, and the dashboard shows no CNAME. |

Then ask us to check the TXT record:

```bash
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/checkout-domains/pay.example.com/verify" \
  -H "X-API-Key: $ORG_API_KEY"
```

When the record is there, the answer has `verified: true`. We then order a certificate for the name, and `certificate` is one of:

| `certificate` | What it means |
| --- | --- |
| `none` | The domain is not verified yet. |
| `pending` | The domain is verified and its certificate is being issued. The name must already point at us for that to finish. |
| `active` | The certificate is installed and customers can open pages on the name. |

`GET /commerce/checkout-domains` lists the domains, oldest first, with `hosted_checkout_enabled`, `cname_target` (a name or `null`) and `limit`. Read it again until `certificate` is `active` before you send a customer to a page.

### What we refuse for a domain

| Answer | Why |
| --- | --- |
| `422` with `invalid_hostname` | The name is not a hostname. It needs at least two parts, no port and no IP address. A scheme, path, query or fragment is cut off. |
| `422` with `host_not_allowed` | The name belongs to the platform, is under the domain App Deployment gives to deployments, or is your storefront's domain or address. `reason` says which: `platform_host`, `app_domain` or `storefront`. |
| `409` with `host_in_use` | Another organization or a site of ours already uses the name. A name that another organization added and never verified frees up after 72 hours. |
| `409` with `limit_reached` | You have 10 domains. Remove one first. |
| `409` with `verification_failed` | The TXT record is missing or wrong. The answer lists what we found. DNS changes can take a while to spread, so check again in a few minutes. |
| `429` | You asked to verify one hostname more than 12 times in an hour, or changed domains more than 60 times in an hour. |

### Remove a domain

```bash
curl -X DELETE "https://api.coritan.com/api/v1/orgs/acme/commerce/checkout-domains/pay.example.com" \
  -H "X-API-Key: $ORG_API_KEY"
```

The answer is `{"deleted": true}`. Pages already made on that hostname stop opening. While a page there is `processing` and its customer started to pay in the last 30 minutes, it answers `409` with `domain_in_use`. Remove the domain once that payment is done.

## Tell the payment site where to send the customer

After the customer pays, the payment site sends them back to your checkout host with `maef_complete`, `sig` and `maef_parent` in the query. That return is how we learn the page is paid. We check its signature with the secret of your payment page, and nothing else makes a page `paid`.

We hand the payment site the return address of each page, `https://<your checkout host>/c/<page id>/return`, and your checkout host as the child site's domain. We also answer `/thank-you` on the checkout host. There we find the page from the signed order number in the return, or from the customer's email and the amount when the return has no order number.

> [!WARNING]
> We have not tested hosted checkout pages against a live payment site. A payment site that sends customers only to the address it holds for your storefront will send them there instead, where the store does not recognise a page's payment. Register your checkout host with the payment site the way you registered your storefront, and make a small test payment before you send a customer to a page.

If the return never arrives, the page stays `processing`. [Troubleshooting](#troubleshooting) says what to check.

## Create a page

A page needs an `amount` and a `return_url`, the address the customer came from. Everything else is optional.

```bash
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/checkout-pages" \
  -H "X-API-Key: $ORG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 2500,
    "currency_code": "USD",
    "livemode": true,
    "reference": "INV-1042",
    "description": "Annual hosting plan",
    "return_url": "https://example.com/invoices/1042",
    "success_url": "https://example.com/thanks",
    "cancel_url": "https://example.com/invoices/1042?cancelled=1",
    "host": "pay.example.com",
    "expires_in_minutes": 4320,
    "style": {"heading": "Pay invoice INV-1042", "accent_color": "#0f766e"}
  }'
```

| Field | Takes |
| --- | --- |
| `amount` | Required. What the customer pays, as a whole number of minor units such as cents, from 1 to 99,999,999. |
| `currency_code` | An ISO 4217 code such as `USD`. It must be one of the store's currencies. The store's default currency when left out. |
| `livemode` | `true` makes a live page, which needs a live store. `false`, the default, makes a test page. |
| `reference` | Your own reference, such as an invoice number, up to 255 characters. `GET /commerce/checkout-pages` filters by it. |
| `description` | The name of the payment line on your payment site's own page, up to 120 characters. It is `Payment` when left out. The customer never sees it on the checkout page. |
| `parent_product_id` | The product number your payment site uses for the payment line, a whole number of 0 or more. `0` by default. |
| `style` | Style keys for this page. They are laid over the store's default style, as [Style the page](#style-the-page) describes. |
| `return_url` | Required. Where the customer came from, such as the invoice page on your site. An `https` address of up to 2,000 characters, with no spaces and no sign-in details. [Send the customer back](#send-the-customer-back) says where we use it. |
| `success_url` | An `https` address, with the same limits, that a customer who has paid goes to instead of `return_url`. |
| `cancel_url` | An `https` address, with the same limits, that a customer who leaves the form without paying goes to instead of `return_url`. |
| `host` | One of your verified checkout domains. Your oldest verified domain when left out. |
| `expires_in_minutes` | How long the page takes payments, from 5 to 43,200. `1440`, one day, when left out. |

The answer is `201` with the page and its `url`, the address to send the customer to:

```json
{
  "checkout_page": {
    "id": "chk_01j9k2m4n6p8q0r2s4t6v8w0x2",
    "livemode": true,
    "status": "open",
    "amount": 2500,
    "currency_code": "USD",
    "reference": "INV-1042",
    "description": "Annual hosting plan",
    "parent_product_id": 0,
    "host": "pay.example.com",
    "url": "https://pay.example.com/c/chk_01j9k2m4n6p8q0r2s4t6v8w0x2",
    "return_url": "https://example.com/invoices/1042",
    "success_url": "https://example.com/thanks",
    "cancel_url": "https://example.com/invoices/1042?cancelled=1",
    "email": null,
    "expires_at": "2026-10-17T09:35:00Z",
    "paid_at": null,
    "parent_order_id": null,
    "attempts": 0,
    "style": {"heading": "Pay invoice INV-1042", "accent_color": "#0f766e", "theme": null, "font": null, "custom_css": null},
    "created_at": "2026-10-14T09:35:00Z"
  }
}
```

The `style` is the merged style we saved on the page, with every key present. It is shortened here. `success_url` and `cancel_url` are `null` when you did not send them. `email` is the customer's address once they have sent the form, and `parent_order_id` is the payment site's own number for the payment once it is paid.

`GET /commerce/checkout-pages/{checkout_page_id}` answers one page. `GET /commerce/checkout-pages` lists them, newest first, and takes these query parameters:

| Parameter | Takes |
| --- | --- |
| `status` | `open`, `processing`, `paid`, `failed`, `canceled` or `expired`. A page whose time has passed counts as `expired`. Any other word answers `422`. |
| `reference` | Only pages with exactly this reference. |
| `livemode` | `true` for live pages, `false` for test pages. Both when left out. |
| `limit` | How many pages, 1 to 200. `50` by default. |
| `before_id` | Send the `next_before_id` of the last answer to read the next page. |

The answer is `{"checkout_pages": [...], "next_before_id": ..., "hosted_checkout_enabled": true}`. `next_before_id` is `null` on the last page.

### What we refuse for a page

| Answer | Why |
| --- | --- |
| `409` with `hosted_checkout_not_enabled` | Staff have not turned hosted checkout pages on for the store. Ask support. |
| `409` with `checkout_domain_required` | You have no verified checkout domain. |
| `409` with `no_payment_page` | The store has no payment page that is turned on for that mode and currency. |
| `403` with `store_not_live` | A live page on a store that is not live, or a page on a store that is not open, such as a suspended one. |
| `422` with `field` | The input is wrong, and `field` names it: `amount`, `currency_code`, `reference`, `description`, `parent_product_id`, `return_url`, `success_url`, `cancel_url`, `expires_in_minutes`, `style` or a style key such as `style.accent_color` or `style.custom_css`. A `host` that is not one of your verified checkout domains names `host`. An empty `return_url`, or one that is not an `https` address, a `return_url` over 2,000 characters, or one with spaces or sign-in details, names `return_url`. |
| `422` for a missing `return_url` | The request has no `return_url`, or sends `null` for it. The answer is the usual validation error: `detail` lists an entry with `loc` set to `["body", "return_url"]` and `type` set to `missing`. We save no page. |
| `429` with `rate_limited` | You created more than 600 pages in an hour. `Retry-After` says when to try again. |

### Send the customer back

Every page needs a `return_url`, the address the customer came from. A page with no way back is never made. We send the customer back to your site in these situations:

| Situation | The customer goes to |
| --- | --- |
| The customer pays and returns from the payment site | We redirect with `303` to `success_url`, or to `return_url` when you gave no `success_url`. The customer sees no receipt. |
| The customer opens a paid page again | A receipt with a "Continue" link to the same address. |
| The customer leaves from the billing form | The "Cancel and go back" link, always shown, goes to `cancel_url`, or to `return_url` when you gave no `cancel_url`. |
| Any other page that knows its page: confirming, failed, held for review, expired or cancelled | A "Return to site" link to `return_url`, beside "Try again" or "Check again" where the page has them. |
| A page that does not exist, and error pages | No link. Nothing says which page the customer meant. |

We add `checkout_page=<id>&status=<status>` to the query of each address, and keep any query and fragment it already has. If the query already has a `checkout_page` or a `status`, ours replace them. We send the address as plain ASCII, so a host with other characters goes out as punycode and any other character is percent-encoded. `status` is the page's status at the moment we make the link: `open`, `processing`, `paid`, `failed`, `canceled` or `expired`. A customer can follow a link before we have applied the payment site's return, so the status can be behind.

Confirm the payment through the API or the event, never from that query, as [Confirm the payment on your server](#confirm-the-payment-on-your-server) describes.

## Style the page

Every key is optional. The page has a built-in look, and each key you set changes one part of it.

### The default style and the style of one page

The store has a default style, which every page uses. Set it with `PATCH /commerce/store`, and send `null` to clear it:

```bash
curl -X PATCH "https://api.coritan.com/api/v1/orgs/acme/commerce/store" \
  -H "X-API-Key: $ORG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"settings": {"checkout_style": {"accent_color": "#0f766e", "font": "inter", "radius": "round"}}}'
```

This needs the `commerce.store:write` scope. `GET /commerce/store` shows the default in `settings.checkout_style`.

The `style` you send with a page is laid over the default. A key you send wins, a key you leave out keeps the default, and a key set to `null` goes back to its built-in value. We save the merged result on the page, so changing the default later does not move pages you made already.

A key that is not a style key, or has a bad value, answers `422` with `field` set to `style.<key>`, or to `settings.checkout_style` when you set the default.

### The style keys

| Key | Takes |
| --- | --- |
| `theme` | `light` or `dark`. Light by default. |
| `accent_color` | A colour as `#rrggbb`. The button and the links. |
| `background_color` | A colour as `#rrggbb`. Behind the card. |
| `surface_color` | A colour as `#rrggbb`. The card. A `plain` layout has no card, so it uses the background colour. |
| `text_color` | A colour as `#rrggbb`. Headings and body text. |
| `radius` | `sharp`, `soft` or `round`. The corners of the card, the button and the fields. `soft` by default. |
| `font` | `system`, `inter`, `plex`, `serif` or `mono`. `system` by default. |
| `layout` | `card` or `plain`. `card` by default. |
| `logo_url` | An `https` address, up to 2,048 characters, with no spaces or quotes. |
| `heading` | Up to 120 characters. `Complete your payment` when empty. |
| `message` | Up to 300 characters. A line under the heading. Left out of the page when empty. |
| `amount_label` | Up to 60 characters. `Amount due` when empty. |
| `billing_title` | Up to 60 characters. `Billing details` when empty. |
| `button_label` | Up to 40 characters. `Pay now` when empty. |
| `footer_text` | Up to 200 characters. Left out of the page when empty. |
| `success_title` | Up to 120 characters. `Payment received` when empty. |
| `success_message` | Up to 300 characters. Left out of the page when empty. |
| `links` | Up to 3 links in the footer, each `{"label": "Terms", "url": "https://example.com/terms"}`. A label has up to 30 characters, and an address is `https` or `mailto:`. |
| `custom_css` | Your own stylesheet, up to 20,000 characters. |

A colour you choose is a starting point. We work out the other colours of the page from it (muted text, borders, the text on the button) and change a text or link colour that would be hard to read on the colour behind it. Text is plain text, and we escape it on the page. A line break in a one-line text, or a control character in any text, is refused. The three longer texts hold one paragraph, and we turn a line break in them into a space.

The page names a font stack and downloads no font. `inter` and `plex` use that font when the customer's device has it, and the closest sans-serif font when it does not.

The page shows the logo from a copy we make: we fetch the address once and place the image in the page itself. A logo we cannot fetch is left out of the page.

### Custom CSS

`custom_css` is added after the style above, so it can override anything. We accept style rules and `@media` and `@supports` blocks. Everything else is refused: when you send CSS, the request answers `422` with `field` set to `style.custom_css` and a `message` that names the first problem, and nothing is saved. We save the CSS rewritten in a compact form, so it reads back differently from what you sent.

The CSS cannot load anything. We refuse every way a stylesheet could name a file or run code:

- `@import`, `@font-face`, `@keyframes`, `@namespace`, `@charset` and `@page`.
- `url()`, `image-set()`, `image()`, `element()`, `cross-fade()` and `src()` in any value. A `background` or `background-image` can hold gradients only, so the CSS cannot load fonts or images.
- `expression()`, `javascript:`, `behavior`, `binding`, `@`, `<`, backslashes and comments inside values and selectors.

We also refuse a property that is not on our list. The families on the list are:

- Colour: `color`, `accent-color`, `caret-color` and `color-scheme`.
- Background: `background` and its parts, with gradients only for the image.
- Borders, outlines and corners: `border` and its parts (not `border-image`), `border-radius` and `outline`.
- Box model and position: `margin`, `padding`, `width`, `height`, their minimums and maximums, `aspect-ratio`, `box-sizing`, `display`, `position` (`static`, `relative`, `absolute` or `sticky`), `top`, `right`, `bottom`, `left`, `inset`, `z-index`, `float`, `overflow` and `visibility`.
- Type: `font` and every `font-*` property, `line-height`, `letter-spacing`, `word-spacing`, `text-align`, `text-transform`, `text-decoration`, `text-overflow`, `text-indent`, `white-space`, `word-break` and `overflow-wrap`.
- Layout: flex and grid properties, `gap`, `order`, `align-*`, `justify-*` and `place-*`.
- Effects: `box-shadow`, `text-shadow`, `opacity`, `transition`, `transform`, `cursor`, `appearance` and `object-fit`.
- `content`, which takes a string, `none` or `normal`.
- Custom properties that start with `--`.

Properties such as `animation`, `filter`, `list-style` and `pointer-events` are not on the list.

A selector can use element names, classes, ids, attribute selectors, pseudo classes, pseudo elements and combinators. `!important` is accepted.

### Class names and variables

The page marks its parts with these classes, and they stay the same between releases:

| Class | Part of the page |
| --- | --- |
| `.page` | The `body`, which also has `.layout-card` or `.layout-plain` and `.theme-light` or `.theme-dark`. |
| `.card` | The box around everything. With the `plain` layout it has no box. |
| `.logo` | The logo image. |
| `.heading` | The heading. |
| `.message` | The line under the heading. |
| `.amount-box` | The box that holds the amount. |
| `.amount-label` | The words before the amount. |
| `.amount` | The amount. |
| `.form` | The billing form. |
| `.section-title` | The form's title. |
| `.field` | One field with its label. Each also has `.field-<name>`, such as `.field-email`. |
| `.label` | A field's label. |
| `.input` | A text field. |
| `.select` | The country list. |
| `.error` | A message about the form. |
| `.button` | The pay button, and the buttons on the pages that follow a payment. |
| `.cancel-link` | The links that lead away from a page: "Cancel and go back", "Return to site" and "Check again". |
| `.footer` | The footer. |
| `.footer-text` | The footer's text. |
| `.links` | The footer's links. |
| `.notice` | A note on the pages that follow a payment. |

The page also sets CSS variables on `:root`, which your CSS can read with `var()` or set again: `--accent`, `--on-accent`, `--bg`, `--surface`, `--text`, `--muted`, `--faint`, `--line`, `--line-strong`, `--panel`, `--link`, `--error`, `--error-bg`, `--ring`, `--radius`, `--radius-button`, `--radius-input`, `--font` and `--scheme`.

### An example

```json
{
  "theme": "dark",
  "accent_color": "#22c55e",
  "background_color": "#0b1020",
  "surface_color": "#131a2e",
  "text_color": "#e6e9f2",
  "radius": "round",
  "font": "inter",
  "layout": "card",
  "logo_url": "https://example.com/assets/logo.png",
  "heading": "Pay your invoice",
  "message": "Enter your billing details. You pay on the next page.",
  "amount_label": "Total due",
  "billing_title": "Billing address",
  "button_label": "Continue to payment",
  "footer_text": "Questions about this invoice? Write to billing@example.com.",
  "success_title": "Thank you",
  "success_message": "We received your payment.",
  "links": [
    {"label": "Terms", "url": "https://example.com/terms"},
    {"label": "Support", "url": "mailto:billing@example.com"}
  ],
  "custom_css": ".card { max-width: 520px; }\n.amount { letter-spacing: .02em; }\n.button { text-transform: uppercase; letter-spacing: .06em; }\n.input:focus { box-shadow: 0 0 0 3px rgba(34, 197, 94, .35); }\n@media (max-width: 520px) { .heading { font-size: 20px; } }"
}
```

### Preview a style

`POST /commerce/checkout-pages/preview` draws a page with sample data and creates nothing. It takes the `commerce.checkout:read` scope, and answers `429` with `rate_limited` beyond 60 previews a minute for the organization.

| Field | Takes |
| --- | --- |
| `style` | Style keys to try, laid over the store's default style as for a page. |
| `amount` | The sample amount in minor units, 1 to 99,999,999. `2500` by default. |
| `currency_code` | The sample currency, one of the store's. The store's default currency when left out. |
| `state` | `form`, `paid`, `processing`, `failed`, `expired` or `canceled`. Any other word draws the form. |

```bash
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/checkout-pages/preview" \
  -H "X-API-Key: $ORG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"style": {"theme": "dark", "accent_color": "#22c55e"}, "state": "paid"}' \
  | jq -r .html > preview.html
```

The answer is `{"html": "<!doctype html>..."}`, the page as a customer sees it. Open `preview.html` in a browser. A style the page would refuse answers the same `422` as a page does.

## How a payment goes

1. You create a page and send the customer its `url`.
2. The customer opens the page, which shows the amount and the billing form in your style.
3. The customer sends the form. We check it, and the page becomes `processing`. We hand the amount to your payment site, signed with its secret, as one payment line.
4. We send the customer to the payment site's own page, and they pay there.
5. The payment site sends the customer back to your checkout host with its signed result, as [Tell the payment site where to send the customer](#tell-the-payment-site-where-to-send-the-customer) describes. We check the signature and keep the return once.
6. A payment that succeeded makes the page `paid`. We send `commerce.checkout_page.paid`, and the customer goes to your `success_url`, or to your `return_url` when you gave no `success_url`, as [Send the customer back](#send-the-customer-back) describes.

A customer who sends the form again within 10 minutes with the same details goes to the same payment address, and we ask the payment site nothing.

### The page's statuses

| Status | What it means |
| --- | --- |
| `open` | No customer has started to pay. |
| `processing` | The customer sent the form, and we are waiting for the payment site's return. A payment the payment site holds for review leaves the page here. |
| `paid` | The payment site reported a payment for the page's amount and currency. |
| `failed` | The payment site reported that the payment failed. The customer can open the page and pay again. |
| `canceled` | You cancelled the page. |
| `expired` | The page's time passed and nobody is paying on it. A page shows `expired` in the API as soon as its time has passed. A page does not expire within 10 minutes of a customer's last attempt. |

A page takes a payment while it is `open`, `processing` or `failed`. A customer who opens a `paid`, `canceled` or `expired` page sees that, cannot pay, and has a link back to your site.

### Late payments, repeats and other amounts

A payment that arrives after the page expired still pays it, when its amount and currency are the page's. Some payments cannot pay a page. We keep them as `unmatched` in the page's events, and we send `commerce.checkout_page.refund_required` so you refund them at your payment site. We refund nothing.

| `reason` | What happened |
| --- | --- |
| `canceled` | The customer paid a page you had cancelled. |
| `second_payment` | The page was already paid, and the payment site sent another payment for it. |
| `amount_mismatch` | The payment site reports another amount or currency than the page asked for. |
| `no_page` | The return names no page of yours. |

The same return, passed again, shows what the first one came to and sends no new event.

### Cancel a page

```bash
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/checkout-pages/chk_01j9k2m4n6p8q0r2s4t6v8w0x2/cancel" \
  -H "X-API-Key: $ORG_API_KEY"
```

The answer is `{"checkout_page": {...}}`. You can cancel an `open`, `failed` or `expired` page, and a `processing` page whose last attempt is older than 30 minutes. Cancelling a cancelled page changes nothing.

| Answer | Why |
| --- | --- |
| `409` with `payment_in_progress` | A customer started to pay in the last 30 minutes. Try again later. |
| `409` with `checkout_page_unavailable` | The page is paid. |
| `404` | The page is not yours. |

> [!CAUTION]
> A customer who pays after you cancel a page is still charged by the payment site. We send `commerce.checkout_page.refund_required` with the `reason` `canceled`, and you refund the payment.

### Read what the payment site sent back

`GET /commerce/checkout-pages/{checkout_page_id}/events` lists the signed returns of one page, newest first. It takes `limit` (1 to 200, `50` by default) and `before_id`, and answers `{"events": [...], "next_before_id": ...}`.

```json
{
  "events": [
    {
      "id": 311,
      "page_id": "chk_01j8z4a7b2c9d3e6f5g8h1k0m4",
      "connector_id": 4,
      "event_id": "maef-98765-succeeded",
      "type": "payment.succeeded",
      "status": "processed",
      "detail": {
        "reference": "maef-parent-98765",
        "parent_order_id": "98765",
        "payment_status": "completed",
        "email": "ada@example.com",
        "amount": 2500,
        "currency_code": "USD",
        "wc_order_id": "2000000087",
        "outcome": "paid"
      },
      "created_at": "2026-10-14T10:02:41Z"
    }
  ],
  "next_before_id": null
}
```

`type` is `payment.succeeded`, `payment.pending` (held for review) or `payment.failed`. `status` is `processed` when the return changed the page, `unmatched` when it was a payment that cannot pay the page (`detail.reason` says why), and `ignored` when it changed nothing. `connector_id` is the id of your payment page, and `page_id` is the page's own `chk_` id.

## Confirm the payment on your server

Read the page, or take the webhook. The `checkout_page` and `status` that we add to your `success_url`, `return_url` and `cancel_url` are in the customer's browser address, so anyone can type them. Never mark an invoice paid from them.

With the API, read the page and check that `status` is `paid`:

```bash
curl "https://api.coritan.com/api/v1/orgs/acme/commerce/checkout-pages/chk_01j9k2m4n6p8q0r2s4t6v8w0x2" \
  -H "X-API-Key: $ORG_API_KEY"
```

With a webhook, subscribe to the three events, or to `*`, as [Receive commerce webhooks](/docs/organizations/storefront/commerce-api/#receive-commerce-webhooks) describes. Each event has an `id` that you can use to skip one you handled. `resource.id` is `null`: the page is `data.checkout_page`.

`commerce.checkout_page.paid`: the page was paid.

```json
{
  "id": "evt_48311",
  "type": "commerce.checkout_page.paid",
  "livemode": true,
  "created_at": "2026-10-14T10:02:41Z",
  "resource": {"type": "checkout_page", "id": null},
  "data": {"checkout_page": "chk_01j9k2m4n6p8q0r2s4t6v8w0x2", "reference": "INV-1042", "amount": 2500, "currency_code": "USD", "parent_order_id": "98765", "email": "ada@example.com", "livemode": true}
}
```

`commerce.checkout_page.failed`: the payment site reported a failed payment.

```json
{
  "id": "evt_48312",
  "type": "commerce.checkout_page.failed",
  "livemode": true,
  "created_at": "2026-10-14T10:04:09Z",
  "resource": {"type": "checkout_page", "id": null},
  "data": {"checkout_page": "chk_01j9k2m4n6p8q0r2s4t6v8w0x2", "reference": "INV-1042", "amount": 2500, "currency_code": "USD", "parent_order_id": "98766", "email": "ada@example.com", "livemode": true}
}
```

`commerce.checkout_page.refund_required`: a payment that cannot pay the page. `amount` is what the payment site took, which can differ from the page's amount. `checkout_page`, `reference` and `currency_code` can be `null` when the return named no page.

```json
{
  "id": "evt_48320",
  "type": "commerce.checkout_page.refund_required",
  "livemode": true,
  "created_at": "2026-10-14T11:20:33Z",
  "resource": {"type": "checkout_page", "id": null},
  "data": {"checkout_page": "chk_01j9k2m4n6p8q0r2s4t6v8w0x2", "reference": "INV-1042", "amount": 2500, "currency_code": "USD", "parent_order_id": "98771", "email": "ada@example.com", "livemode": true, "reason": "second_payment"}
}
```

`GET /commerce/events` shows the same events to a caller with `commerce.store:read`, and shows the `email` of these three only to a caller who also has `commerce.checkout:read`.

## Refunds

You refund at your payment site. Nothing here moves money, books anything on your balance, issues an invoice or creates an order, because a hosted checkout page is not a store order.

## Limits

| What | Limit |
| --- | --- |
| Checkout domains | 10 for each organization. |
| Pages created | 600 an hour for each organization. |
| Domain changes (add and remove) | 60 an hour for each organization. |
| Domain checks | 12 an hour for each hostname. |
| Previews | 60 a minute for each organization. |
| Payment attempts by customers | 10 for each page and 30 for each network address in 10 minutes. |
| Returns from the payment site | 600 with a good signature in 10 minutes for each organization. A wrong signature counts against 60 in 10 minutes, and never uses up the 600. |
| Page life | 5 to 43,200 minutes, `1440` by default. |

A return that the payment site signed more than 15 minutes ago, and that we have not had before, is not taken. The customer sees that we could not confirm the payment, the page stays as it was, and you check the payment at your payment site.

### What a customer sees

| Situation | The customer sees |
| --- | --- |
| A mistake in the billing form | The form again with what they typed and a message under each field. |
| Too many attempts | "Too many attempts. Try again in a few minutes." |
| The payment site refused the amount or did not answer | "We could not start the payment", with a "Try again" button. |
| No payment page is turned on for the page's mode and currency any more | "This payment page is not available". |
| A payment still being confirmed | "We are confirming your payment", with a "Check again" link, a "Return to site" link and a note not to pay again. |
| A payment that failed | "The payment did not go through", with a "Try again" button and a "Return to site" link. |
| A payment held on review, or one that cannot pay the page | "We received your payment and are checking it. You do not need to pay again." It has a "Return to site" link. |
| A return we cannot confirm | "We could not confirm this payment. If you were charged, contact the seller." |
| A cancelled or expired page | The page says so and tells them to ask the seller for a new payment link. It has a "Return to site" link. |
| A page that does not exist, or whose store has hosted checkout turned off | "Page not found", with no link. |
| A paid page opened again | The receipt, with a "Continue" link to your `success_url`, or to your `return_url` when you gave no `success_url`. |
| The billing form | A "Cancel and go back" link to your `cancel_url`, or to your `return_url` when you gave no `cancel_url`. |

When staff turn hosted checkout pages off for a store, the checkout host stops showing forms and taking payments. A return for a `processing` page is still taken.

## Result

Your checkout host opens each page you create in your style, with the amount and the billing form. A customer who pays at your payment site comes back to your host, the page is `paid`, the customer lands on your `success_url` or `return_url`, and you have the `commerce.checkout_page.paid` event and `status: "paid"` in the API.

## Troubleshooting

`409` with `hosted_checkout_not_enabled`
: Staff have not turned hosted checkout pages on for the store. Ask [support](https://www.coritan.com/dashboard/support).

`409` with `checkout_domain_required`
: Add a checkout domain and verify it, as [Set up a checkout domain](#set-up-a-checkout-domain) describes.

`409` with `no_payment_page`
: The store has no payment page that is turned on for the page's mode and currency. Add or turn on a [payment page](/docs/organizations/storefront/commerce-payment-pages/), and check that its `currencies` list takes the currency.

`409` with `verification_failed`
: We did not find the TXT record. The answer says what we found. Publish `_coritan-checkout.<hostname>` with the token from `records`, wait a few minutes, and verify again.

`certificate` stays `pending`
: The name must point at us before the certificate can be issued. Publish the CNAME record when `records` lists one, and read the domain again in a few minutes. When `records` lists none, ask [support](https://www.coritan.com/dashboard/support) where to point the name.

`422` with `field` set to `style.custom_css`
: The `message` names the first rule, property or value we refused. Remove it and send the CSS again. [Custom CSS](#custom-css) lists what we accept.

The customer paid and the page stays `processing`
: We have not had the payment site's return. Read the page's events: an empty list means no return reached us. Check that the payment site sends customers back to your checkout host and not to your storefront, as [Tell the payment site where to send the customer](#tell-the-payment-site-where-to-send-the-customer) describes. The payment site's own record of the payment shows whether the customer paid.

The page shows "We received your payment and are checking it"
: The payment site held the payment for review, or sent a payment that cannot pay the page. Read the page's events and check for `commerce.checkout_page.refund_required`. Refund an unmatched payment at your payment site.

`success_url` or `return_url` opens, but the page is not `paid`
: The address a customer opens proves nothing, and its `status` can be behind or typed by hand. Read the page with the API, and wait for the return if it is still `processing`.

`422` and `detail` names `["body", "return_url"]`
: The request has no `return_url`. Add the address the customer came from, as [Send the customer back](#send-the-customer-back) describes.

`422` with `field` set to `return_url`
: The address is empty, is not `https`, is over 2,000 characters, or has spaces or sign-in details. The same limits apply to `success_url` and `cancel_url`.

A customer has no link back from a page
: Every page that knows its page has one. A page that does not exist, and an error page, have none. Check the page's `url` for a typing mistake.

## Related

- [Create and style hosted checkout pages](/docs/organizations/commerce/checkout-pages/)
- [Take payments through a MAEF parent site](/docs/organizations/storefront/commerce-payment-pages/)
- [Sell with the Commerce API](/docs/organizations/storefront/commerce-api/)
- [Create organization API keys](/docs/organizations/api-keys/)
- [Receive organization webhooks](/docs/organizations/webhooks/)

## API

- `GET /api/v1/orgs/{org_slug}/commerce/checkout-pages`: List the organization's hosted checkout pages, newest first (https://www.coritan.com/docs/api/reference/organizations/commerce/commerce/#op-get-api-v1-orgs-org-slug-commerce-checkout-pages)
- `POST /api/v1/orgs/{org_slug}/commerce/checkout-pages`: Make a hosted checkout page for an amount, and get its address (https://www.coritan.com/docs/api/reference/organizations/commerce/commerce/#op-post-api-v1-orgs-org-slug-commerce-checkout-pages)
- `POST /api/v1/orgs/{org_slug}/commerce/checkout-pages/preview`: Draw a hosted checkout page with sample data, to see a style before using it (https://www.coritan.com/docs/api/reference/organizations/commerce/commerce/#op-post-api-v1-orgs-org-slug-commerce-checkout-pages-preview)
- `GET /api/v1/orgs/{org_slug}/commerce/checkout-pages/{checkout_page_id}`: Read one hosted checkout page by its id (chk...) (https://www.coritan.com/docs/api/reference/organizations/commerce/commerce/#op-get-api-v1-orgs-org-slug-commerce-checkout-pages-checkout-page-id)
- `POST /api/v1/orgs/{org_slug}/commerce/checkout-pages/{checkout_page_id}/cancel`: Cancel a hosted checkout page so it takes no more payments (https://www.coritan.com/docs/api/reference/organizations/commerce/commerce/#op-post-api-v1-orgs-org-slug-commerce-checkout-pages-checkout-page-id-cancel)
- `GET /api/v1/orgs/{org_slug}/commerce/checkout-pages/{checkout_page_id}/events`: List what the parent site reported for one page, newest first (https://www.coritan.com/docs/api/reference/organizations/commerce/commerce/#op-get-api-v1-orgs-org-slug-commerce-checkout-pages-checkout-page-id-events)
- `GET /api/v1/orgs/{org_slug}/commerce/checkout-domains`: List the organization's checkout domains, oldest first (https://www.coritan.com/docs/api/reference/organizations/commerce/commerce/#op-get-api-v1-orgs-org-slug-commerce-checkout-domains)
- `POST /api/v1/orgs/{org_slug}/commerce/checkout-domains`: Add a hostname to serve checkout pages on (https://www.coritan.com/docs/api/reference/organizations/commerce/commerce/#op-post-api-v1-orgs-org-slug-commerce-checkout-domains)
- `POST /api/v1/orgs/{org_slug}/commerce/checkout-domains/{hostname}/verify`: Check the TXT record now and, when it is there, verify the hostname (https://www.coritan.com/docs/api/reference/organizations/commerce/commerce/#op-post-api-v1-orgs-org-slug-commerce-checkout-domains-hostname-verify)
- `DELETE /api/v1/orgs/{org_slug}/commerce/checkout-domains/{hostname}`: Remove a checkout domain and stop serving pages on it (https://www.coritan.com/docs/api/reference/organizations/commerce/commerce/#op-delete-api-v1-orgs-org-slug-commerce-checkout-domains-hostname)
