Skip to content
Coritan Docs

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.

View as Markdown

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.

  • Our staff turn payment pages on for a store, so ask support. Until then, adding a page answers 409 with external_payments_not_enabled, and the list of pages has external_payments_enabled: false.
  • Adding, changing and deleting pages takes an organization API key with the commerce.payments:write scope, or a member's token with the Admin role or above. Reading them takes commerce.payments:read, or the Billing role or above.
  • Host the page at an https address on a public host, with somewhere safe to keep two secrets.
  • List external in the payment_providers of each region that should offer the page, as Add regions describes.
  • Set the store's storefront_url, or have your storefront send a return_url and a cancel_url with each payment, so that the page can send the shopper back.
  1. Your storefront lists the cart's payment providers, as Take payment describes. external is among them when a page can take the cart's payment, with the display_name, description and brands to show in its config.
  2. The storefront starts a payment session with external, and sends the shopper to the session's redirect_url: your page's address with a signed link added.
  3. Your page checks the link, then looks up what to charge.
  4. The shopper pays on your page, with your payment provider.
  5. Your page reports the outcome. A payment that succeeded becomes the order at once.
  6. Your page sends the shopper back to the return_url, or to the cancel_url when 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.

Shell
curl -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:

JSON
{
  "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 page
  • GET /commerce/payment-connectors lists the pages, oldest first, with external_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": null removes the description. livemode and kind cannot change, which answers 422.
  • "enabled": false turns 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-secrets makes two new secrets and answers them with the payment_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 answers 409 with connector_in_use, and open_sessions counts 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.

Checkout 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:

Text
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.

Python
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.

Your 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:

Text
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.

Python
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 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.

Shell
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:

JSON
{
  "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:

Text
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 each outcome to the events_url, signed in the same way:

Shell
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 a payment.failed for the same session, as long as checkout has not started another payment for the cart since.
  • payment.failed: the payment fails, with your message as the reason. Completing the cart answers payment_failed, and the shopper can pay again.
  • payment.pending: we note it, and completing the cart answers payment_processing until you report the outcome.

The answer is 200:

JSON
{"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.

After 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.

An order paid on a payment page is yours to sell:

  • Its seller_of_record is merchant and its seller_entity_key is null, where the store's other orders name a Coritan company.
  • We issue no invoice or credit note for it, so its documents stay empty, and the Store API's sold_by is null.
  • 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 provider external, and your page's reference as its page_reference.
  • With payment routing on, its payment_route says which route paid for it.
  • We send commerce.order.placed and commerce.payment.captured as 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 page

Return 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 order

We 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:

JSON
{
  "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.

GET /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.

Shell
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.

Checkout 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.

409 with external_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.
409 with limit_reached
The store has 10 payment pages. Delete one you no longer use.
422 with field set to start_url
The message says why: the address is not https, its host is not public, it has a # part, or it carries a parameter that the link adds.
409 with connector_in_use
A shopper may still be paying on the page. Send "enabled": false instead, 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 list external.
401 with invalid_signature
Sign the exact bytes you send, with the inbound secret rather than the outbound one, and with the secrets of the latest rotation.
401 with signature_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.
404 with not_found
The page in the address does not exist, or this page did not issue the session. Use the lookup_url and events_url of the page whose secret checked the link.
404 with commerce_not_enabled
The organization has no store, or its store is disabled.
Completing the cart answers payment_processing after the shopper paid
We have not had your page's payment.succeeded. Check that the page reports to the events_url, signs with the inbound secret and gets a 200.
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.

API operations on this page

MethodPathWhat it does
GET/api/v1/orgs/{org_slug}/commerce/payment-connectorsList the store's payment connectors, oldest first
POST/api/v1/orgs/{org_slug}/commerce/payment-connectorsAdd 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}/eventsList the events the connector's page reported, newest first
POST/api/v1/orgs/{org_slug}/commerce/payment-connectors/{connector_id}/eventsReport a payment's outcome on the payment page
POST/api/v1/orgs/{org_slug}/commerce/payment-connectors/{connector_id}/rotate-secretsReplace 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/lookupLook up what to charge for a hand-off, as the payment page does before taking any money