Sell with the Commerce API
Run a store with Coritan as the merchant of record: products, regions, prices, orders, refunds and fulfilment from your systems.
The Commerce API is the back office of your organization's store for goods. Your own systems use it to run the catalogue, prices, stock, shipping, promotions, orders, refunds and fulfilment. Shoppers buy through the Store API, which your storefront calls with a publishable key.
Coritan is the store's merchant of record. A Coritan company is the seller on every order: it takes the payment, issues the invoice and credit notes in its own name and accounts for the tax. Your balance records the part of each sale that is yours.
Every route is under https://api.coritan.com/api/v1/orgs/{org_slug}/commerce/. The API reference lists them under Commerce, with the product routes under Catalog & Services and the customer routes under Customers. This page covers the main tasks, and the reference has every field. The organization's Commerce tab does the same tasks in the dashboard (Run your store from the Commerce tab).
Before you begin
Section titled Before you begin- Ask support to turn commerce on for your organization. We create the store in
testmode, with one sales channel calledWeb store. Until then, every route answers404withcommerce_not_enabled. - Get a credential: an organization API key with the scopes your system needs, or a member's access token. Authenticate explains both.
- Have your company's legal details and its owners' names ready for the merchant profile. The store takes real orders only after we approve it.
Authenticate
Section titled AuthenticateSend one of these with every request:
- An organization API key in the
X-API-Keyheader, for your own systems. Create it through the API with the scopes it needs, as Create a key with scopes shows. A key made in the dashboard has no scopes, so every route here refuses it. - A member's token in
Authorization: Bearer, for a person: their coritan.com access token, or the token of their staff console session. Their role decides what the token can do.
curl "https://api.coritan.com/api/v1/orgs/acme/commerce/store" \
-H "X-API-Key: $ORG_API_KEY"
When a request carries X-API-Key, we read the key and ignore any Authorization header. No route asks for a step-up.
Scopes and roles
Section titled Scopes and rolesEach route needs one scope from a key, or a role from a member. A :write scope includes the :read scope of its area. commerce:* grants every scope, and an area with :*, such as commerce.orders:*, grants every scope in that area.
| Scope | What it allows | Lowest role |
|---|---|---|
commerce.store:read |
Reading the store, its publishable keys and its event log. | Read only |
commerce.store:write |
Changing the store's settings, publishable keys, sales channels, regions, shipping profiles, zones and options, and fulfilment providers. | Admin |
commerce.catalog:read |
Reading products, tags, collections, categories, price lists, regions, shipping, stock locations, inventory items, sales channels and imports. | Read only |
commerce.catalog:write |
Changing products with their options, variants, prices and images; collections, categories and price lists; stock locations, inventory items and the locations each sales channel sells from; importing from Shopify. | Admin |
commerce.inventory:write |
Setting stock levels. | Tier 3 support |
commerce.customers:read |
Reading customers, customer groups and gift cards. | Tier 1 support |
commerce.customers:write |
Changing customers and customer groups. | Tier 3 support |
commerce.orders:read |
Reading orders with their documents, fulfilments and returns, the return reasons, and fulfilment providers with their deliveries and events. | Tier 1 support |
commerce.orders:write |
Changing an order and adding notes to it. | Tier 2 support |
commerce.refunds:write |
Refunding and cancelling orders, and refunding returns. | Billing |
commerce.promotions:write |
Reading and changing promotions, and issuing, changing and adjusting gift cards. | Admin |
commerce.fulfillment:write |
Creating fulfilments, marking them dispatched, shipped, delivered or cancelled, and sending a fulfilment provider's events. | Tier 3 support |
commerce.returns:write |
Opening, approving, declining, receiving and cancelling returns, and changing the return reasons. Reading them takes commerce.orders:read. |
Tier 2 support |
commerce.finance:read |
Reading the balance, the ledger, disputes, payouts and tax reports. | Billing |
commerce.disputes:write |
Changing a dispute's evidence, sending it and accepting a dispute. | Billing |
The lowest role applies to a member's token. The roles form a ladder, and each role can do what the roles below it can: Read only, then Tier 1, Tier 2 and Tier 3 support, then Billing, then Admin and Owner. Organization roles and permissions describes them. The merchant profile takes an owner's or admin's token, and no key can reach it, because it holds the identities of your company's owners. Asking for a payout also takes an owner's or admin's token, and no key can ask for one.
Check the store's status
Section titled Check the store's statusGET /commerce/store answers the store with its settings and its status, the merchant review's status under merchant, can_go_live, and the store's sales_channels.
| Status | What it means |
|---|---|
test |
The status a new store starts in. It takes test orders, paid with test payments through test publishable keys. |
live |
The store also takes real orders, through live publishable keys. It needs an approved merchant profile, and Coritan makes the switch. |
suspended |
Shoppers cannot buy: the Store API answers 503 with store_unavailable. This API keeps working. |
disabled |
Every route answers 404 with commerce_not_enabled, except reading the store and the merchant profile. |
Test and live business stay apart. Orders, gift cards and events carry livemode, which is false for test ones, and test orders never reach your balance.
Submit the merchant profile
Section titled Submit the merchant profileCoritan sells your goods in its own name, so we check who you are before the store goes live. The profile routes take an owner's or admin's token.
Read the profile with
GET /commerce/merchant-profile. The first read creates it as adraft, filled in from your organization's details. The answer hasprofile,missing_for_submissionwith what review still needs, andeditable.Fill it in with
PUT /commerce/merchant-profile. Send the fields you change; the others keep their values.Shellcurl -X PUT "https://api.coritan.com/api/v1/orgs/acme/commerce/merchant-profile" \ -H "Authorization: Bearer $CORITAN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "legal_name": "Acme Goods Ltd", "country_code": "GB", "support_email": "support@example.com", "product_description": "Printed T-shirts and mugs.", "address": {"line1": "1 Example Street", "city": "London", "postal_code": "EC1A 1AA", "country_code": "GB"}, "ship_from": {"line1": "Unit 4, Example Park", "city": "Leeds", "postal_code": "LS1 1AA", "country_code": "GB"}, "return_address": {"line1": "Unit 4, Example Park", "city": "Leeds", "postal_code": "LS1 1AA", "country_code": "GB"}, "beneficial_owners": [{"name": "Alex Example", "date_of_birth": "1985-04-12", "country_code": "GB", "ownership_percent": 100}] }'Send it to review with
POST /commerce/merchant-profile/submit. When something is missing, the answer is422with the missing fields inmissing.
Review needs legal_name, country_code, support_email, product_description, at least one entry in beneficial_owners, and three addresses: address for the company, ship_from and return_address, each with at least line1, city and country_code. An address also takes company, first_name, last_name, line2, province, postal_code and phone. Each owner takes a name, a date_of_birth, a country_code and an ownership_percent from 0 to 100, and we keep the first 10. The profile also takes trading_name, legal_form, registration_number, tax_id, website, support_phone, up to 20 product_categories, expected_monthly_volume and volume_currency.
| Status | What it means |
|---|---|
draft |
You are filling it in. Changing a submitted profile brings it back here, and so does Coritan reopening a rejected or suspended one. |
submitted |
Waiting for review. |
approved |
Coritan can switch the store to live. |
restricted |
Review stopped short of approval. Read review_notes, change the profile and submit it again. |
rejected |
Review turned it down. You can change it and submit it again. |
suspended |
Coritan suspended the merchant, which also suspends a live store. |
An approved or suspended profile is locked: changing it answers 409 with profile_locked, and support makes any change. After review, the profile's terms show the fee, the reserve and the payout delay Coritan set, and review_notes hold what the reviewer wrote.
Set up the store
Section titled Set up the storeA shopper can check out once the store has a region for where they live, a stock location, shipping, products with prices and a publishable key for your storefront. Money is always a whole number in the currency's smallest unit: 2500 in EUR is €25.00.
Change the store's settings
Section titled Change the store's settingsPATCH /commerce/store changes the store's name, default_currency and supported_currencies, default_locale and supported_locales (language tags such as en-GB), allowed_origins, storefront_url, support_email, and the keys under settings.
curl -X PATCH "https://api.coritan.com/api/v1/orgs/acme/commerce/store" \
-H "X-API-Key: $ORG_API_KEY" \
-H "Content-Type: application/json" \
-d '{"default_currency": "EUR", "allowed_origins": ["https://shop.example.com"], "storefront_url": "https://shop.example.com", "settings": {"terms_url": "https://shop.example.com/terms", "terms_version": "2026-09"}}'
allowed_origins lists the sites whose browsers may call the Store API, each as a scheme and host, up to 20. The default currency is always among the supported ones.
| Setting | What it does |
|---|---|
guest_checkout |
true by default. false makes shoppers sign in before they pay. |
order_access_days |
For how many days after an order is placed its access token still opens it. Defaults to 90. |
terms_url, terms_version |
When terms_url is set, shoppers must accept your terms to pay, and each order records the version they accepted (up to 40 characters). |
privacy_url |
Your privacy policy, which the Store API passes to your storefront. |
auto_dispatch |
false stops us sending fulfilments to any provider on our own. |
release_delay_minutes |
The least time every fulfilment provider waits after an order is placed, up to 10,080 (a week). |
risk_high_value |
The amount above which a first order counts as high value, in minor units. Defaults to 50000. |
returns_window_days |
For how many days after an item is delivered a shopper can ask to return or exchange it, counted from when it shipped if no delivery is recorded. Defaults to 30, and 0 stops shoppers from asking. |
refund_policy, shipping_policy |
Your refund and shipping policies as text, up to 5,000 characters each. Dispute evidence quotes them. |
payout_schedule |
How often we create payouts: daily, weekly or monthly. Defaults to weekly. |
Setting a key to null removes it, which brings back its default. A key the store does not know answers 422.
Add regions
Section titled Add regionsA region is a group of countries that share a currency, tax handling and payment methods. A cart belongs to one region and is priced in its currency.
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/regions" \
-H "X-API-Key: $ORG_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Europe", "currency_code": "EUR", "countries": ["DE", "FR", "NL"], "payment_providers": ["stripe", "paypal"]}'
payment_providerssays whether shoppers in the region pay withstripe,paypalor both, and defaults to["stripe"]. PayPal takes only currencies with two decimal places. A test cart can also pay withmanual, a test payment that moves no money.- Prices exclude tax unless you set
tax_inclusivetotrue. We work out the tax on each cart from Coritan's own rates for the shopper's address, and charge none where Coritan does not collect tax. The region'sautomatic_taxesfield changes nothing. - A country belongs to one region at most, and only where Coritan sells. A country we do not sell into yet answers
409withmarket_not_enabled. - The first enabled region becomes the store's default. Send
is_default: trueto move the default. The default region cannot be disabled or deleted. - A region's currency joins the store's supported currencies. Deleting a region also deletes the prices set for it.
A store has up to 50 regions.
Add stock locations
Section titled Add stock locationsA stock location is a place that holds your goods, such as a warehouse. Stock is counted per location.
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/stock-locations" \
-H "X-API-Key: $ORG_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Leeds warehouse", "address": {"line1": "Unit 4, Example Park", "city": "Leeds", "postal_code": "LS1 1AA", "country_code": "GB"}, "is_default": true}'
Set fulfillment_provider_id on a location that a fulfilment provider runs. A store has up to 100 locations. A sales channel sells from every enabled location until you choose some with PUT /commerce/sales-channels/{channel_id}/stock-locations.
Set up shipping
Section titled Set up shippingShipping has three parts:
- A shipping profile groups products that ship the same way. The store's default profile covers every product that names no other, and you cannot delete it. Add a
customorgift_cardprofile withPOST /commerce/shipping-profiles, up to 50. - A shipping zone is where you ship: a list of up to 300 places in
geo, each acountry_code, optionally narrowed byprovince_codeandpostal_prefix. A store has up to 200 zones. - A shipping option is what the shopper chooses, with its price, for one zone and one profile. A store has up to 500 options.
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/shipping-zones" \
-H "X-API-Key: $ORG_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Western Europe", "geo": [{"country_code": "DE"}, {"country_code": "FR"}, {"country_code": "NL"}]}'
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/shipping-options" \
-H "X-API-Key: $ORG_API_KEY" \
-H "Content-Type: application/json" \
-d '{"zone_id": 12, "name": "Standard", "currency_code": "EUR", "price_type": "free_over", "amount": 490, "free_over_amount": 5000, "delivery_min_days": 2, "delivery_max_days": 4}'
An option without profile_id belongs to the default profile. Its price_type is one of these, worked out from the items of its profile after discounts:
price_type |
Price |
|---|---|
flat |
amount. |
free_over |
amount, or nothing once the subtotal reaches free_over_amount. |
weight_tiers |
The first of tiers whose up_to, in grams, covers the weight. |
subtotal_tiers |
The first of tiers whose up_to, in minor units, covers the subtotal. |
tiers holds up to 50 {up_to, amount} entries in ascending order, and only the last may leave up_to as null. An option no tier covers is not offered. min_subtotal and max_subtotal limit the subtotals an option is offered for, and provider_id and provider_code tell a fulfilment provider which service to use. At checkout, the shopper picks one option for each profile among the items that ship, from a zone that matches the address and in the cart's currency.
Create publishable keys and sales channels
Section titled Create publishable keys and sales channelsYour storefront sends a publishable key to the Store API. It names the store and is safe to put in a browser.
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/publishable-keys" \
-H "X-API-Key: $ORG_API_KEY" \
-H "Content-Type: application/json" \
-d '{"label": "Web shop", "livemode": false}'
The answer is 201 with the key, whose token starts with pk_test_, or with pk_live_ when livemode is true. A live key works once the store is live. GET /commerce/publishable-keys shows every key with its token, and DELETE /commerce/publishable-keys/{key_id} revokes one; a revoked key can keep working for up to 15 seconds.
A sales channel is a place you sell, such as your website or a marketplace. A key sells the products of the channels in its sales_channel_ids, or of the default channel when it names none. A store has up to 50 channels. The default channel cannot be disabled or deleted, and PATCH /commerce/sales-channels/{channel_id} with is_default: true moves the default.
Add products
Section titled Add productsA product has options, such as size and colour, and variants, one for each combination you sell. Each variant has its own SKU, prices and stock.
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/products" \
-H "X-API-Key: $ORG_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Logo T-shirt",
"handle": "logo-t-shirt",
"status": "published",
"options": [{"title": "Size", "values": ["S", "M", "L"]}],
"variants": [
{"title": "S", "sku": "TSHIRT-S", "options": {"Size": "S"}, "weight_g": 180, "prices": [{"currency_code": "EUR", "amount": 2500}]},
{"title": "M", "sku": "TSHIRT-M", "options": {"Size": "M"}, "weight_g": 190, "prices": [{"currency_code": "EUR", "amount": 2500}]},
{"title": "L", "sku": "TSHIRT-L", "options": {"Size": "L"}, "weight_g": 200, "prices": [{"currency_code": "EUR", "amount": 2500}]}
],
"tags": ["clothing"]
}'
The answer is 201 with the whole product. Keep these rules in mind:
- A product is a
draftuntil you setstatustopublished, and the Store API shows published products only.archivedhides it. - It sells in the store's default channel unless you send
sales_channel_ids. - A handle or a SKU belongs to one product. A taken
handleanswers409withhandle_taken, and a taken SKU409withsku_taken. Without ahandle, we make one from the title. - A variant tracks stock unless you set
manage_inventorytofalse, and it cannot be ordered beyond its stock unlessallow_backorderistrue. A tracked variant with no stock set cannot be bought unless it allows backorders. - A price can be narrowed to a
region_id, or to a quantity withmin_quantityandmax_quantity, and can carry acompare_at_amountto show as the old price. - A product has up to 10 options, 250 values an option, 500 variants, 250 tags and 250 images.
PATCH /commerce/products/{product_id} changes the fields you send. When you send options, variants, sales_channel_ids, collection_ids, category_ids or tags, the list replaces the product's: name each variant you keep by its id or sku, or we delete it. The options, variants and images also have routes of their own under the product. DELETE /commerce/products/{product_id} archives the product: orders keep its variants, and its handle and SKUs are free for new products.
Images are links: POST /commerce/products/{product_id}/images takes a url, alt text, and optionally a variant_id, a rank and the image's width and height.
Group products
Section titled Group products- A collection is a set of products your storefront shows together. A
manualcollection holds the products you set withPUT /commerce/collections/{collection_id}/products. Asmartcollection holds every product that matches itsrules, as{"match": "all", "conditions": [{"field": "tag", "op": "equals", "value": "clothing"}]}, wherefieldistag,product_type,vendorortitle,opisequalsorcontains, andmatchisallorany. - A category sits in a tree through
parent_id. The Store API hides a category, and everything under it, while itsis_activeisfalseor itsis_internalistrue. - Tags are words on the product.
GET /commerce/product-tagslists the ones in use, most used first.
Set sale and customer prices
Section titled Set sale and customer pricesA price list changes what variants cost for a while or for some customers. POST /commerce/price-lists takes a title, a kind, a status of draft or active, optionally starts_at, ends_at and customer_group_ids, and prices that each name a variant_id.
- In a
salelist, the shopper pays the lowest of the variant's own price and every active sale list they qualify for. - An
overridelist they qualify for replaces all of those.
The Store API shows what the price would be without any list as original_amount, which your storefront can strike through. PUT /commerce/price-lists/{price_list_id}/prices replaces a list's prices, up to 5,000.
Import products from Shopify
Section titled Import products from ShopifyPOST /commerce/imports takes the CSV that Shopify's admin exports and queues it for import. Send the file as the multipart field file, as a text/csv body, or as JSON {"csv": "...", "filename": "..."}. Try it first with dry_run=true: a dry run goes through the same steps and saves nothing but its report.
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/imports?dry_run=true¤cy_code=EUR" \
-H "X-API-Key: $ORG_API_KEY" \
-F "file=@products_export.csv"
mode=upsert, the default, updates the products whose handle the store already has, andmode=create_onlyskips them.- Prices are read in
currency_code, which defaults to the store's default currency and must be one the store sells in. Variant Inventory Qtybecomes the stock atlocation_id, which defaults to the store's default location.- Shopify's
active,draftandarchivedbecomepublished,draftandarchived. A new product without a status is a draft. - A blank cell keeps the store's value. A blank
Variant Inventory Trackerturns stock tracking off for the variant, as in Shopify.
A file is at most 10 MB and 5,000 rows, and must be UTF-8. We read it before we queue it, and a file we cannot import at all, such as one without a Handle column, answers 422 with the reason. Otherwise the answer is 202 with the queued import, and we import the products in batches of 50. A store runs one import at a time: while one is queued or running, another answers 409 with import_in_progress and that import's import_id. The route's earlier path, POST /commerce/imports/shopify-products, works the same way and also answers 202 with the queued import.
Follow the import with GET /commerce/imports/{import_id}:
statusstarts asqueued, moves torunning, and ends ascompletedorfailed. A failed import says why inerror.progresscounts theproducts_processedout ofproducts_total.reportcounts what changed:products_created,products_updated,products_skipped,variants_created,variants_updated,images_added,levels_setandcategories_created. Itserrorslist up to 500 rows the import could not use, each with its spreadsheetrow, itshandleand amessage, anderror_countcounts them all. The rest of the file still imports.
When an import fails part way, the products in the batches it finished stay imported, so send the file again to import the rest. GET /commerce/imports lists the store's imports newest first, without their errors: 20 a page by default and up to 100, narrowed by status.
Set stock levels
Section titled Set stock levelsEach variant that tracks stock has an inventory item, with a level at each location. A level has a stocked_quantity (on the shelf), a reserved_quantity (held for orders that have not shipped), an incoming_quantity, and an available_quantity, which is stocked less reserved. Shipping takes units off both stocked and reserved, and cancelling an order gives its reserved units back.
Set one item's level at one location with stocked_quantity, or change it by a delta:
curl -X PUT "https://api.coritan.com/api/v1/orgs/acme/commerce/inventory-items/301/levels/7" \
-H "X-API-Key: $ORG_API_KEY" \
-H "Content-Type: application/json" \
-d '{"delta": -2}'
Set many at one location by SKU, up to 1,000 a request:
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/inventory/levels/batch" \
-H "X-API-Key: $ORG_API_KEY" \
-H "Content-Type: application/json" \
-d '{"location_id": 7, "levels": [{"sku": "TSHIRT-S", "stocked_quantity": 40}, {"sku": "TSHIRT-M", "stocked_quantity": 55}]}'
The answer lists the levels it set and any unknown_skus. A SKU sent twice takes its last count, and stock never goes below zero. GET /commerce/inventory-items?low_stock=5 finds the items with 5 or fewer available.
Offer promotions and gift cards
Section titled Offer promotions and gift cardsCreate a discount code
Section titled Create a discount codecurl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/promotions" \
-H "X-API-Key: $ORG_API_KEY" \
-H "Content-Type: application/json" \
-d '{"code": "WELCOME10", "status": "active", "value_type": "percentage", "percentage": 10, "target": "order", "conditions": {"first_order_only": true}, "per_customer_limit": 1}'
value_typeispercentage,fixed(anamountwith itscurrency_code) orfree_shipping, andtargetisorder,itemsorshipping.conditionscan ask for amin_subtotalin the promotion'scurrency_code, items fromproduct_idsorcollection_ids, a shopper in one ofcustomer_group_ids, orfirst_order_only.usage_limit,per_customer_limit,starts_atandends_atbound its use. A promotion that is notcombinableapplies only on its own.- Codes are unique in the store and match whatever the case. A new promotion is a
draftuntil you make itactive. is_automatic: truemakes a promotion without a code that applies to every cart it fits. Up to 50 can be active at once.
A promotion that an order used cannot be deleted: set its status to disabled instead.
Issue a gift card
Section titled Issue a gift cardcurl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/gift-cards" \
-H "X-API-Key: $ORG_API_KEY" \
-H "Content-Type: application/json" \
-d '{"currency_code": "EUR", "amount": 5000, "recipient_email": "alex@example.com", "message": "Thank you for your help."}'
The answer holds the card and its code. We show the code this once and do not email it, so send it to the recipient yourself. POST /commerce/gift-cards/{gift_card_id}/adjust adds or takes away an amount with a note, and a card's balance cannot go below zero. PATCH sets a card's status to active or disabled. A card is exhausted when its balance runs out and expired after its expires_at. The gift cards that shoppers buy in your store are emailed to their recipients.
Manage customers
Section titled Manage customersA customer here is one of your organization's customers who has ordered from the store or has a store profile. GET /commerce/customers searches them with q, and PATCH /commerce/customers/{customer_id} changes your note about them, their tags, tax_exempt, and their vat_id with vat_id_valid once you have checked it. You can set accepts_marketing to false only: shoppers opt in themselves.
A customer group collects customers for price lists and promotions. Create one with POST /commerce/customer-groups and set its members with PUT /commerce/customer-groups/{group_id}/customers, up to 1,000. Deleting a group keeps its customers.
Handle orders
Section titled Handle ordersA cart becomes an order when the shopper pays through the Store API. We email them a confirmation, and later a notice if you cancel or refund the order.
Find orders
Section titled Find ordersGET /commerce/orders lists orders newest first, with count for everything the filters match:
| Filter | Takes |
|---|---|
status |
pending, completed, canceled, archived or requires_action |
payment_status |
not_paid, awaiting, captured, partially_refunded, refunded, canceled or requires_action |
fulfillment_status |
not_fulfilled, partially_fulfilled, fulfilled, partially_shipped, shipped, partially_delivered, delivered, partially_returned, returned or canceled |
q |
An order number such as #1001, an order_ ID, or part of an email address |
customer_id, email, livemode, risk_hold |
Exact values |
placed_from, placed_to |
Dates; placed_to is not included |
limit, offset |
Up to 200 a page; 50 by default |
curl "https://api.coritan.com/api/v1/orgs/acme/commerce/orders?payment_status=captured&fulfillment_status=not_fulfilled&livemode=true" \
-H "X-API-Key: $ORG_API_KEY"
Reach an order by its id or its order_ public ID. Order numbers start at 1001. GET /commerce/orders/{order_id} answers the order with its items, totals, payments, refunds, fulfilments, documents, timeline and risk, the Coritan company that sold it, and what is left to refund under refundable. It also lists the order's returns, each with its kind, status and any exchange_order_id, and for an exchange order, exchange_for names the return and the order it replaces. GET /commerce/orders/{order_id}/documents lists the invoice and credit notes as the Coritan company issued them.
Change an order
Section titled Change an orderPATCH /commerce/orders/{order_id} corrects the email or the shipping_address, or changes the note (up to 2,000 characters) and metadata. The address keeps its country, and in the US and Canada its state or province, because the order was taxed for them. The address cannot change once part of the order has shipped, which answers 409 with already_shipped, or once a fulfilment has gone to its provider, which answers 409 with fulfillment_started: ask the provider to change it.
POST /commerce/orders/{order_id}/notes adds a note of up to 500 characters to the timeline, and customer_visible: true shows it on the shopper's order page.
Release a risk hold
Section titled Release a risk holdWe hold an order that shows two or more of these signals, or that the payment provider rates as high risk:
- It is the first order from its email and above
risk_high_value. - The billing and shipping countries differ.
- The shopper's IP address is in another country than the shipping address.
- The payment provider rates it as elevated risk.
A held order has risk_hold: true, and risk lists the reasons. Nothing on it can be fulfilled until you release it, a fulfilment provider receives nothing for it, and any gift cards it sold cannot be spent. When you have checked the order, release it:
curl -X PATCH "https://api.coritan.com/api/v1/orgs/acme/commerce/orders/order_01j8z3k4m5n6p7q8r9s0t1v2w3" \
-H "X-API-Key: $ORG_API_KEY" \
-H "Content-Type: application/json" \
-d '{"risk_hold": false}'
To turn the order down instead, cancel it.
Refund or cancel an order
Section titled Refund or cancel an orderPOST /commerce/orders/{order_id}/refunds refunds part or all of an order. Send an amount, which we spread over what is left, or items with each order_item_id and quantity, plus shipping: true to refund the shipping too. Add a reason of up to 64 characters and a note.
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/orders/order_01j8z3k4m5n6p7q8r9s0t1v2w3/refunds" \
-H "X-API-Key: $ORG_API_KEY" \
-H "Idempotency-Key: refund-1043-1" \
-H "Content-Type: application/json" \
-d '{"items": [{"order_item_id": 5521, "quantity": 1}], "reason": "damaged"}'
We refund the card or PayPal payment first and credit the rest to any gift cards the shopper paid with, and we issue a credit note. The answer is 201 with the refund and the order.
A payment that reaches an order after it was placed, such as a shopper paying twice, is refunded on its own. The order's payments mark it with unmatched: true. Send its id as payment_id, with an amount or with none to refund all that is left of it. Such a refund takes no items or shipping. The payment the order was paid with answers 422 there, and an amount above what is left of that payment answers 422 with refund_too_large.
A refund takes an optional Idempotency-Key header of up to 128 characters. A retry with the same key and body within 24 hours gets the first answer back and refunds nothing more. The same key with another body, or for another order, answers 409 with idempotency_key_reused, and a retry while the first request runs answers 409 with idempotency_in_progress. A refund that fails keeps no key, so you can send it again with the same one.
A refund moves the stock of the units it names in items, and only those. An amount, or shipping on its own, moves no stock.
- Without
restock, the refunded units come first from those that have not shipped. They will never ship: we release their stock and take them out of anypendingfulfilment we have not yet tried to send, and cancel one that is left empty. When a fulfilment that a provider already has still holds them, the order's timeline names it, so ask the provider to take them out. Any other refunded units have shipped, and their stock does not move. - Send
restock: truewhen shipped units have come back to you. The refunded units then come first from the shipped units not yet returned: they go back in stock at thelocation_idyou send, or where they shipped from, and count in the item'sreturned_quantity. Any others come from units that have not shipped, as withoutrestock.
restock needs items, and location_id needs restock: true and must be one of the store's stock locations. Otherwise the answer is 422.
Units that an open return holds are refunded through the return, as Refund a return describes. Asking for more of an item in items than open returns leave answers 409 with units_in_return: return_id names a return that holds units of the item, and refundable_quantity says how many you can refund on the order. Refunding an exchange order, which sends an exchange's new items or a claim's replacements, answers 409 with exchange_order.
POST /commerce/orders/{order_id}/cancel refunds whatever is left, gives the stock back and ends the order. Once any of it has shipped, refund it instead. Cancelling an exchange order refunds nothing, and what its new items took becomes refundable again on the order they came from.
Warning
A refund or a cancellation sends money back to the shopper at once, and neither can be undone.
Refunds and cancellations share a limit of 30 a minute for the organization.
Fulfil orders
Section titled Fulfil ordersA fulfilment is a set of an order's items that leave together from one stock location. Gift cards and items that need no shipping never go into one. You ship fulfilments yourself, or a fulfilment provider ships them, such as a 3PL warehouse that we send each fulfilment to.
When an order is placed, we create its fulfilments: one for each provider and location its items go to. An order on risk hold gets them when you release it. For each item, we choose the first of these that applies:
- The provider of the shipping option the shopper chose.
- The provider that runs the stock location holding the item.
- Your only enabled provider, when you have exactly one.
- You.
GET /commerce/orders/{order_id}/fulfillments lists an order's fulfilments with the requests we sent to each provider, and each item's unfulfilled_quantity.
| Status | What it means |
|---|---|
pending |
No provider has it yet. You ship it, or it waits for the release delay or for you to dispatch it. |
requested |
Its provider received it. |
accepted |
Its provider took it on. |
exception |
Something needs you. The fulfilment's exception field says what. |
shipped |
It left the stock location. |
delivered |
It arrived. |
canceled, rejected |
You cancelled it, or its provider refused it. Its items are free for a new fulfilment. |
Ship an order yourself
Section titled Ship an order yourselfA fulfilment that no webhook provider ships stays pending until you ship it:
Find its
idinGET /commerce/orders/{order_id}/fulfillments.Mark it shipped, with its tracking. To ship part of it, add
items: those units become a fulfilment of their own, and the answer'sremainderkeeps the rest under the old ID.Shellcurl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/orders/order_01j8z3k4m5n6p7q8r9s0t1v2w3/fulfillments/88/ship" \ -H "X-API-Key: $ORG_API_KEY" \ -H "Content-Type: application/json" \ -d '{"tracking": [{"carrier": "DHL", "number": "00340434161234567890", "url": "https://www.dhl.com/track?id=00340434161234567890"}]}'Mark it delivered with
POST .../fulfillments/{fulfillment_id}/deliver. A fulfilment you never marked shipped is marked shipped first, without tracking.
A shipment takes up to 20 tracking entries, each with a carrier and a number of up to 100 characters, and optionally a url. The Store API shows the shopper each shipment with its tracking.
Cancel or add a fulfilment
Section titled Cancel or add a fulfilmentPOST .../fulfillments/{fulfillment_id}/cancel cancels a fulfilment that has not shipped, with an optional reason, and frees its items. When its provider may already have it, we send the provider a fulfillment.cancel. Cancelling a fulfilment refunds nothing.
To ship freed items, add a fulfilment for them. Name each order_item_id and quantity, whose IDs are in the order's items, and optionally the provider_id or location_id to use:
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/orders/order_01j8z3k4m5n6p7q8r9s0t1v2w3/fulfillments" \
-H "X-API-Key: $ORG_API_KEY" \
-H "Content-Type: application/json" \
-d '{"items": [{"order_item_id": 5521, "quantity": 1}], "provider_id": 3}'
A fulfilment takes only units that no other fulfilment holds, and asking for more answers 409 with quantity_exceeds_unfulfilled. A webhook provider whose auto_dispatch is on receives the new fulfilment at once, without the release delay.
Connect a fulfilment provider
Section titled Connect a fulfilment providerA provider is manual or webhook. A manual provider is a team that ships by hand, such as your own warehouse: we send it nothing, and it reports what it ships with an organization API key. A webhook provider is an HTTPS endpoint at your 3PL. We send it each fulfilment to ship, and it sends back what happened.
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/fulfillment-providers" \
-H "X-API-Key: $ORG_API_KEY" \
-H "Content-Type: application/json" \
-d '{"kind": "webhook", "name": "Example 3PL", "endpoint_url": "https://3pl.example.com/coritan/orders", "config": {"release_delay_minutes": 60}}'
The answer holds secrets with the outbound_secret, which signs what we send, and the inbound_secret, which the 3PL signs its events with. We show them this once. POST .../rotate-secrets makes new ones, and the old ones stop working at once. POST .../test sends a signed ping and reports what the endpoint answered.
config key |
What it does |
|---|---|
auto_dispatch |
true by default: we send each fulfilment once the release delay has passed. false waits for you to dispatch it. |
release_delay_minutes |
How long after the order is placed we wait, from 0 to 10,080. It gives you time to change or cancel an order. |
service_code_map |
Maps a shipping option's ID or provider code to the 3PL's service code, up to 200 entries. |
field_map |
Renames the top-level keys of the body we send, for a 3PL that expects other names. |
include_prices |
true by default. false leaves prices and totals out of the body. |
returns |
false by default. true makes a webhook provider receive the goods that come back from returns of units it shipped. We send it return.requested and return.canceled, and it answers with return.received, as Have a 3PL receive the goods explains. |
The endpoint must use https on a public host. A provider that is disabled receives nothing, and what waited for it goes out when you enable it again. You cannot delete a provider while a fulfilment that has not shipped uses it. A store has up to 20 providers.
Check what we send to a provider
Section titled Check what we send to a providerEach request is a POST with a JSON body whose schema is coritan.fulfillment.v1 and whose type is fulfillment.create, fulfillment.cancel or ping, or for a provider that receives returns, return.requested or return.canceled. A fulfillment.create holds the order with its shipping address, shipping method and seller, the fulfillment with its location, and the items. A return.requested holds the order, the return with the location its goods go to, and the items, as Have a 3PL receive the goods describes. Every request carries these headers:
| Header | Holds |
|---|---|
X-Coritan-Signature |
t=<unix seconds>,v1=<hex HMAC-SHA256> |
X-Coritan-Event |
The type |
X-Coritan-Delivery |
The request's ID |
Idempotency-Key |
A key that stays the same when we retry the request |
To check a request, compute the HMAC-SHA256 of <t>.<raw body> with the outbound secret, compare it with v1, and refuse a t that is too old:
import hashlib
import hmac
import time
def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
parts = dict(item.split("=", 1) for item in header.split(","))
timestamp = int(parts["t"])
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
return abs(time.time() - timestamp) <= tolerance and hmac.compare_digest(expected, parts["v1"])
Answer with a 2xx status to accept the request. A JSON answer with a reference saves it as the fulfilment's provider_reference, or the return's for a return.requested. We retry after 408, 429, a 5xx, a timeout of 10 seconds or a failed connection, 1, 5, 30, 120 and 480 minutes later, and then give up. Any other answer fails at once, and we do not follow redirects. A fulfillment.create that fails puts the fulfilment in exception; fix the cause and send it again with POST .../fulfillments/{fulfillment_id}/dispatch. When a return.requested fails, we note it in the order's timeline, and you receive the goods yourself when they arrive. After five failures in a row we send the commerce.fulfillment.provider_failing webhook.
GET /commerce/fulfillment-providers/{provider_id}/deliveries lists what we sent and how the endpoint answered, and GET .../events lists what the provider sent us. Filter the deliveries by kind: create, cancel, ping, return_create or return_cancel.
Send events from the provider
Section titled Send events from the providerThe 3PL reports back with POST /api/v1/orgs/{org_slug}/commerce/fulfillment-providers/{provider_id}/events. It signs the raw body with the inbound secret in X-Coritan-Signature, in the same form as above and within 5 minutes, or sends an organization API key with commerce.fulfillment:write in X-API-Key.
{
"id": "evt-3pl-10492",
"type": "fulfillment.shipped",
"data": {
"fulfillment_id": 88,
"tracking": [{"carrier": "DHL", "number": "00340434161234567890"}]
}
}
id is the 3PL's own event ID, up to 128 characters. An event already processed answers 200 with status: duplicate and changes nothing, so the 3PL can send it again safely. One that failed can be sent again under the same ID.
type |
data |
What it does |
|---|---|---|
fulfillment.accepted |
fulfillment_id or order_id, and reference |
The 3PL took the fulfilment. |
fulfillment.rejected |
fulfillment_id, and reason |
The 3PL refused it, and its items are free again. |
fulfillment.shipped |
fulfillment_id or order_id, tracking, and items by sku or order_item_id with quantity |
Marks it shipped. Without items, everything in it shipped. |
fulfillment.delivered |
fulfillment_id or order_id |
Marks it delivered. |
fulfillment.exception |
fulfillment_id and message |
Marks a problem with it. |
inventory.levels |
levels, each with sku, stocked_quantity and incoming_quantity, and location_id |
Sets stock at a location the provider runs, up to 1,000 SKUs. |
return.received |
return_id, location_id, and items by sku or order_item_id with quantity and condition |
Receives a return we sent the provider. sellable units, the default, go back in stock at location_id, which must be a location the provider runs, or else where the return.requested said. damaged units do not. Without items, every unit comes back sellable. It refunds nothing. |
order_id is the order's order_ public ID. The answer is 200 with status processed, ignored, failed or duplicate. A body that is not an event answers 422, and one over 256 KB answers 413.
Receive commerce webhooks
Section titled Receive commerce webhooksThe store's events reach your organization webhooks with names that start with commerce.. Subscribe a webhook to the exact names, or to * for everything. The delivery, retries and signature are the ones Receive organization webhooks describes.
{
"id": "evt_48213",
"type": "commerce.order.placed",
"livemode": true,
"created_at": "2026-09-26T14:03:11Z",
"resource": {"type": "order", "id": "1742"},
"data": {"order_id": 1742, "public_id": "order_01j8z3k4m5n6p7q8r9s0t1v2w3", "display_id": 1043, "livemode": true, "risk_hold": false}
}
An event can arrive more than once, so skip an id you have handled.
| Event | When | data |
|---|---|---|
commerce.product.created, .updated, .deleted |
A product changes. | product_id, handle |
commerce.inventory.updated |
Stock changes through the API, an import, a provider, a refund or a return. | location_id, inventory_item_ids, skus |
commerce.order.placed |
A shopper completes a cart. | order_id, public_id, display_id, livemode, risk_hold |
commerce.order.updated |
An order's email, address, note or risk hold changes. | The same, plus changes |
commerce.order.canceled |
An order is cancelled. | The same, plus reason, refund_id, refunded_amount, gift_card_amount, currency_code |
commerce.payment.captured |
A payment is taken. | order_id, payment_id, amount, currency_code, provider, livemode |
commerce.payment.refunded |
Money goes back to the shopper. | order_id, public_id, refund_id, amount, gift_card_amount, total, currency_code, reason, cancellation, livemode |
commerce.customer.created |
A guest order creates a customer. | customer_id, email, is_guest |
commerce.customer.updated |
A customer's store profile or groups change. | customer_id, changed |
commerce.gift_card.issued |
A gift card is sold or issued. The code is never in the event. | id, last4, currency_code, amount, recipient_email, issuing_order_id, expires_at |
commerce.fulfillment.created |
We or you create a fulfilment. | order_id, fulfillment_id, provider_id, items |
commerce.fulfillment.accepted |
The provider takes a fulfilment on. | order_id, fulfillment_id, reference |
commerce.fulfillment.rejected, .canceled |
The provider refuses a fulfilment, or it is cancelled. | order_id, fulfillment_id, reason |
commerce.fulfillment.shipped |
A fulfilment ships. | order_id, fulfillment_id, tracking, items |
commerce.fulfillment.delivered |
A fulfilment arrives. | order_id, fulfillment_id |
commerce.fulfillment.exception |
A fulfilment needs you, such as when its provider's endpoint refused it. | order_id, fulfillment_id, message |
commerce.fulfillment.provider_failing |
A provider's endpoint fails five times in a row. We send it again only after a request succeeds. | provider_id, name, consecutive_failures, last_error |
commerce.dispute.opened, .won, .lost |
A shopper's dispute opens, or ends in your favour or theirs. | order_id, dispute_id, amount, currency_code |
commerce.payout.created, .paid, .failed, .canceled |
A payout is created, completed, declined or cancelled. | id, currency_code, amount, platform_fee, net_amount, status, payout_method, entry_count |
commerce.return.requested, .approved, .rejected, .received, .refunded, .completed, .canceled |
A return, exchange or claim is asked for or opened, approved, declined, received, refunded, settled with nothing to refund, or cancelled. | return_id, order_id, public_id, kind, claim_type, status, currency_code, livemode, and the fields each one adds |
commerce.exchange.created |
We create an exchange order to send an exchange's new items or a claim's replacements. | return_id, kind, order_id, public_id, exchange_order_id, exchange_public_id, display_id, items, livemode |
GET /commerce/events shows the same events with whether we delivered each one, filtered by event_type (without the commerce. prefix) and status (pending, dispatched or failed).
Read the balance and the ledger
Section titled Read the balance and the ledgerGET /commerce/balance answers, for each currency your live orders were paid in:
| Field | What it is |
|---|---|
pending |
Money from sales that is not yours to take yet. |
available |
Money that is yours. |
reserved |
Money held back as a reserve until its release date. |
payable |
available less reserved, and never below zero. |
A sale becomes available after the payout delay in your merchant profile's terms, 7 days unless we set another, or when it is delivered, if that comes first.
GET /commerce/ledger lists every movement, newest first, filtered by currency_code, status, entry_type, order_id, created_from and created_to. Each order books its sale and shipping, the tax that the Coritan company owes, and our commission, which is the fee percentage in your terms plus any fixed fee. A refund books the reverse and returns the commission on what it refunds. A dispute books chargeback rows, as Answer disputes on store orders describes. Test orders book to a separate ledger that you read with livemode=false, and never count towards the balance.
We pay the payable amount out on your store's schedule. A payout marks the rows it pays paid_out and books a payout row against them, as Get store payouts and tax reports describes.
Retry requests safely
Section titled Retry requests safelyMost Commerce API routes read no Idempotency-Key, so a request that timed out may have succeeded. Before you send it again, check:
- Create a product with an explicit
handle. If the first request succeeded, the retry answers409withhandle_takenand creates nothing. - Read the order before you retry a refund sent without an
Idempotency-Key. A refund never exceeds what is left, and while an earlier one waits for the payment provider, a new one answers409withrefund_in_progress. - Set stock with
stocked_quantity: sending the same count twice gives the same result, where a repeateddeltacounts twice. - A provider's events carry their own
id, so a repeated event changes nothing.
Refunding an order and every write on a return take an Idempotency-Key, as Refund or cancel an order and Handle returns, exchanges and claims explain.
Sending a dispute's evidence, accepting a dispute and asking for a payout take an Idempotency-Key, as Answer disputes on store orders and Ask for a payout explain.
In the Store API, completing a cart takes an Idempotency-Key, as Complete the order explains.
Errors
Section titled ErrorsAn error answers with a status and a detail object that holds a machine-readable error code and a message to show a person. Some errors add fields, such as field for the input that is wrong.
{"detail": {"error": "invalid", "message": "currency_code must be a currency code such as EUR.", "field": "currency_code"}}
| Status | error |
Meaning |
|---|---|---|
403 |
scope_required, role_required, people_only, forbidden |
The credential cannot do this. |
404 |
not_found, commerce_not_enabled |
The thing, or the store, does not exist for this organization. |
409 |
conflict or a specific code, such as handle_taken |
The request clashes with the store's state. |
422 |
invalid or a specific code |
The input is wrong; field names it. |
429 |
rate_limited |
Too many refunds and cancellations, dispute answers or payout requests. Retry-After says when to try again. |
502 |
refund_failed, payment_provider_error |
The payment provider refused a refund, or a dispute's evidence or acceptance. |
Errors from the organization API key itself, such as a key that is missing, revoked or used from the wrong address, are on Create organization API keys.
Result
Section titled ResultYour storefront can list the published products through the Store API with a test publishable key, and a test order placed there appears in GET /commerce/orders?livemode=false. Once we approve the merchant profile and switch the store to live, live keys take real orders, and each one reaches your ledger.
Troubleshooting
Section titled Troubleshooting404withcommerce_not_enabled- Commerce is not turned on for the organization, or Coritan disabled the store. Ask support.
403withscope_required- The key lacks the scope in the answer's
scope. Keys cannot change, so create one with the scopes it needs and revoke the old one. 403withrole_required- The member's role is below the lowest role for the route, as the answer's
permissionshows. Scopes and roles lists them. 403withpeople_only- The merchant profile routes and payout requests refuse every key. Call them with an owner's or admin's token.
409withmarket_not_enabled- A country in the region is one where Coritan does not sell yet. Take it out of the region.
409withcountry_in_other_region- The country is already in another region. Take it out of that region first.
409withdefault_regionordefault_channel- You tried to disable or delete the default region or sales channel. Make another one the default first.
409withhandle_takenorsku_taken- Another product has that handle or SKU. Choose another, or change the product that has it.
409withimport_in_progress- The store runs one import at a time, and another is queued or running. Follow it with
GET /commerce/imports/{import_id}, using theimport_idin the answer, and send the file again once it has finished. 409withorder_on_hold- The order is on risk hold. Release the hold before you fulfil it.
409withalready_shipped- Part of the order has shipped, so it cannot be cancelled or have its address changed. Refund the items instead.
409withfulfillment_started- A fulfilment provider already has the order with its address. Ask the provider to change the address.
409withquantity_exceeds_unfulfilled- You named more units than there are: other fulfilments hold them, or the fulfilment you are shipping part of holds fewer. The answer's
unfulfilledsays how many there are. To move units between fulfilments, cancel one first. 409withrefund_in_progress- An earlier refund is still waiting for the payment provider. Read the order again in a few minutes.
409withgift_card_used- The order sold a gift card that has been spent, so the order cannot be refunded in full. Refund the other items.
409withpromotion_in_use- An order used the promotion. Set its
statustodisabledinstead of deleting it. 409withlimit_reached- The store has as many of these as it can hold. Delete one you no longer need.
422listingmissing- The merchant profile lacks what review needs. Fill in the fields in
missingand submit it again. 429withrate_limited- The organization made more than 30 refunds and cancellations in a minute, or went over the limit for dispute answers or payout requests. Wait for the time in
Retry-After. - A fulfilment stays
requestedor goes toexception - Read
GET /commerce/fulfillment-providers/{provider_id}/deliveriesto see how the endpoint answered, fix the endpoint, then dispatch the fulfilment again.