Skip to content
Coritan Docs

Build a checkout with the Store API

Use a publishable key to list products, fill a cart, take payment and show orders on your own site.

View as Markdown

The Store API is what your storefront calls to sell the goods in your organization's store. It lists the catalogue, fills carts, takes payment, places orders and shows shoppers their orders. You set up and run the store with the Commerce API.

Every route is under https://api.coritan.com/api/v1/orgs/{org_slug}/store. The API reference lists them under Commerce Store API, with the product routes under Catalog & Services and the shopper's account routes under Customers.

  • Set up the store with the Commerce API: a region, a stock location, shipping, and published products with prices. Set up the store explains each part.
  • Create a publishable key, as Create publishable keys and sales channels shows. Build with a test key, whose token starts with pk_test_. A live key works once we switch the store to live.
  • Set the store's storefront_url, because the order emails link to pages on it, and list your storefront's address in allowed_origins.
  • We send order emails with your organization's email settings. Choose how your email leaves before you take orders.
  • To let shoppers sign in, add customer sign-in to your storefront. Shoppers can also check out as guests, unless you turn off the store's guest_checkout setting.

Send the key in the x-publishable-api-key header of every request. It names your store and cannot change it, so it is safe to put in the browser.

Shell
curl "https://api.coritan.com/api/v1/orgs/acme/store" \
  -H "x-publishable-api-key: $PUBLISHABLE_KEY"

The answer is the store as your storefront needs it: its name, whether the key is in livemode, the currencies and locales with their defaults, the default_region_id, the key's sales_channels, the storefront_url and support_email, the terms_url and privacy_url, and whether guest_checkout is on. The store also holds turnstile, with enabled and the widget's site_key: when enabled is true, show the challenge with that key on your sign-up form and send its answer as turnstile_token, as Register a customer shows. The challenge is a Cloudflare Turnstile widget unless turnstile also has provider set to wicket: then load the script at its script_url and draw a Wicket widget.

The store also holds storefront, the merchant's own settings for your storefront: the JSON object saved under Storefront settings in the dashboard, or {} when none is saved. Coritan gives none of its keys a meaning, so your storefront reads the ones it knows, such as an age gate or a welcome popup, and ignores the rest. It is at most 16,384 bytes. leads_turnstile is true when a sign-up to Collect leads must send a turnstile_token. A change to either reaches the Store API within 15 seconds.

  • A test key reaches test carts and orders only, and a live key reaches live ones. A cart or order of the other mode answers 404.
  • A key sells the products of its sales channels. The catalogue routes and new carts take a sales_channel_id to narrow to one of them.
  • When the shopper is signed in, also send their customer token as Authorization: Bearer $CUSTOMER_TOKEN. It prices products for the shopper's customer groups, makes the carts they change theirs, and opens their account and order routes.
  • While the store lists no allowed_origins, a browser on any site can call the Store API. Once it lists some, only those sites can. A change to the list takes up to 30 seconds to apply.

Server-side storefronts: pass the visitor's address

Section titled Server-side storefronts: pass the visitor's address

The Store API counts some limits for each IP address, such as 60 payment sessions in 10 minutes. A storefront that renders on a server calls the API from the server's address, so every visitor shares those limits. Your server can tell us who the visitor is. We then count the visitor, and the address and browser we record for an order, a sign-in session or a risk check are the visitor's.

You need an organization API key that holds only the scope commerce.storefront:forward, and a server that can read its visitor's address.

  1. In the dashboard, go to Organizations, open your organization, then choose Settings, then API keys. You need the owner or admin role.
  2. Choose New key… and enter a Label such as Storefront server.
  3. Under Scopes, tick Forward visitor addresses and nothing else.
  4. Optionally, fill in the IP allow-list with the addresses your server calls from. We then believe a visitor's address only when the request really came from one of them.
  5. Choose Create key, copy the key, then choose I have saved them. We show the key once.

A key with only this scope reaches nothing else. Whoever holds it can claim any visitor address and so avoid the limits, so revoke it if it leaks. Keep it in a setting that only your server reads, such as STOREFRONT_SERVER_KEY. Never put it in a page, a browser script or a variable that your build copies to the browser, such as one that starts with NEXT_PUBLIC_. App Deployment refuses variable names that start with CORITAN_, except CORITAN_ORG_API_KEY, CORITAN_WEBHOOK_SECRET and CORITAN_MICROSOFT_ADS_FEED_URL (Names your app reads), so give this key another name. Create a key in the dashboard describes every field.

Send three headers with each request your server makes for a visitor, beside x-publishable-api-key:

Header Value
X-API-Key The key.
Coritan-Client-Address The visitor's IPv4 or IPv6 address.
Coritan-Client-User-Agent Optional. The visitor's browser, at most 500 characters.
Shell
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/store/carts" \
  -H "x-publishable-api-key: $PUBLISHABLE_KEY" \
  -H "X-API-Key: $SERVER_KEY" \
  -H "Coritan-Client-Address: 203.0.113.24" \
  -H "Coritan-Client-User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6)" \
  -H "Content-Type: application/json" \
  -d '{}'

With the JavaScript SDK, give createClient the key as serverKey and the visitor of the request as visitor. It sends the headers for you, on the routes below. A Next.js server reads the address from x-real-ip, or from the first entry of x-forwarded-for:

TypeScript
import { headers } from "next/headers";
import { createClient } from "@coritan/commerce";

const incoming = await headers();
const commerce = createClient({
  baseUrl: "https://api.coritan.com",
  store: "acme",
  publishableKey: process.env.NEXT_PUBLIC_CORITAN_PUBLISHABLE_KEY!,
  serverKey: process.env.STOREFRONT_SERVER_KEY,
  visitor: {
    address: incoming.get("x-real-ip") ?? incoming.get("x-forwarded-for")?.split(",")[0],
    userAgent: incoming.get("user-agent"),
  },
});

Read the visitor's address only from a header that your own proxy writes. On App Deployment, our edge sets X-Real-IP and X-Forwarded-For from the connection and removes any that the visitor sent. Behind another proxy, check that it does the same. Otherwise a visitor can send an address of their own choosing.

  • We believe the headers only on your own organization's paths, /api/v1/orgs/{org_slug}/, and only on these routes: store/, auth/ (customer sign-in), staff/auth/ (staff sign-in), chat/conversations, chat/meta and storefront/contact. We never read them on the Commerce API (commerce/...) or on any other route.
  • We believe them only when the key is active and holds commerce.storefront:forward (commerce:* includes it), and, when the key has an IP allow-list, only when the request came from an address on it.
  • An address that is not an IP address is ignored without an error.
  • Checking the key does not use any of the key's hourly request limit.
  • The SDK sends no key for a call without an address, trims the address and the browser, drops control characters, cuts the browser to 500 characters, and sends nothing for an address longer than 45 characters. A client created in a browser with a serverKey throws a TypeError. A call's own visitor option replaces the client's for that call.

When we believe the address, every per-address limit of these routes counts the visitor.

Without the key your storefront still works, but it cannot tell its visitors apart:

  • On App Deployment, the server's address stands for every visitor of the store, so we allow each per-address limit of the Store API 100 times the number it allows one visitor. Each limit then protects the whole store, and we cannot tell an abuser from a shopper.
  • The sign-in limits are not raised, so failed sign-ins from all your visitors still add up against the server's address.
  • On a server anywhere else, the visitors share one set of the ordinary limits.

To check that it works, place a test order through your storefront, then read it with a key that holds commerce.orders:read:

Shell
curl "https://api.coritan.com/api/v1/orgs/acme/commerce/orders/$ORDER_ID" \
  -H "X-API-Key: $ORG_API_KEY"

The order's client holds the visitor's ip_address and user_agent:

