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.
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 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. The dashboard does the same in Checkout pages, as Create and style hosted checkout pages describes.
Before you begin
Section titled 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. Until then, every write answers
409withhosted_checkout_not_enabled, and the two lists answer withhosted_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 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:writescope, or a member's token with the Admin role or above. Reading them, and the preview, takescommerce.checkout:read, or the Billing role or above.
Send the key in the X-API-Key header, as Authenticate describes. The examples use $ORG_API_KEY and an organization called acme.
Set up a checkout domain
Section titled Set up a checkout domainA 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:
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.
{
"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:
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
Section titled 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
Section titled Remove a domaincurl -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
Section titled Tell the payment site where to send the customerAfter 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 says what to check.
Create a page
Section titled Create a pageA page needs an amount and a return_url, the address the customer came from. Everything else is optional.
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 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 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:
{
"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
Section titled 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
Section titled Send the customer backEvery 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 describes.
Style the page
Section titled Style the pageEvery 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
Section titled The default style and the style of one pageThe store has a default style, which every page uses. Set it with PATCH /commerce/store, and send null to clear it:
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
Section titled 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
Section titled Custom CSScustom_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,@charsetand@page.url(),image-set(),image(),element(),cross-fade()andsrc()in any value. Abackgroundorbackground-imagecan 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-colorandcolor-scheme. - Background:
backgroundand its parts, with gradients only for the image. - Borders, outlines and corners:
borderand its parts (notborder-image),border-radiusandoutline. - Box model and position:
margin,padding,width,height, their minimums and maximums,aspect-ratio,box-sizing,display,position(static,relative,absoluteorsticky),top,right,bottom,left,inset,z-index,float,overflowandvisibility. - Type:
fontand everyfont-*property,line-height,letter-spacing,word-spacing,text-align,text-transform,text-decoration,text-overflow,text-indent,white-space,word-breakandoverflow-wrap. - Layout: flex and grid properties,
gap,order,align-*,justify-*andplace-*. - Effects:
box-shadow,text-shadow,opacity,transition,transform,cursor,appearanceandobject-fit. content, which takes a string,noneornormal.- 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
Section titled Class names and variablesThe 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
Section titled An example{
"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
Section titled Preview a stylePOST /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. |
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
Section titled How a payment goes- You create a page and send the customer its
url. - The customer opens the page, which shows the amount and the billing form in your style.
- 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. - We send the customer to the payment site's own page, and they pay there.
- 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 describes. We check the signature and keep the return once.
- A payment that succeeded makes the page
paid. We sendcommerce.checkout_page.paid, and the customer goes to yoursuccess_url, or to yourreturn_urlwhen you gave nosuccess_url, as 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
Section titled 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
Section titled Late payments, repeats and other amountsA 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
Section titled Cancel a pagecurl -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
Section titled Read what the payment site sent backGET /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": ...}.
{
"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
Section titled Confirm the payment on your serverRead 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:
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 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.
{
"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.
{
"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.
{
"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
Section titled RefundsYou 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
Section titled 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
Section titled 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
Section titled ResultYour 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
Section titled Troubleshooting409withhosted_checkout_not_enabled- Staff have not turned hosted checkout pages on for the store. Ask support.
409withcheckout_domain_required- Add a checkout domain and verify it, as Set up a checkout domain describes.
409withno_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, and check that its
currencieslist takes the currency. 409withverification_failed- We did not find the TXT record. The answer says what we found. Publish
_coritan-checkout.<hostname>with the token fromrecords, wait a few minutes, and verify again. certificatestayspending- The name must point at us before the certificate can be issued. Publish the CNAME record when
recordslists one, and read the domain again in a few minutes. Whenrecordslists none, ask support where to point the name. 422withfieldset tostyle.custom_css- The
messagenames the first rule, property or value we refused. Remove it and send the CSS again. 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 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_urlorreturn_urlopens, but the page is notpaid- The address a customer opens proves nothing, and its
statuscan be behind or typed by hand. Read the page with the API, and wait for the return if it is stillprocessing. 422anddetailnames["body", "return_url"]- The request has no
return_url. Add the address the customer came from, as Send the customer back describes. 422withfieldset toreturn_url- The address is empty, is not
https, is over 2,000 characters, or has spaces or sign-in details. The same limits apply tosuccess_urlandcancel_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
urlfor a typing mistake.
Related
Section titled RelatedAPI operations on this page
| Method | Path | What it does |
|---|---|---|
GET | /api/v1/orgs/{org_slug}/commerce/checkout-pages | List the organization's hosted checkout pages, newest first |
POST | /api/v1/orgs/{org_slug}/commerce/checkout-pages | Make a hosted checkout page for an amount, and get its address |
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 |
GET | /api/v1/orgs/{org_slug}/commerce/checkout-pages/{checkout_page_id} | Read one hosted checkout page by its id (chk...) |
POST | /api/v1/orgs/{org_slug}/commerce/checkout-pages/{checkout_page_id}/cancel | Cancel a hosted checkout page so it takes no more payments |
GET | /api/v1/orgs/{org_slug}/commerce/checkout-pages/{checkout_page_id}/events | List what the parent site reported for one page, newest first |
GET | /api/v1/orgs/{org_slug}/commerce/checkout-domains | List the organization's checkout domains, oldest first |
POST | /api/v1/orgs/{org_slug}/commerce/checkout-domains | Add a hostname to serve checkout pages on |
POST | /api/v1/orgs/{org_slug}/commerce/checkout-domains/{hostname}/verify | Check the TXT record now and, when it is there, verify the hostname |
DELETE | /api/v1/orgs/{org_slug}/commerce/checkout-domains/{hostname} | Remove a checkout domain and stop serving pages on it |