Build a payment page for your store
Take your store's payments on a page you host, then check the signed link, read what to charge, report the payment and send the shopper back.
A payment page is a page you host that takes your store's payments on your own payment account, with the payment provider you choose. Checkout offers it as the payment provider external. Your storefront sends the shopper to the page with a signed link, and the page reads from us what to charge, takes the money, reports the outcome and sends the shopper back. We place the order once the page reports a payment that succeeded.
You sell the orders paid on a payment page yourself. Coritan is not their merchant of record: we take none of their money, issue no invoice or credit note for them and book nothing on your balance for them, as What the order looks like describes.
The API calls a payment page a payment connector. Its routes are under https://api.coritan.com/api/v1/orgs/{org_slug}/commerce/payment-connectors, and the API reference lists them under Commerce. The dashboard does the same in Payments, as Take payments on your own payment pages describes.
Before you begin
Section titled Before you begin- Our staff turn payment pages on for a store, so ask support. Until then, adding a page answers
409withexternal_payments_not_enabled, and the list of pages hasexternal_payments_enabled: false. - Adding, changing and deleting pages takes an organization API key with the
commerce.payments:writescope, or a member's token with the Admin role or above. Reading them takescommerce.payments:read, or the Billing role or above. - Host the page at an
httpsaddress on a public host, with somewhere safe to keep two secrets. - List
externalin thepayment_providersof each region that should offer the page, as Add regions describes. - Set the store's
storefront_url, or have your storefront send areturn_urland acancel_urlwith each payment, so that the page can send the shopper back.
How a payment goes
Section titled How a payment goes- Your storefront lists the cart's payment providers, as Take payment describes.
externalis among them when a page can take the cart's payment, with thedisplay_name,descriptionandbrandsto show in itsconfig. - The storefront starts a payment session with
external, and sends the shopper to the session'sredirect_url: your page's address with a signed link added. - Your page checks the link, then looks up what to charge.
- The shopper pays on your page, with your payment provider.
- Your page reports the outcome. A payment that succeeded becomes the order at once.
- Your page sends the shopper back to the
return_url, or to thecancel_urlwhen they give up. The storefront completes the cart, which answers the order.
We trust only what your page signs. A shopper coming back to the storefront proves nothing, so the order waits for your page's report.
A cart is offered one page: the first one added that is turned on, has its secrets, is in the cart's mode and takes the cart's currency. A cart that buys or spends a gift card is offered none.
Add the payment page
Section titled Add the payment pagecurl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/payment-connectors" \
-H "X-API-Key: $ORG_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"livemode": false,
"display_name": "Credit / Debit Card",
"description": "Pay with your card, Apple Pay or Google Pay.",
"brands": ["visa", "mastercard", "amex", "apple_pay", "google_pay"],
"start_url": "https://pay.example.com/start/",
"currencies": ["USD"]
}'
| Field | Takes |
|---|---|
display_name |
The name the storefront shows for the page, up to 80 characters. Required. |
start_url |
Where the shopper is sent: https on a public host, up to 1,000 characters, without a # part, and without session, expires, mode or sig in its query, which the signed link adds. Required. |
livemode |
true for live carts, or false for test carts, which is the default. It never changes, so add a second page for the other mode. |
description |
Text the storefront shows under the name, up to 300 characters. |
brands |
The marks the storefront shows beside the name, from visa, mastercard, amex, discover, apple_pay and google_pay. What the page takes is up to the page. |
currencies |
The currencies the page takes, up to 50, such as ["USD", "EUR"]. null, the default, takes any. |
handoff_minutes |
How long the signed link works, from 5 to 1,440 minutes. Defaults to 30. |
enabled |
false adds the page turned off. Defaults to true. |
kind |
signed_redirect, the only kind, which is the default. |
The answer is 201 with the payment_connector and its two secrets, each 64 lowercase hex characters:
{
"payment_connector": {
"id": 4,
"kind": "signed_redirect",
"livemode": false,
"enabled": true,
"display_name": "Credit / Debit Card",
"description": "Pay with your card, Apple Pay or Google Pay.",
"brands": ["visa", "mastercard", "amex", "apple_pay", "google_pay"],
"start_url": "https://pay.example.com/start/",
"currencies": ["USD"],
"handoff_minutes": 30,
"has_secrets": true,
"api_base": "/api/v1/orgs/acme/commerce/payment-connectors/4",
"lookup_url": "/api/v1/orgs/acme/commerce/payment-connectors/4/sessions/lookup",
"events_url": "/api/v1/orgs/acme/commerce/payment-connectors/4/events",
"created_at": "2026-10-04T11:40:00Z",
"updated_at": "2026-10-04T11:40:00Z"
},
"secrets": {
"outbound_secret": "0f1e2d3c4b5a69780f1e2d3c4b5a69780f1e2d3c4b5a69780f1e2d3c4b5a6978",
"inbound_secret": "8a7b6c5d4e3f20118a7b6c5d4e3f20118a7b6c5d4e3f20118a7b6c5d4e3f2011"
}
}
Put both secrets in your page's settings, with the lookup_url and the events_url on https://api.coritan.com. The page checks the links we send it with the outbound_secret, and signs its calls to us with the inbound_secret.
Warning
Only this answer shows the secrets, and we keep them sealed. has_secrets says that a page has them, but no route shows them again. If they are lost, rotate them.
A store has up to 10 payment pages. Beyond that, adding one answers 409 with limit_reached.
Change, turn off or delete a page
Section titled Change, turn off or delete a pageGET /commerce/payment-connectorslists the pages, oldest first, withexternal_payments_enabled.GET /commerce/payment-connectors/{connector_id}answers one.PATCH /commerce/payment-connectors/{connector_id}changes the fields you send, with the same rules, and"description": nullremoves the description.livemodeandkindcannot change, which answers422."enabled": falseturns the page off. No new cart is offered it, and the page can still look up and report the payments already started on it.POST /commerce/payment-connectors/{connector_id}/rotate-secretsmakes two new secrets and answers them with thepayment_connector, this once. The old ones stop working at once: until the page has the new ones, the links we send fail its check and we refuse what it signs.DELETE /commerce/payment-connectors/{connector_id}answers{"deleted": true}. While a shopper may still be paying on the page, it answers409withconnector_in_use, andopen_sessionscounts those payments. A payment that is still open counts until an hour after its link stops working, so turn the page off instead, and delete it once they are done.
Caution
Deleting a page and rotating its secrets cannot be undone. A deleted page can no longer look up or report a payment.
Check the signed link
Section titled Check the signed linkCheckout sends the shopper to the page's start_url with four parameters added, after a ?, or after a & when the address already has a query:
https://pay.example.com/start/?session=cps_1042_k3m9q2x7v5b8n4r6t1w0z8y2&expires=1791117000&mode=test&sig=3c3c43e567a4e1b6cd13ea8d0fd24dcbd529de26fd7ffb1b056b803cf8c7b6ac
| Parameter | Holds |
|---|---|
session |
The payment's reference. Treat it as opaque text of up to 128 letters, digits, _, . and -. |
expires |
When the link stops working, in Unix seconds: handoff_minutes after the shopper was sent. |
mode |
live or test, the cart's mode. |
sig |
The lowercase hex HMAC-SHA256 of <session>.<expires>.<mode>, keyed with the outbound secret. |
Before you show the shopper anything, compute sig again and compare the two in constant time, and refuse a link whose expires has passed. When one page serves both a test and a live payment page, check each link with the secret of its mode.
import hashlib
import hmac
import time
def link_is_valid(outbound_secret: str, session: str, expires: str, mode: str, sig: str) -> bool:
message = f"{session}.{expires}.{mode}".encode()
expected = hmac.new(outbound_secret.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig.lower()) and expires.isdigit() and time.time() <= int(expires)
To test your check, use the example outbound_secret above with the link above. It signs the text cps_1042_k3m9q2x7v5b8n4r6t1w0z8y2.1791117000.test, which gives its sig. Its expires is Oct 4, 2026, 12:30 UTC, so the signature matches but the link has expired.
When the shopper starts paying again for the same total, checkout sends a new link for the same session, with a later expires and a new sig. When the cart changes, the next link names a new session, and the old one looks up as canceled.
Sign your requests
Section titled Sign your requestsYour page makes two calls to us, each a POST with a JSON body of up to 256 KB. Sign each one with the inbound secret in the X-Coritan-Signature header:
X-Coritan-Signature: t=1791115204,v1=a438d205245d8ee44124b41ac8090e9fbb1a0996130b8ebfade7e9321c06a80b
t is when you sign, in Unix seconds. v1 is the lowercase hex HMAC-SHA256 of t, a full stop and the raw body, keyed with the inbound secret. Sign the exact bytes you send, because a body encoded again after signing no longer matches. We refuse a t more than 5 minutes from our clock, so sign each request as you send it, retries included. A header can carry more than one v1, and one that matches is enough.
import hashlib
import hmac
import json
import time
def signed(inbound_secret: str, payload: dict) -> tuple[bytes, str]:
body = json.dumps(payload, separators=(",", ":")).encode()
t = int(time.time())
v1 = hmac.new(inbound_secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return body, f"t={t},v1={v1}"
With the example inbound_secret above, the t 1791115204 and the body {"session":"cps_1042_k3m9q2x7v5b8n4r6t1w0z8y2"}, the signed bytes are 1791115204.{"session":"cps_1042_k3m9q2x7v5b8n4r6t1w0z8y2"}, which give the header above.
Instead of a signature, a call can carry an organization API key with commerce.payments:write in X-API-Key. We then check the key and ignore any signature. The inbound secret signs only this page's two calls, while the key can also change and delete the store's pages.
A page that is turned off still answers both calls, so that it can finish the payments already started on it.
| Answer | Why |
|---|---|
401 with authentication_required |
The call has neither a signature nor a key. |
401 with invalid_signature |
The header is not t=<unix seconds>,v1=<hex signature>, the signature does not match the inbound secret, or the page has no inbound secret. |
401 with signature_expired |
t is more than 5 minutes from our clock. |
413 with payload_too_large |
The body is over 256 KB. |
Look up what to charge
Section titled Look up what to chargeLook the session up once the link checks out, and again just before you take the money, because the cart can change while the shopper is on your page.
BODY='{"session":"cps_1042_k3m9q2x7v5b8n4r6t1w0z8y2"}'
T=$(date +%s)
V1=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$INBOUND_SECRET" | sed 's/^.*= //')
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/payment-connectors/4/sessions/lookup" \
-H "Content-Type: application/json" \
-H "X-Coritan-Signature: t=$T,v1=$V1" \
--data-binary "$BODY"
The answer is the payment_page.v1 document:
{
"schema": "payment_page.v1",
"session": "cps_1042_k3m9q2x7v5b8n4r6t1w0z8y2",
"status": "open",
"livemode": false,
"amount": 5896,
"currency_code": "USD",
"email": "alex@example.com",
"customer": {"first_name": "Alex", "last_name": "Morgan", "phone": "+12025550143"},
"billing_address": {"first_name": "Alex", "last_name": "Morgan", "company": "", "address_1": "350 Fifth Avenue", "address_2": "", "city": "New York", "province": "NY", "postal_code": "10118", "country_code": "US", "phone": "+12025550143"},
"shipping_address": {"first_name": "Alex", "last_name": "Morgan", "company": "", "address_1": "350 Fifth Avenue", "address_2": "", "city": "New York", "province": "NY", "postal_code": "10118", "country_code": "US", "phone": "+12025550143"},
"lines": [
{"kind": "item", "sku": "MUG-BLU", "title": "Mug (Blue)", "quantity": 2, "unit_price": 2000, "subtotal": 4000, "discount_total": 400, "total": 3600, "tax_total": 297}
],
"shipping_total": 1500,
"fee_lines": [{"code": "shipping_protection", "label": "Shipping protection", "total": 499, "tax_total": 0}],
"tax_total": 297,
"adjustment_total": 0,
"return_url": "https://shop.example.com/checkout",
"cancel_url": "https://shop.example.com/checkout",
"expires_at": "2026-10-04T12:30:00Z",
"reference_label": "Cart cart_01j8z4a7b2c9d3e6f5g8h1k0m4"
}
| Field | Holds |
|---|---|
status |
Whether to take the money, as the next table says. |
livemode |
true for a live cart. |
amount, currency_code |
What to charge, in the currency's smallest unit, such as cents. |
email, customer |
The shopper's email address, and the first_name, last_name and phone of the billing address, or of the shipping address when the cart has no billing address. |
billing_address, shipping_address |
The cart's addresses, with every key present and "" for a missing value. billing_address is the shipping address when the cart has no billing address. |
lines |
Each item in the cart: its kind, which is item, or gift for one a promotion added free; its sku; its title, with the variant in brackets; the quantity, unit_price, subtotal and discount_total; the total after discounts and before tax; and its tax_total. |
shipping_total |
Shipping after its discounts, before tax. |
fee_lines |
The store fees the shopper chose, each with its code, label, total before tax and tax_total. |
tax_total |
All the tax on the cart. |
adjustment_total |
Whatever makes the parts add up to amount. |
return_url, cancel_url |
Where to send the shopper back. |
expires_at |
When the link stops working. |
reference_label |
A label for the payment in your provider's records. |
The parts always add up to amount exactly:
sum of lines[].total + shipping_total + sum of fee_lines[].total + tax_total + adjustment_total = amount
When your provider takes a breakdown of the payment, send it these parts, with an adjustment_total that is not 0 as a line of its own.
status |
What to do |
|---|---|
open |
Take the payment. |
paid |
Your page already reported this payment as succeeded. Take nothing more, and send the shopper to the return_url. |
failed |
Your page reported that the payment failed. Take nothing more: the shopper starts again at checkout, which sends a new link. |
canceled |
The cart has another payment now, or it changed, closed or became an order. Take nothing, and send the shopper to the cancel_url. |
expired |
The link's time is up. Take no new payment, and send the shopper to the cancel_url to start again. A payment you already took can still be reported. |
A session that this page did not issue answers 404 with not_found, and a body that is not {"session": "<reference>"} answers 422 with invalid.
Report the payment
Section titled Report the paymentReport each outcome to the events_url, signed in the same way:
BODY='{"id":"pay-77810","type":"payment.succeeded","session":"cps_1042_k3m9q2x7v5b8n4r6t1w0z8y2","amount":5896,"currency_code":"USD","reference":"ch_77810","occurred_at":"2026-10-04T12:02:30Z"}'
T=$(date +%s)
V1=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$INBOUND_SECRET" | sed 's/^.*= //')
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/payment-connectors/4/events" \
-H "Content-Type: application/json" \
-H "X-Coritan-Signature: t=$T,v1=$V1" \
--data-binary "$BODY"
With the example inbound_secret, that body signed at t 1791115351 gives the v1 91f9caf8bd2e5a75723fea54e3df5bece12d0976cbd3f1bddc5daf7a1e260959.
| Field | Takes |
|---|---|
id |
Your own ID for the event, unique for the page, up to 128 characters. A number counts as text. Required. |
type |
payment.succeeded, payment.failed or payment.pending. Required. |
session |
The session from the link. Required. |
amount, currency_code |
What the shopper paid, in the currency's smallest unit, and its currency. Required with payment.succeeded. |
reference |
Your provider's reference for the payment, up to 255 characters. The order's payment keeps it as page_reference. |
message |
A note of up to 1,000 characters, such as why the payment failed. |
occurred_at |
When it happened, as text of up to 64 characters, which we keep as you send it. |
What each type does:
payment.succeeded: the payment is paid, and we place the order at once. A success can follow apayment.failedfor the samesession, as long as checkout has not started another payment for the cart since.payment.failed: the payment fails, with yourmessageas the reason. Completing the cart answerspayment_failed, and the shopper can pay again.payment.pending: we note it, and completing the cart answerspayment_processinguntil you report the outcome.
The answer is 200:
{"status": "processed", "order_id": "order_01j8z3k4m5n6p7q8r9s0t1v2w3"}
status |
What it means |
|---|---|
processed |
We acted on it. For a success, order_id is the order's order_ ID, or null while we are still placing it. |
duplicate |
The page reported an event with this id before. Nothing changes, and order_id names the order once there is one. |
ignored |
Nothing changes: a payment.failed or payment.pending for a payment that is closed, or a success the page reported before, with the same amount, currency and reference. |
unmatched |
The payment succeeded but cannot pay for the cart's order. We keep it for you to refund, as Refund what could not become an order describes. |
Send an event again with the same id until it answers 200, because a repeat never counts twice. A 404 or a 422 keeps nothing, so correct the event and send it again.
Send the shopper back
Section titled Send the shopper backAfter the shopper pays, send them to the return_url, and when they give up, to the cancel_url. Both are on your storefront: it sends them when it starts the payment, and they default to <storefront_url>/checkout.
The storefront then completes the cart, as Complete the order describes. Once your page has reported the success, completing answers the order. Until then it answers 409 with payment_processing, while your page reports the payment as pending, or for 15 minutes after the shopper was last sent to your page. After that it answers 409 with payment_requires_action, and the shopper can pay again. A success your page reports later still places the order, unless the cart was paid another way or changed in the meantime.
What the order looks like
Section titled What the order looks likeAn order paid on a payment page is yours to sell:
- Its
seller_of_recordismerchantand itsseller_entity_keyisnull, where the store's other orders name a Coritan company. - We issue no invoice or credit note for it, so its
documentsstay empty, and the Store API'ssold_byisnull. - We book nothing on the store's balance or ledger for it: no sale, no commission and no processing fee. Its tax is worked out as on any cart, and the tax report leaves it out, so account for it yourself.
- Its payment has the
providerexternal, and your page'sreferenceas itspage_reference. - With payment routing on, its
payment_routesays which route paid for it. - We send
commerce.order.placedandcommerce.payment.capturedas for any order, and email the shopper the order confirmation.
Refund an order paid on a payment page
Section titled Refund an order paid on a payment pageReturn the money to the shopper at your payment provider, then record the refund with POST /commerce/orders/{order_id}/refunds, as Refund or cancel an order describes. We move no money: the refund is succeeded at once, with a gateway_refund_id that starts with external_refund_, and no credit note. What is left to refund goes down, the order's timeline says to return the money at the payment page's provider, and we email the shopper the refund notice and send commerce.payment.refunded. Cancelling the order records the refund of what is left in the same way.
Refund what could not become an order
Section titled Refund what could not become an orderWe never refund a payment that answered unmatched, so refund it at your payment provider. The event's detail.reason, in the page's events, says why it could not pay for the order:
reason |
What happened |
|---|---|
second_payment |
The page already reported a success for this payment, and this one has another amount, currency or reference. |
amount_mismatch |
The amount or the currency is not the payment's. |
cart_missing |
The cart no longer exists. |
already_ordered |
The cart already became an order. |
session_replaced |
Checkout started another payment for the cart, on your page or another way to pay. |
cart_not_open |
The cart was closed without an order. |
paid_otherwise |
Another payment for the cart already succeeded. |
cart_changed |
The cart's total or currency changed after the payment started. |
Another code, such as insufficient_inventory |
We could not place the order, for the reason the code names. order_not_placed gives no reason. |
When the cart already has an order, the payment joins it: the order's payments hold it with unmatched: true, and once you have refunded it, you record that with its payment_id, as Refund or cancel an order describes. Otherwise there is no order to record it on, and we close the payment.
For each one, we send the commerce.payment.refund_required webhook:
{
"id": "evt_48230",
"type": "commerce.payment.refund_required",
"livemode": false,
"created_at": "2026-10-04T12:41:07Z",
"resource": {"type": "cart", "id": "cart_01j8z4a7b2c9d3e6f5g8h1k0m4"},
"data": {"cart": "cart_01j8z4a7b2c9d3e6f5g8h1k0m4", "session_id": 1042, "order_id": null, "amount": 5896, "currency_code": "USD", "reference": "ch_77811", "reason": "cart_changed", "livemode": false}
}
resource is the order when the payment joined one, and order_id is then its numeric id. Otherwise both name the cart. reference is your page's reference for the payment.
Read what a page reported
Section titled Read what a page reportedGET /commerce/payment-connectors/{connector_id}/events lists what the page reported, newest first, 50 at a time and up to 200 with limit. Filter it by status (processed, ignored or unmatched) and by type, and page back by sending next_before_id as before_id.
curl "https://api.coritan.com/api/v1/orgs/acme/commerce/payment-connectors/4/events?status=unmatched" \
-H "X-API-Key: $ORG_API_KEY"
Each event has the page's id as its event_id, with its type, session_id, status and created_at. Its detail holds what the page sent (reference, amount, currency_code, message and occurred_at) and, once known, the reason and the order_id. A repeat that answered duplicate is not listed.
Result
Section titled ResultCheckout offers your page as external to carts of its mode and currency in the regions that list it. A shopper who pays there comes back to an order whose seller_of_record is merchant, and the page's events show each payment it reported.
Troubleshooting
Section titled Troubleshooting409withexternal_payments_not_enabled- Our staff have not turned payment pages on for the store. Ask support. When they turn them off, your pages stay, but no cart is offered them and no page can be added.
409withlimit_reached- The store has 10 payment pages. Delete one you no longer use.
422withfieldset tostart_url- The
messagesays why: the address is nothttps, its host is not public, it has a#part, or it carries a parameter that the link adds. 409withconnector_in_use- A shopper may still be paying on the page. Send
"enabled": falseinstead, and delete the page an hour after its last link stops working. - The storefront is not offered
external - Check that our staff turned payment pages on, that the cart's region lists
external, that a page that is turned on is in the cart's mode and takes its currency, and that the cart neither buys nor spends a gift card. With payment routing on, the cart's route must also listexternal. 401withinvalid_signature- Sign the exact bytes you send, with the inbound secret rather than the outbound one, and with the secrets of the latest rotation.
401withsignature_expired- Your server's clock is more than 5 minutes off, or the request was signed long before it was sent. Correct the clock, and sign each request as you send it.
404withnot_found- The page in the address does not exist, or this page did not issue the
session. Use thelookup_urlandevents_urlof the page whose secret checked the link. 404withcommerce_not_enabled- The organization has no store, or its store is disabled.
- Completing the cart answers
payment_processingafter the shopper paid - We have not had your page's
payment.succeeded. Check that the page reports to theevents_url, signs with the inbound secret and gets a200. - Links fail the page's check after a rotation
- Links sent before the rotation were signed with the old outbound secret. The shopper starts the payment again at checkout for a new link.
Related
Section titled RelatedAPI operations on this page
| Method | Path | What it does |
|---|---|---|
GET | /api/v1/orgs/{org_slug}/commerce/payment-connectors | List the store's payment connectors, oldest first |
POST | /api/v1/orgs/{org_slug}/commerce/payment-connectors | Add a payment page the store hosts, and get its two secrets |
GET | /api/v1/orgs/{org_slug}/commerce/payment-connectors/{connector_id} | Read one payment connector, with the addresses its page calls (lookupurl and eventsurl) |
PATCH | /api/v1/orgs/{org_slug}/commerce/payment-connectors/{connector_id} | Change what a connector shows and where it sends shoppers, or turn it off |
DELETE | /api/v1/orgs/{org_slug}/commerce/payment-connectors/{connector_id} | Delete a payment connector |
GET | /api/v1/orgs/{org_slug}/commerce/payment-connectors/{connector_id}/events | List the events the connector's page reported, newest first |
POST | /api/v1/orgs/{org_slug}/commerce/payment-connectors/{connector_id}/events | Report a payment's outcome on the payment page |
POST | /api/v1/orgs/{org_slug}/commerce/payment-connectors/{connector_id}/rotate-secrets | Replace both secrets of a connector and get the new ones, shown this once |
POST | /api/v1/orgs/{org_slug}/commerce/payment-connectors/{connector_id}/sessions/lookup | Look up what to charge for a hand-off, as the payment page does before taking any money |