JSON
{"client": {"ip_address": "203.0.113.24", "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6)"}}

When ip_address is your server's own, we did not believe the headers. Check that the key is active and holds the scope, that the request went to one of the routes above, and that the IP allow-list includes your server.

GET /store/products lists the published products in the key's sales channels, newest first.

Parameter Takes
q Text to find in the title, subtitle, handle or tags
collection_id or collection_handle A published collection. A smart collection follows its rules.
category_id or category_handle A category, with every category under it
tag One tag, in any case
id A product ID, repeated for more, up to 100
order -created_at (the default), created_at, title or -title
limit, offset Up to 100 a page; 20 by default
Shell
curl "https://api.coritan.com/api/v1/orgs/acme/store/products?collection_handle=clothing&country_code=DE" \
  -H "x-publishable-api-key: $PUBLISHABLE_KEY"

Every answer is priced for one place: the region_id you send, else the region that sells to country_code, else currency_code, else the store's default region. The answer's region and currency_code say which it used. Each variant has:

  • calculated_price, with the amount the shopper pays, the original_amount before any price list, the merchant's compare_at_amount, and is_discounted. It is null when the variant has no price there.
  • purchasable, which is true when the variant has a price and can be ordered.
  • availability: in_stock, low_stock (5 or fewer left), backorder, out_of_stock, or unlimited for a variant that does not track stock. Stock counts never leave our servers.
  • metadata, the data you keep on the variant, such as a size chart's ID.

GET /store/products/{id_or_handle} answers one product by its handle or ID, priced the same way.

For menus and pickers:

  • GET /store/collections lists the published collections, and GET /store/collections/{handle} answers one. Its products come from GET /store/products?collection_handle=....
  • GET /store/categories answers the categories as a tree, and GET /store/categories/{handle} answers one with its children and parent. A category that is inactive or internal is hidden, with everything under it.
  • GET /store/regions lists the regions with their countries, currency_code, tax_inclusive and payment_providers, for a country picker. GET /store/regions/{region_id} answers one.

Catalogue answers and GET /store to requests without a shopper's token can be cached for up to 30 seconds, so a change to a product or to the store's settings can take that long to show.

Create the cart when the shopper adds their first item. Send their country, and the cart takes the region that sells there, with its currency:

Shell
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/store/carts" \
  -H "x-publishable-api-key: $PUBLISHABLE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"country_code": "DE", "email": "alex@example.com", "items": [{"variant_id": 1841, "quantity": 2}]}'

The answer is 201 with the whole cart. Keep its id, which starts with cart_, in the shopper's browser. Every change answers the whole cart again, priced from the catalogue as it is at that moment.

  • Without country_code or region_id, the cart takes the store's default region. A country the store does not sell to answers 422 with country_not_served.
  • POST /store/carts/{cart_id}/line-items adds a variant_id with a quantity. The same variant with the same metadata joins its existing line. A line holds up to 999 units, and a cart up to 100 lines.
  • PATCH /store/carts/{cart_id}/line-items/{line_id} sets a line's quantity, and 0 removes the line. DELETE on the same path removes it too.
  • PATCH /store/carts/{cart_id} sets the email, the shipping_address and billing_address, a note of up to 2,000 characters, the locale and metadata. A new country_code or region_id moves the cart to that region and prices every line in its currency.
  • Adding an item holds no stock: we reserve it when the order is placed. Asking for more than there is answers 409 with insufficient_inventory, and available gives the units left for each variant.
  • Each item in the cart's items has product_id and product_handle: the product's current handle, so you can link the item to its product page without fetching the product. A renamed product shows its new handle. product_handle is null when the store deleted the product, so show the item's title without a link.

Anyone who holds a guest cart's ID can reach it. When a signed-in shopper changes a cart, it becomes theirs, and anyone else then gets 403 with cart_forbidden. Only an open cart changes, and a cart that nobody touches for 30 days becomes abandoned.

An address takes first_name, last_name, company, address_1, address_2, city, province, postal_code, country_code and phone. To ship and tax an order, it needs the country_code, a first or last name, address_1, city, and a postal_code where the country uses them. A Canadian address also needs its province, as a code such as ON or a name such as Ontario, because the tax depends on it. The shipping country must be one of the cart's region.

To sell a gift card, put its recipient in the line's metadata: recipient_email, recipient_name, sender_name and a message of up to 500 characters. We issue one card for each unit, and email it to the recipient once the order is placed, or to the shopper when the line names nobody.

A cart's metadata and a line's metadata are yours, for data such as where the shopper came from. The order keeps them. They take up to 50 keys of 1 to 40 characters, and values can be text, numbers, true, false, null, or objects and arrays nested up to 8 levels deep. Each value is at most 4,096 bytes as JSON, and the whole object at most 16,384 bytes. Keys that start with _coritan are ours: sending one answers 422 with field set to metadata._coritan, and they never appear in an answer.

  • POST /store/carts/{cart_id}/promotions with a code applies a discount code. A code that does not apply answers 422 with promotion_not_applicable, a reason such as expired, min_subtotal, min_quantity, email_not_allowed, requires_promotion or too_many_combined, and a message you can show. A cart holds up to 5 codes. Remove one with DELETE /store/carts/{cart_id}/promotions?code=WELCOME10. Automatic promotions apply without a code.
  • A line with is_gift set to true is a free gift that a promotion added. The cart adds it while the promotion applies and removes it when the promotion stops applying. Its adjustments take its whole price, so it costs nothing. Changing or removing it answers 409 with line_managed, so show it without quantity controls. A gift that cannot be sold now is left out, and the cart still checks out. The order keeps the line, with is_gift too.
  • POST /store/carts/{cart_id}/gift-cards with a code applies a gift card, up to 5 a cart. A wrong code answers 422 with gift_card_invalid, and a card that cannot pay answers 422 with gift_card_not_applicable and a reason: disabled, expired, exhausted or currency_mismatch. The cart shows each card's last4 and the amount it applied.

Each cart, and each IP address, gets 10 gift card attempts in 10 minutes, right or wrong. When the cart changes, codes and cards that no longer apply leave it.

Once the cart has a shipping address, GET /store/carts/{cart_id}/shipping-options lists the options that ship its items there, priced for them. Each option has its id, name, profile_id, amount and currency_code, whether it is_pickup, and its delivery_min_days and delivery_max_days.

Items in different shipping profiles need one option for each profile. Choose one with POST /store/carts/{cart_id}/shipping-methods and its option_id: it replaces the method that profile had. A cart with nothing to ship, such as one that holds only gift cards, needs a billing address instead of shipping.

A store can offer optional fees at checkout, such as shipping protection. The cart's fees list every fee it is offered, each with its code, label, description, amount, tax_total, total, and whether it is selected. Show each one with a checkbox, ticked when selected is true: a fee the store turns on by default starts ticked.

  • POST /store/carts/{cart_id}/fees/{code} selects a fee, and DELETE on the same path turns it off. Both answer the whole cart. The shopper's choice sticks, even for a fee that is on by default.
  • GET /store/carts/{cart_id}/fees answers the same fees on their own.
  • A code the cart is not offered answers 404 with not_found. A fee can depend on the cart's currency, and a fee for shipped goods is only offered while something in the cart ships.

Fees are never discounted and never count toward a discount's minimum or free shipping. Gift cards can pay for them. The order keeps the fees the shopper chose, in its own fees.

The cart's totals hold what the shopper pays: the subtotal, discount_total, shipping_total, shipping_discount_total, fee_total, tax_total and gift_card_total, and the total that is left to pay after gift cards. fee_tax_total is the tax on the selected fees, and tax_total counts it too. Each line has its own totals. Until the cart has an address, tax_estimated is true and the tax is an estimate for the region's first country.

Each of the cart's shipping_methods has its amount, the price before discounts, its discount_total from promotions, its tax_total and its total. Where the price does not include tax, total is amount - discount_total plus tax_total. Where it does, total is amount - discount_total. To show the shipping price, read amount - discount_total and show the tax apart. In totals, shipping_total adds up the methods' amount and shipping_discount_total adds up their discount_total. The cart's discount_total counts items only.

We work out the tax from Coritan's own rates. Each tax in a line's tax_lines, or a shipping method's, has its code, name, rate (a fraction, such as 0.19) and amount, and the jurisdiction it is owed to: the country_code, and the subdivision_code of a state or province that charges it.

The cart's blockers list what stops checkout, and completable is true when the list is empty.

Blocker What to do
empty Add an item.
region_unavailable The cart's region was disabled. Move the cart to another region.
item_unavailable An item is no longer for sale. Remove it.
insufficient_inventory An item has less stock than the cart asks for. Lower the quantity.
price_unavailable An item has no price in the cart's currency. Remove it.
email_required Set the cart's email.
customer_required The store does not take guest orders. Sign the shopper in.
shipping_address_required Add a complete shipping address.
billing_address_required Nothing in the cart ships, so add a complete billing address.
shipping_method_required Choose a shipping option for each profile.
tax_unavailable We cannot work out the tax for the address. A Canadian address needs its province. For an address elsewhere, ask support.

Starting a payment or completing the cart while something blocks it answers 409 with cart_not_ready and the blockers. There, the list can also hold seller_unavailable, which means no Coritan company sells to the address.

  1. List what can take the payment with GET /store/carts/{cart_id}/payment-providers. Each provider has an id, a display_name and the config its browser script needs. The answer also has the total and payment_required, which is false when gift cards pay for everything: then go straight to Complete the order. Its route is null unless the store routes payments, when the list holds only the ways to pay on the cart's route and route names it, default or rollout.

  2. Start a payment session with the provider the shopper picks. Send accept_terms: true once the shopper has accepted your terms.

    Shell
    curl -X POST "https://api.coritan.com/api/v1/orgs/acme/store/carts/cart_01j8z4a7b2c9d3e6f5g8h1k0m4/payment-sessions" \
      -H "x-publishable-api-key: $PUBLISHABLE_KEY" \
      -H "Content-Type: application/json" \
      -d '{"provider": "stripe", "accept_terms": true}'
    
  3. Let the shopper pay in the browser with the payment_session from the answer:

    • stripe: mount Stripe's Payment Element with config.publishable_key and the session's client_secret, and confirm the payment with Stripe.js.
    • paypal: send the shopper to the session's approve_url, or show PayPal's buttons with config.client_id and the paypal_order_id. PayPal brings the shopper back to the return_url you sent, or to the cancel_url when they stop. Both must be on your storefront's origin or in allowed_origins, and both default to <storefront_url>/checkout.
    • external: your store's own payment page. Show the choice with its config.display_name, config.description and the card marks in config.brands, then send the shopper to the session's redirect_url. The session takes no return_url or cancel_url: the parent sends the shopper back to /thank-you on the address it holds for your site, which the payment page guide describes. A cart that buys or spends a gift card is never offered it.
    • manual: a test payment that moves no money. Only test carts offer it, and it is authorized at once.

Starting a session again with the same provider for the same amount answers the same session, and the shopper's time to pay starts again. A session with another provider replaces it. With external, it also answers a new redirect_url. When the cart changes after the payment started, start the session again. The shopper pays the Coritan company that sells to their address, which also issues the invoice. On a payment page, the shopper pays your business instead, which sells the order, and we issue no invoice for it.

Stripe's Payment Element can offer Apple Pay and Google Pay above the card form. Stripe shows them only on a domain registered with the Stripe account that takes the payment, and on any other domain it hides them without an error. We register the domains, so ask support to register each host your storefront is served from, such as shop.example.com and www.shop.example.com, and name your store. We register each host with Stripe's payment method domains, and Stripe checks it with Apple itself, so you host no domain association file for Apple. A registration covers live and test payments. To check it, open your checkout in Safari on a device that has a card in Apple Pay: the Apple Pay button shows above the card form.

POST /store/carts/{cart_id}/complete checks the payment with the provider and places the order. Send an Idempotency-Key header: a value of up to 128 characters that you make once for each order the shopper places.

Shell
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/store/carts/cart_01j8z4a7b2c9d3e6f5g8h1k0m4/complete" \
  -H "x-publishable-api-key: $PUBLISHABLE_KEY" \
  -H "Idempotency-Key: 5f0c2a6e-1d3b-4c7e-9a41-2b8f6d0e7c19" \
  -H "Content-Type: application/json" \
  -d '{"accept_terms": true}'

The answer is 200 with the order and the access_token for its page. We email the shopper a confirmation that links to <storefront_url>/orders/{order_id}?token=<access_token>, a page your storefront serves.

  • A retry with the same key for the same cart, within 24 hours, answers what the first request answered. A cart that already has its order answers that order again, with or without the key.
  • The same key for another cart answers 409 with idempotency_key_reused, and a retry while the first request still runs answers 409 with idempotency_in_progress.
  • We price the cart again at this step. When its total no longer matches the payment, the answer is 409 with payment_session_stale: start the payment again.
  • When the money moved but the order cannot be placed, for example because another shopper bought the last unit, we refund the payment. The answer is 409 with the reason and payment_refunded: true. Money a payment page took is not ours to refund: the answer then has payment_refunded: false and refund_required: true, and the store refunds it, as Refund what could not become an order describes.
  • When the shopper closes the page after paying, we place the order once the payment provider reports the payment. A payment page's payment is placed when the page reports it.
  • A payment the shopper does not finish within the store's unpaid_minutes, 60 unless set, expires: we cancel it, and completing the cart answers 409 with payment_expired. Start a new payment session. A payment the provider reports as still processing does not expire. The checkout appears in the store's Unpaid checkouts.
  • Money that still arrives for an expired payment places the order when it comes within 7 days of the expiry, no newer payment was started for the cart, and the cart still costs the same. Otherwise we refund it, and the answer is 409 with payment_too_late.
  • A guest's first order adds a customer with that email to your organization, without a password. Their later orders join the same customer.
Answer What to do
409 with payment_required Start a payment session first.
409 with payment_requires_action The shopper has not finished paying, for example approving in PayPal, or the payment page has reported no payment. Let them finish, then complete the cart again.
409 with payment_failed The payment was declined. Let the shopper pay again.
409 with payment_expired The shopper did not finish paying in time, so the store cancelled the payment. Start a new payment session and let the shopper pay again.
409 with payment_too_late The money arrived after the payment expired: more than 7 days after, which reason gives as checkout_expired, or after a newer payment was started, session_replaced. We refund it, as for any payment that cannot become an order, and the answer says whether the refund went out.
409 with payment_processing The payment is still clearing. We place the order as soon as it clears. A payment page's payment counts as clearing while the page reports it pending, or for 15 minutes after the shopper was last sent to the page.
409 with payment_provider_not_offered A payment page paid for a cart that buys or spends a gift card, which it cannot pay for. The answer has refund_required: true.
409 with cart_completing Another request is placing this order. Complete the cart again in a moment to get the order.
503 with completion_pending The payment went through and we are placing the order. Try again in a minute.
503 with payment_provider_unavailable The payment provider did not answer. Try again in a moment.
401 with sign_in_required The store takes no guest orders. Sign the shopper in.
422 with terms_not_accepted Show the terms at terms_url, then send accept_terms: true.

A cart gets 20 payment sessions and 20 completion attempts in 10 minutes, and an IP address gets 60 of each. Beyond that, the answer is 429 with rate_limited and a Retry-After header.

A guest can ask for an account in the same request. Send create_account: true, and optionally the password the shopper chose, 8 to 128 characters:

Shell
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/store/carts/cart_01j8z4a7b2c9d3e6f5g8h1k0m4/complete" \
  -H "x-publishable-api-key: $PUBLISHABLE_KEY" \
  -H "Idempotency-Key: 5f0c2a6e-1d3b-4c7e-9a41-2b8f6d0e7c19" \
  -H "Content-Type: application/json" \
  -d '{"accept_terms": true, "create_account": true, "password": "a long passphrase"}'
  • After we place the order, we make the customer the order belongs to an account, with the name from the billing or shipping address. The shopper signs in with the email and the password at POST /auth/login.
  • Without a password, we email the shopper a link to choose one. It opens /reset-password?id=...&token=... on your storefront, the page the password reset email opens, and works for 3 days. A forgot-password request from the shopper does not cancel it.
  • When a customer with the email existed before this order, for example from an earlier guest order, we never set the password sent with the cart, because nobody has confirmed that the shopper owns the address. We email the link instead.
  • When the email already belongs to an account, or the shopper is signed in, nothing changes.
  • The answer is the same in every case, so it never says whether the email had an account. A retry never makes a second account or sends a second email.
  • A password without create_account: true answers 422.

With the SDK, pass the same fields to carts.complete(cartId, { create_account: true, password }).

GET /store/orders/{order_id} answers an order to its signed-in shopper, or to anyone who sends its token: the access_token from completion, which every order email carries. A token works for 90 days after the order was placed, or for the store's order_access_days.

Shell
curl "https://api.coritan.com/api/v1/orgs/acme/store/orders/order_01j8z3k4m5n6p7q8r9s0t1v2w3?token=$ORDER_TOKEN" \
  -H "x-publishable-api-key: $PUBLISHABLE_KEY"

The order has its items, totals, shipping_methods and refunds, the fulfillments with their tracking, its documents, a timeline of what the shopper may see, such as shipments and the notes you share, and sold_by, the Coritan company that sold it. sold_by is null for an order paid on your own payment page, which your business sold.

Each of the order's items has product_id and product_handle, as a cart's items do. product_handle is the product's handle now, so an order placed before a rename links to the product's current page. It is null when the store deleted the product. The same holds for every order in GET /store/customers/me/orders?view=detail, which reads all the handles of a page of orders together.

Show the shopper the order's display_status, one word for where it stands: processing, completed, on_hold, refunded or canceled. What each status means explains each one, and when an order counts as completed follows the store's completed_when setting. status, payment_status and fulfillment_status keep their own values.

  • GET /store/orders/{order_id}/documents answers the invoice and any credit notes as data, with the issuer, the buyer, the lines and the tax, for your storefront to show.
  • GET /store/customers/me/orders lists a signed-in shopper's orders in the key's mode, newest first: 20 a page by default, and up to 100. Add display_id=1042 to find one order by its number: the answer holds only the shopper's own order with exactly that number, or an empty list.
  • POST /store/orders/lookup with an email, and optionally an order_number such as #1001, emails the links to up to 10 orders placed with that address while their tokens still work. The answer is always 202 with sent: true, so it never shows whether an address has ordered. An IP address can look up 10 times an hour, and each email address 3 times.

Let the shopper ask for a return

Section titled Let the shopper ask for a return

A shopper can ask to send back units that have shipped, or to exchange them for other variants. The request waits as requested for you to approve or decline it with the Commerce API, as Handle returns, exchanges and claims explains.

GET /store/return-reasons lists the reasons the shopper can pick, ordered by the rank you give them, each with its code, label and description. It leaves out the reasons you disabled.

The return routes of an order answer its signed-in shopper, or anyone who sends its token, as GET /store/orders/{order_id} does. Send the request with the items that go back:

Shell
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/store/orders/order_01j8z3k4m5n6p7q8r9s0t1v2w3/returns?token=$ORDER_TOKEN" \
  -H "x-publishable-api-key: $PUBLISHABLE_KEY" \
  -H "Idempotency-Key: 2c7e9b14-8f3a-4d6b-a1e5-7b0d3c9f6a28" \
  -H "Content-Type: application/json" \
  -d '{"items": [{"order_item_id": 5521, "quantity": 1, "reason_code": "too_small"}], "note": "The shoes are half a size too small."}'
Field Takes
items Up to 100 of the order's items, each with its id as order_item_id and the quantity that goes back. Each can add a reason_code from the store's reasons and a note of up to 500 characters.
exchange_items For an exchange, up to 50 variants the shopper wants instead, each a variant_id and a quantity.
note A note of up to 2,000 characters.

The answer is 201 with the return, which is requested. Its kind is exchange when it names new items, and return otherwise. refund_amount is what we expect to refund. It can fall when you approve the return with a fee, or when fewer units come back.

  • The shopper can ask for units that have shipped and are not already returned, refunded or in another return. Gift cards never come back.
  • The request must come within the store's returns window. The window is 30 days unless you set the store's returns_window_days, and 0 turns shopper requests off. It counts from when the item was delivered, or shipped when no delivery is recorded. When the item arrived in more than one shipment, the latest one counts.
  • We price the new items in the order's currency and region, for the shopper's customer groups, and tax them at the order's address. They must be for sale in the order's sales channel and in stock. They can cost no more than the units that go back are worth, because the Store API takes no payment for a difference.
  • Send an Idempotency-Key header, as when you complete a cart. A retry with the same key for the same order, within 24 hours, answers what the first request answered.
  • GET /store/orders/{order_id}/returns lists every return on the order, oldest first, including the ones you open yourself. Show each one's status, the instructions and label_url you add when you open or approve it, and the rejection_reason when you decline it.
  • Once we create the order that sends an exchange's new items, the return's exchange_order_id names it, and exchange_order_access_token is its token for GET /store/orders/{order_id}.
  • We email the shopper when a return is refunded, as for any refund. The other steps of a return send no email, so show them on your order page. The order's timeline shows them to the shopper too.
Answer What to do
409 with item_not_returnable Ask for no more than returnable_quantity units of the item in order_item_id.
409 with return_window_closed The window closed at closed_at. You can still open the return yourself with the Commerce API.
409 with exchange_payment_required The new items cost difference_amount more than what goes back. Let the shopper pick items that cost less, or ask for a refund. You can also open the exchange yourself and waive the difference.
409 with insufficient_inventory A new item has less stock than the shopper asks for, and available gives the units left.
409 with order_canceled or exchange_order The order was cancelled, or it sends the new items of an exchange. Neither takes a return, so show the shopper the store's support_email.
422 with invalid on items or exchange_items An order_item_id is not one of the order's items, a reason_code is not one of the store's reasons, or a new item is a gift card or is not for sale in the order's sales channel.
429 with rate_limited The order had 10 return requests in the last hour. Retry-After says when to try again.

These routes need the shopper's token, and reach only their own records:

  • GET /store/customers/me answers the shopper's email, name, phone, accepts_marketing, marketing_blocked and vat_id. PATCH changes first_name, last_name, phone, accepts_marketing and vat_id. Only the shopper can turn marketing on. Turning it off puts their address on the store's suppression list, so no flow email reaches them; turning it on takes the address off that list when they had unsubscribed themselves, and leaves one that bounced, complained or that the store added.
  • marketing_blocked is null, or the reason we cannot email the address and the shopper cannot change that: bounced (mail to it bounced), complained (it was reported as spam) or manual (the store turned it off). While it is set, accepts_marketing is false, and a PATCH with accepts_marketing: true answers 409 with marketing_blocked, the reason and a message that says to ask the store's support to turn it back on. We refuse the whole request, so a name sent with it is not saved. Hide the opt-in switch while marketing_blocked is set, or show that message. accepts_marketing: false still works, and a shopper who unsubscribed themselves has marketing_blocked set to null and can turn marketing on again.
  • GET /store/customers/me/addresses lists their saved addresses, up to 50. POST adds one, which needs address_1, city and country_code. The first address becomes the default for shipping and billing, and is_default_shipping and is_default_billing move the defaults. PATCH and DELETE on /store/customers/me/addresses/{address_id} change and delete one.

A sign-up form or a welcome popup sends the address a shopper gives it with POST /store/leads. It needs only the publishable key, not a shopper token:

Shell
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/store/leads" \
  -H "x-publishable-api-key: $PUBLISHABLE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email": "ada@example.com", "source": "popup", "variant": "gold", "consent": true, "consent_text": "Send me news and offers."}'
  • email is required, up to 254 characters. source (where the form is) and variant (the popup design shown) take up to 64 characters each, consent_text up to 500, and metadata a JSON object of up to 4,096 bytes.
  • The answer is 202 with {"ok": true} for every valid address, new or already on the list, so the form never tells a visitor which it was. A second sign-up with the same address in the same mode updates the lead's source, variant, consent, consent_text and metadata with what it sends, and keeps its first sign-up date.
  • When the store's leads_turnstile is true, send the Turnstile widget's answer as turnstile_token. Without a valid one the answer is 403 with turnstile_failed.
  • Each visitor's address can send 10 sign-ups an hour, and the store takes 2,000 an hour. Past either the answer is 429 with rate_limited.
  • A lead that sends consent: true also becomes a subscribed contact on the store's default list, with the lead's source and consent_text. Grow your email list covers lists, a confirmation click and tags.

To compare popup designs, send POST /store/leads/impressions with the design each time the popup opens, such as {"variant": "gold"}. It adds one to the design's views for the day in UTC and answers 204. Each visitor's address can send 120 an hour. The dashboard's Leads section divides each design's leads by its views.

A test key records test leads and views, and a live key live ones. Coritan stores no IP address or browser details with a lead.

When the store runs an affiliate program, its shoppers can share a link and earn a commission on the orders it brings. The merchant turns the program on and sets its rates in the store's affiliates setting. Your storefront does four things:

  1. Read the terms with GET /store/affiliates/program. It answers {"program": {...}} with enabled, first_order_rate and repeat_rate (percent strings such as "20.00"), cookie_days and reward_every. Show the join page only while enabled is true.
  2. Serve referral links at /ref/CODE/. When a visitor opens one, check the code with GET /store/affiliates/validate?code=CODE, which answers {"valid": true} or {"valid": false} and never says whose the code is. For a valid code, keep it in a cookie for cookie_days days, count the click with POST /store/affiliates/hits and {"code": "CODE"}, which answers 204, and show the shop's front page.
  3. While the cookie lasts, write the code into each cart's metadata as affiliate_code, such as PATCH /store/carts/{cart_id} with {"metadata": {"affiliate_code": "ADA20"}}. When the visitor came from a paid ad, also write "paid_ad": true: a store that blocks paid ads, the default, credits nothing for that order.
  4. Give signed-in shoppers an affiliate page. These routes need the shopper's token and reach only the shopper's own record.

validate and hits need only the publishable key. Each visitor's address can check 60 codes and count 120 clicks an hour. Past that the answer is 429 with rate_limited. A code that is not valid counts nothing, and the answer is still 204.

A shopper joins with POST /store/affiliates/me, with the code they want or {} for one the store makes up:

Shell
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/store/affiliates/me" \
  -H "x-publishable-api-key: $PUBLISHABLE_KEY" \
  -H "Authorization: Bearer $CUSTOMER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"code": "ada20"}'
  • A code is 3 to 32 letters, digits and hyphens, and is stored in capitals, so ada20 becomes ADA20. The answer is 201 with {"affiliate": {...}}, the record GET /store/affiliates/me answers.
  • A code another affiliate of the store has answers 409 with code_taken. A shopper who already joined gets 409 with already_affiliate.

GET /store/affiliates/me answers {"affiliate": {...}, "program": {...}}. The affiliate holds:

  • code, status (active or disabled) and created_at.
  • clicks, the counted clicks on their links, and orders, their referrals that are unpaid or paid. conversion_rate is orders per click, or null before the first click.
  • earnings: draft, unpaid and paid commission in minor units of the store's default currency, named in currency_code.
  • rewards: every (qualified referrals per reward, 0 when the program gives none), earned, claimed, available, and variant_ids, the products a reward can be.
  • referrals, payouts and claims, the latest 50 of each. A referral shows the order's order_display_id, first_order, commission, currency_code and status, and never the shopper who ordered.

A shopper who has not joined gets 404 with not_an_affiliate. While the program is off, every me route answers 404 with program_disabled.

A referral is draft when its order is placed and unpaid once the payment is taken. It becomes rejected when the order is cancelled or fully refunded, and paid when the merchant records a payout that includes it. The first order an affiliate brings links that email address to the affiliate, so the shopper's later orders earn the repeat rate without the code while the program keeps customers for life. Nobody earns commission on their own orders.

When rewards.available is at least 1, the shopper can claim a reward with POST /store/affiliates/me/claims:

JSON
{"variant_id": 6, "shipping_address": {"first_name": "Ada", "last_name": "Lovelace", "address_1": "1 Dock Road", "city": "Hamburg", "postal_code": "20095", "country_code": "DE"}}
  • variant_id is one of rewards.variant_ids. The address needs first_name, last_name, address_1, city and country_code. Either wrong answers 422 naming the field.
  • The answer is 201 with {"claim": {"id", "reward_number", "variant_id", "status", "created_at"}}. The claim is requested until the merchant approves or rejects it, and shipped once they send it. A rejected claim gives its reward back.
  • With no reward available the answer is 409 with no_reward_available. An affiliate the merchant disabled gets 409 with affiliate_disabled.

Send ad tracking with the cart

Section titled Send ad tracking with the cart

When the store sends its orders to Meta, TikTok, OpenAI Ads, GA4 or Google Ads from the server, or lists them in a file for Microsoft Ads (Integrations), the platforms match an order to an ad more often when they get the shopper's click IDs. Write them into the cart's metadata as a tracking object before checkout, and the order keeps it:

JSON
{"metadata": {"tracking": {"fbp": "fb.1.1727600000000.123456789", "fbc": "fb.1.1727600000000.IwAR2abc",
  "ttp": "2mYk3LqVbX0aQ9", "ttclid": "E.C.P.abc123", "obref": "op_7f3a2c", "gclid": "Cj0KCQjwexample",
  "msclkid": "4b1f6d0e2c9a4f3b8e7d6c5b4a392817", "source": "www.bing.com",
  "ip": "203.0.113.24", "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6) AppleWebKit/605.1.15"}}}
  • fbp and fbc are Meta's _fbp and _fbc cookies, and go to Meta.
  • ttp is TikTok's _ttp cookie and ttclid its click ID, from the cookie or the ttclid URL parameter; both go to TikTok.
  • obref is OpenAI's __oppref cookie, and goes to OpenAI Ads.
  • ga_client_id and ga_session_id are GA4's client ID (from the _ga cookie, GA1.1.<client id>) and session ID (from the _ga_<stream> cookie), and let a GA4 purchase join the shopper's visit.
  • gclid, gbraid and wbraid are Google's click IDs, from the URL parameters of the same names or from Google's _gcl_* cookies, and go to Google Ads. A cookie value as Google writes it, such as GCL.1727600000.Cj0KCQjwexample, is cut to the ID after its second dot.
  • msclkid is Microsoft's click ID, from the msclkid URL parameter of the page the shopper landed on. The Microsoft Ads file lists it when it is 32 hexadecimal digits or a GUID, after taking off a leading _uet.
  • source is where the shopper's first visit came from: its utm_source, or else the host of the page that referred it, such as www.bing.com. The Microsoft Ads file reads it to tell a visit from Microsoft when an order has no usable click ID.
  • ip and ua are the shopper's IP address and User-Agent. Send them when your storefront calls the Store API from its own server, where the request carries the server's address, unless it passes the visitor's address with a server key. Without them or a server key, the platforms get the address and browser of the request that completed the checkout.

Every key is optional and each value is text, cut to 500 characters, or to 200 for source. An ip that is not an IP address is left out. A PATCH with metadata replaces the cart's whole metadata, so send tracking together with the other keys you keep there, such as affiliate_code.

To send a shopper who ticked a marketing box at checkout to EmailOctopus, put "marketing_consent": true in the cart's metadata. A shopper who turns on accepts_marketing in their account joins the list too, and one who turns it off is marked unsubscribed there.

Send where the shopper came from

Section titled Send where the shopper came from

The New order email to your team (Emails) says where each order came from: the shopper's first visit and their latest one, each with the page they landed on, the site that sent them and the UTM parameters, and the time on the shopper's own clock. Your storefront knows these; write them into the cart's metadata as an attribution object before checkout, and the order keeps it:

JSON
{"metadata": {"attribution": {
  "first": {"landing": "/shop/?utm_source=google&utm_medium=cpc", "referrer": "https://www.google.com/",
            "utm_source": "google", "utm_medium": "cpc", "utm_campaign": "autumn", "click": {"gclid": "Cj0KCQjwexample"},
            "at": "2026-10-01T12:00:00Z"},
  "last": {"landing": "/product/board-game/", "referrer": "https://news.example.org/story", "at": "2026-10-03T08:30:00Z"},
  "browser_time": {"hour": "14-15", "day": "Thursday", "month": "October"},
  "device": "mobile"}}}
  • first is the shopper's first visit and last their latest visit from another site. Send the same visit as both when there was only one.
  • In each visit, landing is the page they arrived on, as a path or a full address; referrer is the page that sent them; utm_source, utm_medium, utm_campaign, utm_content, utm_term and utm_id are the landing page's UTM parameters; click holds the ad click IDs by name, such as gclid or msclkid; and at is when the visit began, as an ISO 8601 time.
  • browser_time is the shopper's clock when they ordered: hour as a range such as 14-15, day the weekday and month the month. One string such as "14-15|Thursday|October" works too.
  • device is mobile, tablet or desktop.

Every key is optional. The email shows a visit's traffic source as the referrer's host, or direct when no site sent the shopper, and leaves out what you did not send. Send attribution together with the other metadata keys you keep, since a PATCH with metadata replaces them all.

Count a Microsoft Ads purchase once

Section titled Count a Microsoft Ads purchase once

When the store lists its orders for Microsoft Ads and your thank-you page also reports purchases with the UET tag, ask the store before the page fires the purchase event. POST /store/orders/{order_id}/conversions/microsoft-ads/claim takes the publishable key with the shopper's token when they are signed in, or the order's token, as GET /store/orders/{order_id} does:

Shell
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/store/orders/order_01j8z3k4m5n6p7q8r9s0t1v2w3/conversions/microsoft-ads/claim?token=$ORDER_TOKEN" \
  -H "x-publishable-api-key: $PUBLISHABLE_KEY"
  • The answer is {"fire": true} once for each order: when the order is live and paid, the file has not listed it, and Microsoft Ads is on and out of test mode. Fire the UET purchase event then. The file never lists that order.
  • Every other call answers {"fire": false}, and the page fires nothing: a repeat, an order the file already listed, a test, unpaid, cancelled or imported order, and every call while Microsoft Ads is off or in test mode. An order that was not paid when you asked is left to the file, which lists it once it is paid.
  • Ask as soon as the order is placed. Once a fetch of the file has listed the order, the answer is false.
  • An order that does not exist, is another shopper's or belongs to the key's other mode, and a wrong token, answer 404 with not_found.
  • One order takes 10 calls an hour, and one IP address 600 an hour. Past that the answer is 429 with rate_limited, and the order is left to the file.
  • Your storefront must call this route for every order. An order it never asks about is not claimed: the file lists it, and the UET tag counts it in the browser too. Each Microsoft Ads integration answer carries claims, which counts the orders claimed (browser) and the orders the file listed (feed) in the last 7 days (window_days), and the dashboard card warns when the file listed orders and none was claimed (Count an order on the thank-you page).
Section titled Answer the links in flow emails

The store's marketing emails link to two pages on your storefront, at the Storefront address set in Settings. Build both.

The page at /cart/recover?t=… opens the cart an abandoned cart or unpaid checkout email is about. Send the token to GET /store/carts/recover, with the publishable key and, when the shopper is signed in, their token. Then keep the cart's id as the storefront's cart and show the cart page:

Shell
curl "https://api.coritan.com/api/v1/orgs/acme/store/carts/recover?t=$TOKEN" \
  -H "x-publishable-api-key: $PUBLISHABLE_KEY"
  • The answer is {"cart": {...}}, as GET /store/carts/{cart_id} answers it.
  • A link lasts 7 days. An expired or altered token, a token from another store, or a cart that was checked out or expired answers 404 with not_found. Show the shop's front page then.
  • A cart that belongs to a customer account answers 403 with cart_forbidden unless that customer is signed in. Ask the shopper to sign in, then try again.

The page at /unsubscribe?t=… takes the shopper off the store's marketing emails. Show a page with one button, and when the shopper selects it, send the token to POST /store/unsubscribe:

Shell
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/store/unsubscribe" \
  -H "Content-Type: application/json" \
  -d '{"token": "'"$TOKEN"'"}'
  • It needs no publishable key. The answer is 200 with {"ok": true}, as often as it is sent, and the address goes on the store's suppression list. Order and shipping emails still reach it.
  • A token that is not valid, or is another store's, answers 400 with invalid_token. An unsubscribe token never expires.
  • Each IP address can send 120 a minute. Past that the answer is 429 with rate_limited.
  • Do not unsubscribe when the page opens. Mail scanners open links, and a shopper would be taken off the list without asking.

The emails also carry List-Unsubscribe and List-Unsubscribe-Post: List-Unsubscribe=One-Click headers that point at POST /store/unsubscribe?t=…, so a mail app's own unsubscribe button works without your page. A store with no storefront address links to that route's own confirmation page instead.

Email marketing sends campaigns and automations to the contacts on your lists. Your storefront adds contacts, confirms them, shows the preference centre and takes unsubscribes with the routes below. They sit under /store/marketing and take the publishable key, with no shopper token. The API reference lists them under Marketing Store API. A test key makes test contacts and a live key makes live ones.

These routes are for contacts of email marketing. POST /store/leads (Collect leads) records a lead for your popup figures, and when the lead carries consent, it also makes a subscribed contact on your default list. The unsubscribe route of the store's flow emails, POST /store/unsubscribe, is a different one from POST /store/marketing/unsubscribe below.

POST /store/marketing/subscribe signs an address up:

Shell
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/store/marketing/subscribe" \
  -H "x-publishable-api-key: $PUBLISHABLE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email": "ada@example.com", "first_name": "Ada", "list": "Newsletter", "tags": ["footer"],
       "consent": true, "consent_text": "Send me news and offers from Acme.", "source": "footer"}'
JSON
{"ok": true, "confirmation_required": false}
Field Takes
email Required.
consent Required, and it must be the boolean true. The shopper ticked a box that holds your consent words. Anything else answers 422 with consent_required.
consent_text The words the shopper agreed to, up to 500 characters. It is kept on the contact as the proof of consent.
first_name, last_name Up to 100 characters each.
fields Custom field values by key.
list_ids or list The lists to join, up to 20 ids, or one list by id or by name. Without either, the form's lists, or your default list.
tags Up to 20 tag names.
source Where on the storefront the signup was, up to 64 characters, such as footer. Without it, the source is form: and the form's number when you send form_id, and api when you send neither.
form_id A signup form's public_id. Its tags and confirmation setting apply, its lists apply when you name none, its consent words apply when you send none, and the signup counts toward its Signups.
turnstile_token The Turnstile widget's answer, when the store's leads_turnstile is true or the form asks for it. Without a valid one the answer is 403 with turnstile_failed.
  • The answer is 202 for every valid address, whether the store knew it or not, so your form never tells a visitor which. An address that unsubscribed, bounced or marked an email as spam stays out and still gets this answer.
  • confirmation_required is true when a chosen list asks for double opt-in. The address then gets the Confirm subscription email, and joins the list when its link is opened. Show a message such as "Check your inbox".
  • A contact that exists keeps what it has. A name or a field that is empty is filled in, and none is overwritten.
  • Each visitor's address can send 10 signups an hour, and the store takes 2,000 an hour. Past either the answer is 429 with rate_limited. One email address can sign up 5 times an hour, and past that the answer is the usual one and nothing happens.

The confirmation email links to <storefront_url>/m/confirm/<token>, or to the same path on https://api.coritan.com when the store has no storefront address. Your storefront has two choices. It can forward /m/ to the API's hosted pages, which show the page in your organization's name and colours. Or it can serve the page itself and send the token to POST /store/marketing/confirm:

Shell
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/store/marketing/confirm" \
  -H "x-publishable-api-key: $PUBLISHABLE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"token": "'"$TOKEN"'"}'
  • The answer is {"ok": true, "list": {"id": 3, "name": "Newsletter"}}. The contact joins the list, and gets the list's welcome email when it has one. Sending the token again changes nothing.
  • A token that is not valid, has expired (it works for 7 days) or is another store's answers 400 with invalid_token. An address that left or was suppressed in the meantime answers 409 with unsubscribed.

Campaign and automation emails link to the contact's preference centre on the API host, at https://api.coritan.com/m/p/<token>. The welcome email links to it at <storefront_url>/m/p/<token> when the store has a storefront address. The link works for 90 days. Forward /m/ as above, or read the token from the address and use these routes:

  • GET /store/marketing/preferences?t=<token> answers {"email_masked": "a***@example.com", "status": "subscribed", "lists": [{"id": 3, "name": "Newsletter", "description": null, "subscribed": true}]}. The lists are the ones marked Offer on the preference page, and any the contact is on.
  • PUT /store/marketing/preferences saves the choices. Send the token as t and list_ids, the lists the contact should end up on, or unsubscribe_all: true. A reason of up to 200 characters is kept with an unsubscribe. The answer is {"ok": true}.
Shell
curl -X PUT "https://api.coritan.com/api/v1/orgs/acme/store/marketing/preferences" \
  -H "x-publishable-api-key: $PUBLISHABLE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"t": "'"$TOKEN"'", "list_ids": [3], "unsubscribe_all": false}'

A contact who unsubscribed and picks lists is subscribed again, because it is their own consent. A list the page does not offer answers 422 with invalid. An address that bounced, was reported as spam or was turned off by the store answers 409 with marketing_blocked. An invalid or expired token answers 400 with invalid_token. Each IP address can call confirm, preferences and unsubscribe 120 times a minute together.

POST /store/marketing/unsubscribe unsubscribes the contact a marketing email was sent to, from every list. Send the token from that email's unsubscribe link, the code that ends /m/u/<token>, as t, and an optional reason:

Shell
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/store/marketing/unsubscribe" \
  -H "x-publishable-api-key: $PUBLISHABLE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"t": "'"$TOKEN"'", "reason": "Too many emails"}'

The answer is 200 with {"ok": true} as often as it is sent, and the address goes on the store's suppression list. A token that is not valid, or is another store's, answers 400 with invalid_token. The unsubscribe link of a marketing email opens the API host's own page at /m/u/<token>, which asks for a click before it unsubscribes. Use this route only when your storefront serves a page of its own for that token. As with flow emails, do not unsubscribe when a page opens, because mail scanners open links.

GET /store/marketing/forms/{public_id} answers the configuration of a signup form, to draw it in your storefront: {id, name, kind, fields, consent_text, design, turnstile, lists_visible}. id is the public_id, the value to send as form_id. Each field has its input type and, for a choice, its choices. turnstile is {enabled, site_key}, with provider and script_url when your organization uses Wicket. The lists and tags a signup applies are never shown. A form that is not this store's, or is archived, answers 404 with not_found.

Call POST /store/marketing/forms/{public_id}/views each time the form shows. It adds one to the form's views and answers 204. Each visitor's address can send 300 an hour for a form, and past that the answer is 429 with rate_limited. To submit the form, call subscribe with form_id set. The route applies the form's lists, tags and confirmation setting, but your storefront checks the form's required fields.

An error answers with a status and a detail object that holds a machine-readable error code and a message you can show the shopper. Some errors add fields, such as blockers or available.

JSON
{"detail": {"error": "cart_not_ready", "message": "The cart cannot be completed yet.", "blockers": ["shipping_method_required"]}}
Status error Meaning
400 invalid_token An unsubscribe, confirmation or preference token that is not valid, has expired or is another store's.
401 publishable_key_required, invalid_publishable_key The key is missing, revoked or for another store.
403 store_not_live A live key, while the store is still in test mode.
403 cart_forbidden The cart belongs to another shopper.
403 turnstile_failed A sign-up needed a Turnstile answer and the one sent was missing or not valid.
404 commerce_not_enabled The organization has no store, or Coritan disabled it.
404 not_found The thing does not exist in this store or this mode, or the order is not the shopper's.
404 program_disabled, not_an_affiliate The store's affiliate program is off, or the shopper has not joined it.
409 cart_not_open The cart already has its order, or was abandoned.
409 unsubscribed, marketing_blocked A signup to confirm was unsubscribed or suppressed meanwhile, or a preference change was refused for an address that bounced, reported spam or was turned off by the store.
409 code_taken, already_affiliate, no_reward_available Another affiliate has the code, the shopper already joined, or no reward is available to claim.
422 invalid or a specific code The input is wrong; field names it when it can. consent_required means a marketing sign-up did not send consent: true.
429 rate_limited Too many attempts. Retry-After says when to try again.
503 store_unavailable Coritan suspended the store, so it takes no orders.

With a test key, your storefront lists the catalogue, fills a cart, takes a test payment and places a test order. The order appears in the Commerce API's GET /commerce/orders?livemode=false, and the shopper receives a confirmation whose subject starts with [TEST]. Once the store is live, the same code with a live key takes real orders.

401 with invalid_publishable_key
The key was revoked or mistyped, or belongs to another organization. List the keys with GET /commerce/publishable-keys, or create one.
403 with store_not_live
The store is still in test mode. Use a test key until we switch the store to live.
The browser reports a CORS error
The store lists allowed_origins, and your site is not among them. Add its origin with PATCH /commerce/store. The change applies within 30 seconds.
422 with country_not_served or country_not_in_region
The store has no region for that country, or the address is outside the cart's region. Move the cart with PATCH /store/carts/{cart_id} and a country_code, or add the country to a region.
409 with price_unavailable
The variant has no price in the cart's currency. Give it one for that currency or region with the Commerce API.
409 with cart_not_ready
Something in blockers stops checkout. Check the cart before payment says what each one needs.
409 with payment_already_authorized
The cart is already paid for. Complete it.
409 with cart_not_open
The cart is completed or abandoned. Completing a completed cart again answers its order; otherwise create a new cart.
422 with invalid on return_url or cancel_url
The return addresses of PayPal and of a payment page must be on your storefront. Set the store's storefront_url, or add your site's origin to allowed_origins.
429 with rate_limited from your server
Every visitor your server calls for shares its address's limits. Pass the visitor's address with a server key, so each visitor counts on their own. Retry-After says when to try again.
409 with payment_provider_not_offered when a payment starts
The way to pay is not on the cart's payment route. Offer only what GET /store/carts/{cart_id}/payment-providers lists, and the answer's available names it.
The shopper came back from the payment page, but completing answers payment_processing
The page has not reported the payment yet. Complete the cart again in a moment. We place the order as soon as the page reports it, even when nobody completes the cart.
An order link answers 404
The token is wrong, or the order is older than the store's order_access_days. A signed-in shopper can still read their own orders.

API operations on this page

MethodPathWhat it does
GET/api/v1/orgs/{org_slug}/storeStore info
GET/api/v1/orgs/{org_slug}/store/return-reasonsList return reasons
POST/api/v1/orgs/{org_slug}/store/affiliates/hitsOne click on a referral link today (UTC); 204 whether or not the code counted
GET/api/v1/orgs/{org_slug}/store/affiliates/meThe shopper's own affiliate record and the program's terms
POST/api/v1/orgs/{org_slug}/store/affiliates/meMake the shopper an affiliate with the code they ask for, or one made up
POST/api/v1/orgs/{org_slug}/store/affiliates/me/claimsClaim the next reward the shopper has earned
GET/api/v1/orgs/{org_slug}/store/affiliates/programThe program's public terms, for the storefront's cookie and its signup page
GET/api/v1/orgs/{org_slug}/store/affiliates/validate{valid}: whether the code belongs to an active affiliate while the program is on
POST/api/v1/orgs/{org_slug}/store/cartsA cart in the key's mode and sales channel, in a region and its currency
GET/api/v1/orgs/{org_slug}/store/carts/recoverThe cart an abandoned cart or failed payment email links to, while it is open
GET/api/v1/orgs/{org_slug}/store/carts/{cart_id}Get cart
PATCH/api/v1/orgs/{org_slug}/store/carts/{cart_id}Email, addresses, region or country (re-prices every line), note, locale, metadata
POST/api/v1/orgs/{org_slug}/store/carts/{cart_id}/completePlace the cart's order: {"order", "accesstoken"}
GET/api/v1/orgs/{org_slug}/store/carts/{cart_id}/feesEvery fee the cart is offered, with selected: the same list as the cart's fees
POST/api/v1/orgs/{org_slug}/store/carts/{cart_id}/fees/{code}Select an offered fee
DELETE/api/v1/orgs/{org_slug}/store/carts/{cart_id}/fees/{code}Turn an offered fee off; the choice is kept even for a fee on by default
POST/api/v1/orgs/{org_slug}/store/carts/{cart_id}/gift-cardsApply a gift card by its code: 10 attempts per 10 minutes per cart and per client IP
DELETE/api/v1/orgs/{org_slug}/store/carts/{cart_id}/gift-cards/{gift_card_id}Remove gift card
POST/api/v1/orgs/{org_slug}/store/carts/{cart_id}/line-itemsAdd a variant; the same variant with the same metadata merges into its line
PATCH/api/v1/orgs/{org_slug}/store/carts/{cart_id}/line-items/{line_id}Set a line's quantity; 0 removes it
DELETE/api/v1/orgs/{org_slug}/store/carts/{cart_id}/line-items/{line_id}Delete line item
GET/api/v1/orgs/{org_slug}/store/carts/{cart_id}/payment-providersThe providers that can take this cart's payment, and whether it needs one
POST/api/v1/orgs/{org_slug}/store/carts/{cart_id}/payment-sessionsStart the payment with provider, or refresh the one already started for the same amount
POST/api/v1/orgs/{org_slug}/store/carts/{cart_id}/promotionsApply a code
DELETE/api/v1/orgs/{org_slug}/store/carts/{cart_id}/promotionsRemove a code, named in the body {"code"} or as ?code=
POST/api/v1/orgs/{org_slug}/store/carts/{cart_id}/shipping-methodsChoose an option; it replaces the method its shipping profile had
GET/api/v1/orgs/{org_slug}/store/carts/{cart_id}/shipping-optionsThe options the cart's address and items allow, priced for them, per shipping profile
GET/api/v1/orgs/{org_slug}/store/categoriesThe active, public categories as a tree, by rank and then name
GET/api/v1/orgs/{org_slug}/store/categories/{handle}Get category
GET/api/v1/orgs/{org_slug}/store/collectionsList collections
GET/api/v1/orgs/{org_slug}/store/collections/{handle}A published collection; its products are /store/products?collectionhandle=
GET/api/v1/orgs/{org_slug}/store/customers/meGet me
PATCH/api/v1/orgs/{org_slug}/store/customers/meName, phone, marketing consent (the shopper alone turns it on) and VAT id
GET/api/v1/orgs/{org_slug}/store/customers/me/addressesThe signed-in shopper's saved addresses, oldest first
POST/api/v1/orgs/{org_slug}/store/customers/me/addressesAdd my address
PATCH/api/v1/orgs/{org_slug}/store/customers/me/addresses/{address_id}Update my address
DELETE/api/v1/orgs/{org_slug}/store/customers/me/addresses/{address_id}Delete my address
GET/api/v1/orgs/{org_slug}/store/customers/me/ordersList my orders
POST/api/v1/orgs/{org_slug}/store/leadsRecord a sign-up
POST/api/v1/orgs/{org_slug}/store/leads/impressionsOne more view of a popup design today (UTC)
POST/api/v1/orgs/{org_slug}/store/marketing/confirmConfirm a double opt-in signup with the token from the email's link
GET/api/v1/orgs/{org_slug}/store/marketing/forms/{public_id}The public configuration of one signup form, to draw it in a storefront
POST/api/v1/orgs/{org_slug}/store/marketing/forms/{public_id}/viewsCount one view of the form: call it when the form is shown
GET/api/v1/orgs/{org_slug}/store/marketing/preferencesGet preferences
PUT/api/v1/orgs/{org_slug}/store/marketing/preferencesPut preferences
POST/api/v1/orgs/{org_slug}/store/marketing/subscribeSign an address up to the store's marketing email
POST/api/v1/orgs/{org_slug}/store/marketing/unsubscribeUnsubscribe
POST/api/v1/orgs/{org_slug}/store/orders/lookupLook up orders
GET/api/v1/orgs/{org_slug}/store/orders/{order_id}Get order
POST/api/v1/orgs/{org_slug}/store/orders/{order_id}/conversions/microsoft-ads/claimClaim microsoft ads
GET/api/v1/orgs/{org_slug}/store/orders/{order_id}/documentsThe invoice and credit notes, as issued by the seller of record
POST/api/v1/orgs/{org_slug}/store/orders/{order_id}/heard-aboutKeep the shopper's answer to the thank-you page's "How did you first hear about us?"
GET/api/v1/orgs/{org_slug}/store/orders/{order_id}/returnsThe order's returns, oldest first
POST/api/v1/orgs/{org_slug}/store/orders/{order_id}/returnsRequest return
GET/api/v1/orgs/{org_slug}/store/productsPublished products in the key's sales channels, with count across every page
GET/api/v1/orgs/{org_slug}/store/products/{id_or_handle}One published product by handle (or by id), priced like the list
GET/api/v1/orgs/{org_slug}/store/regionsThe regions the store sells in, by name, to pick a country and currency from
GET/api/v1/orgs/{org_slug}/store/regions/{region_id}Get region
POST/api/v1/orgs/{org_slug}/store/unsubscribeSuppress the token's address for this store's marketing emails