Integrations
Two pieces: we tell your system when something happens (webhooks), and your system calls ours to read or change things (API keys). Set both up in your store’s Integrations tab.
Order webhooks
When an order is placed, paid, fulfilled or refunded we POST a small JSON message to your URL. New event types may be added, so ignore any you do not recognise.
A store can send to up to 5 endpoints — say your ERP, a fulfilment partner and an analytics tool. Every endpoint receives every event, signed with its own secret, and each can be paused, re-addressed or removed without touching the others. See Several endpoints.
| Event | When it fires |
|---|---|
order.created | Checkout completed. For card and BNPL this is before money is confirmed. |
order.paid | Payment confirmed. |
order.fulfilled | The merchant marked the order shipped. |
order.cancelled | The order was cancelled before it was paid or shipped:
the merchant cancelled it, or its card or BNPL payment did not happen (declined, expired or
abandoned). Its stock and any discount use were given back in the same moment. It is sent once
per order. It can arrive for an order you were never sent order.created for: a
checkout whose payment page could not be opened is cancelled straight away, so treat an unknown
order id here as "nothing to close". |
order.refunded | A refund was recorded on the order: each partial refund too, so one order can send several. |
order.refund_reversed | A recorded card or BNPL refund failed at the payment provider afterwards and was taken back out of the order: the customer was not paid. |
The message is a pointer, not the order
The body carries identifiers and a timestamp — never customer details:
{
"event": "order.paid",
"storeId": "7c1f…",
"orderId": "b4a9…",
"orderNumber": 1042,
"occurredAtUtc": "2026-08-17T09:14:22.118Z"
}
Treat it as a nudge: fetch the order and act on whatever you find. That one habit makes your integration immune to the two things that break naive receivers — a message arriving twice, and messages arriving out of order (a fast payment can confirm before the “created” message lands). Converging on current state is always correct; replaying a stale payload is not.
What the order you fetch tells you
Reading an order (GET /api/v1/stores/{storeId}/orders/{id}, or the list) returns more
than the totals. These fields exist so a stock or accounting system can reconcile without guessing:
| Field | Meaning |
|---|---|
paymentMethod | How the customer chose to pay, one of
cod (cash on delivery), stripe (card), mada, applepay,
tabby or tamara. Nothing else is ever stored, so an integration that books money
by method should refuse a code it does not know instead of guessing. |
lines[].variantId | The variant that was sold. null once the
merchant deleted that variant: the line itself stays. |
lines[].sku | The SKU as sold, copied at checkout. Editing the variant’s SKU later, or deleting the variant, does not change it. Orders placed before this field existed were filled in from the variant’s SKU at that time, and stay empty if the variant had none or was already gone. |
lines[].restockedQuantity | How many units of that line have come back in all, from every applied refund and from a cancellation (units never sent). Never more than the line’s quantity. |
refunds[].returned | The units that came back with that refund, as
[{"lineId": "…", "quantity": 1}] (lineId is lines[].id). Empty when
the refund was money only. A refund that restocked “everything” lists every returned unit, so there is no
separate restock flag. Units are back in stock only when the refund’s status is
2 (applied) or 6 (reversed: the money went back to the shop, the goods stay
returned), and then the list is exactly what was restocked: a refund recorded late, after another refund
had already brought those units back, lists fewer than it named, or none. For any other status
(0 pending, 1 sent, 3 failed, 4 unknown,
5 orphaned) it is only what the refund named, and nothing has come back yet.
Refunds recorded before 24 September 2026 list nothing here even when applied: the
list did not exist yet. For those orders, read the units that came back from
lines[].restockedQuantity. |
paidAfterCancelAtUtc | When a payment arrived for an order that was already
cancelled (money taken, nothing to ship), otherwise null. The order stays cancelled: this is the
flag that says it needed a refund. It stays set after the refund is made, so compare
refundedTotal with grandTotal to see whether that refund has happened. |
updatedAtUtc | When the order or any of its refunds last changed, to the microsecond. It is the cursor of the pull below. |
externalRef, externalStatus, externalMessage | What
your system wrote back with PUT …/external, or
null. |
Finding every order that changed
Webhooks tell you quickly; a pull makes sure you missed nothing. Ask for the orders that changed after a time:
curl "https://commerce.dev.colorsx74.com/api/v1/stores/{storeId}/orders?updatedAfterUtc=2026-10-10T07:50:38.123456Z&take=100" \
-H "X-Api-Key: cx74_…"
The answer lists every order whose own record or any of its refunds changed strictly
after that time, oldest change first, at most take (up to 100) at a time.
Each order carries updatedAtUtc: the later of the order’s own last change and its newest
refund’s, in UTC, to the microsecond. The list is ordered by updatedAtUtc, then by
id. Each page also carries asOfUtc: the store’s clock when that page was read.
- The next page: pass the last order’s
updatedAtUtc, exactly as you received it, asupdatedAfterUtc, and itsidasafterId. Orders that changed in the same microsecond are then taken by id, so none is skipped or repeated at the edge of a page. Stop at an empty page.totalcounts what is left after the cursor. - Echo the cursor exactly as received. It has six decimal places; never pass it through
a millisecond date type on the way back. In JavaScript, keep
updatedAtUtcandasOfUtcas strings: aDatekeeps only milliseconds. - The next pull: remember the
asOfUtcof the first page of each pull. When the pull reaches its empty page, the next pull starts from thatasOfUtcminus an overlap. A change is stamped with the time its database transaction started and becomes visible only when that transaction commits, so a change can appear behind keys you have already read; starting from the first page’s clock, not from the largest key you received, means that however long your pull pages, such a change comes in the next one. Make the overlap at least as long as the store’s longest transaction; 5 minutes is ample (no order or refund transaction waits on a payment provider). Use the store’s times, never your own clock. If a pull stops halfway, start again from where it started. You will read some orders twice: converge on the order as it is now, as with webhooks. - An order that changes while you page is read again after everything that changed before it: later in
the same pull, or in the next one. An order can also come back with nothing you care about changed:
every change counts, including the shop re-checking a refund with the payment provider, and your own
PUT …/externalbelow. updatedAfterUtccannot be combined withbeforeNumberorskip(400 updated_after_with_number_paging), andafterIdmeans nothing without it (400 after_id_needs_updated_after); a time that cannot be read is400 invalid_updated_after.statusandsearchstill filter. A time with no zone is read as UTC, digits finer than a microsecond are cut (never rounded), and the+of an offset must be URL-encoded (%2B), or useZ.
Reading needs orders.read (or orders.write).
Writing your reference back
When your system has booked an order, tell the store its own document number, status and a note for the merchant:
curl -X PUT https://commerce.dev.colorsx74.com/api/v1/stores/{storeId}/orders/{id}/external \
-H "X-Api-Key: cx74_…" -H "Content-Type: application/json" \
-d '{"externalRef": "INV-2026-0042", "externalStatus": "booked", "externalMessage": ""}'
| Field | Up to | Who sees it |
|---|---|---|
externalRef | 100 characters | The merchant on the order, and the shopper on their order confirmation page, as the invoice number. Put nothing private in it. |
externalStatus | 40 characters | The merchant only |
externalMessage | 300 characters | The merchant only |
It needs orders.write; a read-only key gets 403. The three fields are
replaced together, so send all three every time; a blank or missing field clears it. Text with control
or text-direction characters is refused (400 invalid_external_text), as is a field over its
length. The answer is the order as it now stands. No webhook is sent: you already know.
Only PUT on change. A change moves the order’s updatedAtUtc, so your
next pull returns it once more; a PUT identical to what is stored writes nothing and moves nothing,
which is what stops a sync from chasing its own writes.
Verifying the signature
Every request carries these headers:
| Header | Value |
|---|---|
X-Webhook-Signature | sha256= + HMAC-SHA256 of the raw
body, keyed with your signing secret, hex-encoded |
X-Webhook-Event | the event name |
X-Webhook-Delivery | a stable id for this delivery — reuse it to make your handler idempotent. Each endpoint gets its own id for the same event |
Verify against the exact bytes you received, before parsing. Re-serialising the JSON first can change its spacing, and a single changed byte means the signature will not match.
# Python
import hmac, hashlib
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers["X-Webhook-Signature"]):
return 401
// C#
var expected = "sha256=" + Convert.ToHexString(
HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), rawBody)).ToLowerInvariant();
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(received)))
return Unauthorized();
Delivery, retries and failures
- Answer 2xx to acknowledge. Anything else counts as a failure.
- Answer within 20 seconds. We wait that long for your reply; a slower answer
counts as a failure and the event is sent again. So acknowledge first and do the work
afterwards, and use
X-Webhook-Deliveryto recognise a repeat. - We retry with a widening gap — about a minute, then two, four, and so on, up to 6 hours between attempts — for 72 hours from the first attempt (about 20 attempts), then stop and mark the delivery dead-lettered. Time an event spent held before its first attempt, by a pause for example, does not count. A receiver that is down for a weekend catches up when it returns. A delivery that was given up on is not lost: you can send it again.
- Do not redirect. We never follow redirects, deliberately: a redirect would drop the body and the signature. Publish the final URL.
- Pause in the Integrations tab holds the queue rather than discarding it, so a
maintenance window doesn’t lose events: they are still recorded, and Resume
starts sending what was held straight away, oldest first — a large backlog takes a few
minutes to catch up, a handful at a time. Pausing keeps your signing secret and costs no
delivery attempts. One limit worth planning around: a held event waits up to 7
days, and anything still waiting after that is dropped — a weekend is free; a
fortnight is not. From your own system, the same switch is
POST /api/v1/stores/{id}/webhooks/{endpointId}/pauseand…/resume. - Remove is not a pause. It discards every event still waiting for that endpoint, and it receives nothing more; your other endpoints are not affected. The tab tells you how many will go before it asks.
- Each endpoint keeps its own queue, retries and pause. An endpoint that is down — or has a backlog to catch up on — can take only a bounded share of each sending round, so its backlog cannot crowd your other endpoints' events out. Each attempt at a slow endpoint still takes up to 20 seconds of that round, so events for your other endpoints can arrive a little later while one is timing out.
- Recent attempts are listed in the Integrations tab with their status, under each endpoint — a broken integration is visible, not silent.
Saving an endpoint's address always issues a new signing secret — even when the address is unchanged, so a previous receiver cannot keep validating your events. That makes a save the wrong tool for a maintenance window: a receiver still holding the old secret would reject what was held. Saving the address leaves a pause where it was. Renaming an endpoint changes only its name, never its address or secret.
Several endpoints
Endpoints are managed per store, each by its id. The secret is returned only when it is issued: when the endpoint is added, and when its address is saved.
| Call | What it does |
|---|---|
GET …/webhooks | The store's endpoints, oldest first, each with
id, name, url, isActive and
waiting (events held or not yet sent) |
POST …/webhooks | Add one: {"name": "ERP", "url": "https://…"}.
name is required. Answers 201 with the endpoint and its
secret. A sixth answers 409 webhook_endpoint_limit, and
an address the store already sends to 409 duplicate_endpoint_url |
GET …/webhooks/{endpointId} | One endpoint |
PUT …/webhooks/{endpointId} | A new address and always a new
secret: {"url": "https://…"}, with name optional. Never
pauses or resumes. Another endpoint's address answers 409
duplicate_endpoint_url; its own address is fine, and is how a leaked secret is
replaced |
PATCH …/webhooks/{endpointId} | Rename: {"name": "…"}.
The address and secret stay as they are. A body without name is
400; "" clears the name |
POST …/webhooks/{endpointId}/pause | Hold this endpoint's events |
POST …/webhooks/{endpointId}/resume | Send what was held, oldest first |
GET …/webhooks/{endpointId}/deliveries | This endpoint's recent deliveries, newest first |
POST …/webhooks/{endpointId}/deliveries/{deliveryId}/retry | Send one dead-lettered delivery again, under the same id (details) |
POST …/webhooks/{endpointId}/deliveries/retry-all | Send this endpoint's dead-lettered deliveries again, at most 500 per call |
DELETE …/webhooks/{endpointId} | Remove this endpoint and discard what it was waiting to send |
… is /api/v1/stores/{id}. An endpoint id belongs to its store: asked
for under any other store, it is 404. Two endpoints of one store cannot share an
address: it is compared with surrounding spaces removed and the scheme and host in any case,
while the path and query must match exactly. The refusals a caller can match on, each a
409 whose title is the code:
| Code | Meaning |
|---|---|
webhook_endpoint_limit | The store already has 5 endpoints. Remove one first |
duplicate_endpoint_url | Another endpoint of this store already has that address |
multiple_endpoints | A singular-route write on a store with more than one endpoint. Address the one you mean by id |
endpoint_managed_by_collection | A singular-route write on a store
whose only endpoint was added under …/webhooks with a name. Address it by id |
Sending a given-up delivery again
A delivery that failed for the whole 72 hours is marked dead-lettered and stays in the list. Once your receiver is back you can put it on the queue again — in the Integrations tab (Retry on the row, or Retry all for the endpoint), or from your own system:
POST …/webhooks/{endpointId}/deliveries/{deliveryId}/retrysends that one. It answers the delivery withmanualRetriesincreased by one. It is the same delivery with the same id, soX-Webhook-Deliverystill identifies a repeat. It starts a full new 72-hour ladder from its next attempt, however old the event is, and is sent straight away.POST …/webhooks/{endpointId}/deliveries/retry-alldoes the same for every dead-lettered delivery of that endpoint, oldest first, at most 500 per call. It answers{"retried": n, "remaining": m, "limit": 500}; whileremainingis above zero, call it again. The retried deliveries are sent a handful at a time, like any backlog, so a large replay never delays your other endpoints' new events.- Only a dead-lettered delivery can be retried. One that is still waiting or was delivered
answers
409delivery_not_dead_lettered; a delivery that is not this endpoint's is404. Both need a key with webhooks write access. - The older one-endpoint route
POST …/webhook/deliveries/{deliveryId}/retrydoes the same for a store's single, unnamed endpoint, and refuses like the other older write routes (multiple_endpoints,endpoint_managed_by_collection). - The list shows
deadLetteredon each endpoint andmanualRetrieson each delivery.
The older one-endpoint routes under …/webhook (singular) still
work while the store has no endpoint or exactly one, as they always have:
PUT …/webhook adds or re-addresses it (and sets enabled, which
defaults to on), and …/webhook/pause, …/resume,
…/deliveries and DELETE act on it. Once a store has more than one,
they cannot know which endpoint you mean, so PUT, DELETE,
pause and resume answer 409
multiple_endpoints and change nothing — they never guess, because the guess could
re-address another system's endpoint. GET …/webhook then reports the oldest, and
…/webhook/deliveries lists every endpoint's rows, each with its
endpointId.
The singular routes also leave alone an endpoint added under …/webhooks, even when
it is the store's only one. An endpoint with a name belongs to the system that added it, so a
singular PUT, DELETE, pause or resume
answers 409 endpoint_managed_by_collection and changes nothing —
otherwise the next tool to call the singular PUT would re-address your ERP's
endpoint and replace its secret. A singular PUT makes and keeps its endpoint
without a name; GET …/webhook still reads either kind.
Catalogue: changing a few fields, finding a variant by SKU
An integration that owns a product’s price or SKU should not have to resend, and so risk overwriting, the
options, compare-at price and stock it does not own. Two things make that safe. Both need the
catalog scope: catalog.write to change, catalog.read to look up.
Changing some fields of a variant
curl -X PATCH https://commerce.dev.colorsx74.com/api/v1/stores/{storeId}/products/{productId}/variants/{variantId} \
-H "X-Api-Key: cx74_…" -H "Content-Type: application/json" \
-d '{"price": 42.5, "externalRef": "ERP-ITEM-0042"}'
| Field | Meaning |
|---|---|
price | The new price, from 0 to 999,999,999 with at most three decimal places. Omitted or null keeps the stored price. |
compareAtPrice | The new compare-at (“was”) price, with the same bounds. Omitted or null keeps it. |
clearCompareAtPrice | true removes the compare-at price. A null cannot do
it, because null means “leave this alone”; sending a value and this flag together is
400 compare_at_conflict. |
sku | Up to 100 characters after trimming. Omitted or null keeps it; an empty string clears it. |
externalRef | Your own key for this variant, up to 100 characters after trimming. Omitted or null keeps it; an empty string clears it. |
Anything else in the body, stock included, is ignored: a PATCH never changes
stockQuantity, trackInventory or the options. Stock is deliberately not
here: it has its own route, Setting stock from another system, with its own
rule for which write wins. Text with control or text-direction characters, or over its
length, is refused (400 invalid_external_text or a validation 400); the length is
counted after the surrounding spaces are trimmed, since that is what is stored. A SKU another
variant of the store already uses is accepted, as it is for a full update, which is why the lookup below
returns a list. The answer is the variant as it now stands, with externalRef.
Prices and SKUs on every write
The same rules hold on every route that writes a price or a SKU: creating a product, adding a variant, the full variant update and this PATCH.
- A price or compare-at price below 0 or above 999,999,999 is a validation
400, a fraction outside the bounds included:-0.5and-0.001are refused, not rounded to 0. - More than three decimal places is
400 too_many_decimals. Prices are stored with three, so the answer you get back is exactly what was stored. Trailing zeros do not count:1.5000is1.5. - A SKU with control or text-direction characters is
400 invalid_external_text, the same answer as the PATCH above. A full update that resends a variant’s stored SKU unchanged is not refused for it, so a variant saved before this rule stays editable. - Shipping rate amounts and discount values are compared the same way, from 0 to 999,999: a negative
fraction is a validation
400there too.
Saving a variant with PUT
curl -X PUT https://commerce.dev.colorsx74.com/api/v1/stores/{storeId}/products/{productId}/variants/{variantId} \
-H "X-Api-Key: cx74_…" -H "Content-Type: application/json" \
-d '{"price": 42.5}'
The full update changes the fields you send and keeps the ones you leave out. It used to replace
the whole variant, so a client that sent back the values it had read wrote them over whatever had changed since:
a checkout or a stock push that moved stockQuantity, a PATCH that moved the price or the SKU. The
dashboard now sends only the fields the merchant changed. Sending the whole body still works as before, with one
exception: clearing needs an explicit null. A serializer that leaves null properties
out of the body (System.Text.Json with WhenWritingNull, Newtonsoft with
NullValueHandling.Ignore, code that drops None before sending) used to clear
compareAtPrice or sku by leaving it out; now that keeps the stored value, so such a client
can no longer remove a sale price. Send "compareAtPrice": null to remove it.
| Field | When it is left out | When it is sent |
|---|---|---|
price | Kept | Set. null keeps it |
compareAtPrice | Kept | Set; null removes it |
sku | Kept | Set; null or an empty string removes it |
options | Kept | Replaced by the object; null empties them |
trackInventory | Kept | Set. null keeps it. On a store whose stock
another system sets, a value other than the stored one is 409 stock_managed_externally |
stockQuantity | Kept | Set, 0 or more. null keeps it. On a store whose
stock another system sets, a figure other than the stored one is 409 stock_managed_externally
(below) |
Before this change a left-out price was stored as 0, a left-out stockQuantity as 0 and a
left-out trackInventory as true; none of those was ever the intent. The rules under
Prices and SKUs on every write apply to every field you do send.
Saving a product with PUT
curl -X PUT https://commerce.dev.colorsx74.com/api/v1/stores/{storeId}/products/{productId} \
-H "X-Api-Key: cx74_…" -H "Content-Type: application/json" \
-d '{"status": 1}'
The product’s full update follows the same rule: it changes the fields you send and keeps the ones you
leave out, or send as null. The Arabic name and the descriptions always did. The English
name and the status now do too: the name used to be required and a left-out status was stored as Draft, so every
client had to send both, and one that sent the values it had read could write an older name or status back over a
newer one. The dashboard now sends only the status from Activate and Archive, and only the texts the merchant
changed from the edit panel. Sending the whole body still works exactly as before.
| Field | When it is left out | When it is sent |
|---|---|---|
nameEn | Kept | Set, trimmed. null keeps it. An empty or blank name
is a validation 400, as when creating |
nameAr, descriptionEn, descriptionAr | Kept | Set;
null keeps it, an empty string clears it |
status | Kept | Set: 0 Draft, 1 Active,
2 Archived. null keeps it |
Before this change a body without status put the product back to Draft. A client that relied on
that has to send "status": 0 now.
Your key on a product
curl -X PATCH https://commerce.dev.colorsx74.com/api/v1/stores/{storeId}/products/{productId} \
-H "X-Api-Key: cx74_…" -H "Content-Type: application/json" \
-d '{"externalRef": "ERP-GROUP-17"}'
Sets only externalRef (null keeps, empty clears). Names, descriptions and status keep their own
full update. Products and variants both return externalRef, null until it is set.
Finding a variant by SKU
curl "https://commerce.dev.colorsx74.com/api/v1/stores/{storeId}/variants?sku=LAMP-100" \
-H "X-Api-Key: cx74_…"
{ "items": [ { "productId": "…", "productNameEn": "Lamp", "productNameAr": "…",
"variant": { "id": "…", "sku": "LAMP-100", "price": 42.5, "externalRef": null, … } } ],
"truncated": false }
The match is exact: the SKU is trimmed, then compared character for character, so case and accents count and
there are no prefixes or wildcards. It only ever looks inside the store in the URL, and it includes draft and
archived products. SKUs are not unique, so you get every match; more than one item is a clash
for you to resolve, not a reason to take the first. At most 100 are listed, the oldest first;
truncated is true when there were more, which means the merchant has one SKU on
a great many variants and it needs fixing. A missing or blank sku is
400 sku_required.
Setting stock from another system
A warehouse system, an ERP or a fulfilment partner knows how much stock there is and sets it here in bulk. The
catch every such system has: by the time its figure arrives, the store may have sold some of it. A figure counted
before a sale and applied after it would put the sold unit back on the shelf, and the store would sell it twice.
So with each figure your system says which orders it has already counted, and Commerce takes off the web sales it
has not. It needs the inventory.write scope.
| Call | What it does |
|---|---|
PUT …/inventory-levels | Set the stock of up to 200 variants; answers what became of each |
DELETE …/inventory-levels/sequences | Forget every variant’s sequence, once, when another system takes over (below) |
curl -X PUT https://commerce.dev.colorsx74.com/api/v1/stores/{storeId}/inventory-levels \
-H "X-Api-Key: cx74_…" -H "Content-Type: application/json" \
-d '[{"variantId": "…", "sku": "LAMP-100", "quantity": 10, "sequence": 42, "ackedBelowOrderNumber": 1042}]'
{ "items": [ { "variantId": "…", "result": "applied", "quantityApplied": 7,
"unacknowledged": 3, "currentSequence": 42, "quantityBefore": 9 } ] }
… is /api/v1/stores/{id}. The body is a list of at most 200 items, each variant at
most once:
| Field | Meaning |
|---|---|
variantId | Required. A variant of this store (find one by SKU with
GET …/variants?sku=). |
quantity | Required. How many your system says are available, 0 or more. |
sequence | Required. A whole number, 1 or more, that only goes up across everything you send. One counter for all your pushes is enough: each variant remembers the newest sequence it took, and takes a figure only when its sequence is greater. A push retried or delivered late, with the same or a lower number, changes nothing. |
ackedBelowOrderNumber | Optional: the mark, the lowest order number of this store you have not taken in yet. You have counted every order numbered below it. Leave it out to set the quantity exactly as sent. |
sku | Optional check. When sent, the variant’s SKU must be exactly this (spaces around it are ignored, case counts), or nothing is set for that variant. |
The mark, worked through
Each store numbers its orders one after another. Every number is an order, cancelled ones included; a number is never reused, and a checkout that fails gives its number back, so there are no gaps. That is why a single number can say which sales your figure already includes: every order numbered below the mark.
The mark is the lowest order number you have not taken in yet, which is not always your newest order + 1. Messages about different orders do not arrive in number order: a webhook delivery that fails is tried again later while the next ones go out, and a card order is announced only after the payment provider has answered. So you can have #1041 before #1040. Move the mark up only across numbers you have: with everything up to #1039, and #1041, your mark is 1040 until #1040 arrives. A number you are missing is an order you have not seen; fetch it from the order pull rather than waiting for its webhook.
Read the mark before you compute the quantity, or take both from one snapshot. An order you take in between the two reads is then counted twice, in your figure and in the store’s subtraction, which is the safe side. The other way round it is in neither: your figure was computed before you counted it, the mark has already passed it, and its units go back on sale.
Your system counts 10 lamps. It has taken in every order up to #1041, so it sends
"quantity": 10, "ackedBelowOrderNumber": 1042. Meanwhile the store has sold 2 lamps in #1042 and 1
in #1043, and #1044, also a lamp, was cancelled. Commerce stores 10 − (2 + 1) = 7 and answers
"quantityApplied": 7, "unacknowledged": 3. When your system has taken in #1042 to #1044, its own
count is 7 and its mark is 1045, and nothing is taken off: the figure stands as sent.
Had #1043 reached you before #1042, your count would be 9 and your mark still 1042. Commerce then stores 9 − (2 + 1) = 6, one lamp fewer than there are, because #1043 is in your figure and is taken off again. That is the safe side, and it lasts only until #1042 arrives. Sending 1044 instead would say you had counted #1042’s two lamps when you had not, and Commerce would store 9 for the 7 lamps there are.
What is taken off, per variant: the units of this store’s orders numbered at or above the mark, less any that came back (returned with a refund). A cancelled order counts for nothing: its stock went back when it was cancelled. A unit sold while the variant did not track stock counts too: the store took nothing from its own count for it, but your figure still includes it until you take the order in. A line whose variant has since been deleted counts against no variant, not even a new one with the same SKU (SKUs need not be unique), so take such an order in before you set the new variant. Another store’s orders never count, even another store of yours: each store numbers its own orders. The result is never below 0.
When you take an order in, set its variants again with your new mark, even if your own figure did not move (an order that reaches you already cancelled, say). What the store shows depends on the mark as well as on your figure, and only a push corrects it. Because the result is never below 0, a figure lower than the units still to come off (another channel sold the last unit while the web did too) leaves 0 and forgets the difference, so a web unit that comes back afterwards is on sale again until your next push.
A push that arrives while a checkout of the same variant is under way waits for that checkout to finish, then counts its order. One push is applied as a whole, in one transaction.
What each item tells you
Every item is answered, in the order sent, with variantId, result,
quantityApplied and unacknowledged (both set only when applied), and
currentSequence: the sequence the variant now holds when applied, or the newer one that beat you when
stale, and quantityBefore (set only when applied). An item that is not applied does not use up its
sequence.
quantityBefore is what your push replaced: the stock the variant held at the
moment your figure went in. It is read while the push holds the variant, so no checkout can land between that
reading and your figure: a checkout that finished while your push waited for it is already in it, and one that
started while your push was being applied is not. Compare it with what you last applied (your previous
quantityApplied) to see whether anyone else changed the figure. Equal means nothing moved it; a
difference is a change you did not make, such as a sale or a returned unit since your last push, or a hand edit
in the admin. A separate GET before the push cannot tell you this: a checkout can land between the
two.
| Result | Meaning |
|---|---|
applied | Set. quantityApplied is the stock now stored,
unacknowledged the units taken off your quantity, quantityBefore the stock it
replaced |
stale | The variant already holds this sequence or a newer one. Nothing changed |
sku_mismatch | You sent a sku and the variant’s is different. Nothing
changed; look the variant up again |
unknown_variant | No variant of this store has that id (it was deleted, or it belongs to another store). Nothing changed |
not_tracked | The variant does not track stock (made to order, digital): it is always available, and a level would mean nothing. Nothing changed |
A whole push is refused with 400, and nothing in it applied, when it breaks the shape. The
title is the code:
| Code | Meaning |
|---|---|
too_many_items | More than 200 items. Split the push |
duplicate_variant | The same variantId twice. Send each variant once |
invalid_item | An item that is null instead of an object |
A missing variantId, quantity or sequence, a negative quantity, a
sequence or mark below 1, a number that is not a whole number or does not fit, a sku over 100
characters, or a field this route does not know is an ordinary validation 400 naming the field. A
missing quantity is never read as 0, and a misspelt ackedBelowOrderNumber is never read as no
mark.
Setting stock sends no webhook (your system already knows what it set) and changes no order, so it does not show in the order pull. Unless the store’s stock is set to another system (below), a merchant can still edit stock by hand in the admin; the next push you send replaces it, and an older push never can. The admin edit does not touch the sequence.
When another system takes over
Each variant remembers the newest sequence it took and only ever takes a greater one. A new system taking over the
store’s stock (a new warehouse system, another company’s ERP) starts its own counter, usually at 1, and every figure
it sent would be stale. So, once, when the sender changes: revoke the old system’s key
first, so nothing it still sends can land, then
curl -X DELETE https://commerce.dev.colorsx74.com/api/v1/stores/{storeId}/inventory-levels/sequences \
-H "X-Api-Key: cx74_…"
{ "cleared": 312 }
Every variant of the store forgets its sequence, so the next push applies whatever its sequence, and the
protection starts again from there. cleared counts the variants that had one. Stock is not changed. It
needs the inventory.write scope. It is not for routine use: a push that was retried or delayed, and
lands after a reset, applies.
Who owns the stock
A store’s stock is either typed by its merchant (store, how every store starts) or set by another
system (external). The switch is a statement about ownership, so the dashboard stops offering a number
to type and the API stops accepting one, instead of two writers racing. Switching moves no stock and rewrites no
variant.
| Call | What it does |
|---|---|
GET …/inventory-source | Answers {"source": "store"} or
{"source": "external"}. Needs inventory.read |
PUT …/inventory-source | Body {"source": "external"} or
{"source": "store"}; answers the saved value. Needs inventory.write. Any other value,
or none, is 400 invalid_inventory_source |
The same value is on the store itself as inventorySource. While it is external:
- Creating a product with a
stockQuantityother than 0, adding a variant with one, or a full variant update whosestockQuantitydiffers from the stored figure is409 stock_managed_externallyand nothing in that request is written. A full update that sends the stored figure back unchanged passes, so a client that echoes the whole variant keeps working. PATCH never carries stock, so it is not affected. - Stock tracking is locked with the figure. A variant that does not track stock sells without
limit, and a push answers
not_trackedfor it, so the other system could no longer cap it. Creating a product or adding a variant withtrackInventory: false, or a full update whosetrackInventorydiffers from the stored value (either way), is the same409 stock_managed_externally. A variant that was already untracked when the store was switched stays untracked; to change it, switch the store back, change it, and switch again. - Price, compare-at price, SKU, options and the rest stay editable: only stock and stock tracking are locked. Prices are not owned by the other system until a price route exists.
- Checkout, a cancelled order, a refund that takes units back and
PUT …/inventory-levelsall move stock exactly as before. Your system’s subtraction of unseen sales depends on them. - The dashboard shows every stock box and every Track stock switch read-only, with the reason. The merchant can switch back in the Integrations tab, or with the call above, at any time: they never need your key to take the store back if your system is gone.
The store itself
Every key can read the profile of the stores it reaches (GET …, where … is
/api/v1/stores/{id}): its name, currencies, status and who owns its stock
(inventorySource).
The store’s tax posture
A system that books the store’s sales (an ERP, an accounting tool) needs to know how the store charges VAT. Any key of the store can read it, whatever its scopes, because it is no secret: it is the store’s tax registration and posture, which the store prints on the confirmation whenever it charges VAT.
curl https://commerce.dev.colorsx74.com/api/v1/stores/{storeId}/vat \
-H "X-Api-Key: cx74_…"
{ "enabled": true, "ratePercent": 5.000, "number": "100000000000003", "pricesInclude": true }
enabled says whether the store charges VAT at all, ratePercent at what rate,
number is the store’s tax registration number (empty when none is set), and
pricesInclude is true when the prices shown already contain the tax. Changing any of it
(PUT …/vat) is a store setting and needs settings.write.
The calls a stock and order sync makes
An integration that owns a store’s stock and takes in its orders needs these, and a key made with the Inventory & orders integration button reaches all of them:
| Call | What it is for | Scope |
|---|---|---|
GET …, GET …/vat | Find the store, its currency and its tax posture | any |
GET …/products, GET …/variants?sku= | Match your items to the store’s variants by SKU (details) | catalog.read |
PATCH …/products/{id}/variants/{id} | Write your key on a variant (details) | catalog.write |
PUT …/inventory-source | Take ownership of the stock (details) | inventory.write |
PUT …/inventory-levels | Set stock, net of the sales you have not seen (details) | inventory.write |
DELETE …/inventory-levels/sequences | Once, when your system takes over from another (details) | inventory.write |
POST …/webhooks, GET …/webhooks | Register your order-event endpoint, paused until you are ready, and find it again (details) | webhooks.write |
GET …/orders?updatedAfterUtc= | Take in every order that changed (details) | orders.read |
PUT …/orders/{id}/external | Write your reference back on an order (details) | orders.write |
API keys
Send the key as a header:
curl https://commerce.dev.colorsx74.com/api/v1/stores \ -H "X-Api-Key: cx74_…"
Keys are shown once when created, then stored only as a hash — we cannot recover one for you, so rotate it if it is lost. Two limits are worth choosing deliberately:
- Scope it to one store when the integration only needs that store. Such a key cannot see or address your other stores at all, and cannot create new ones.
- Give it only the areas it needs. Under Access, choose Read everything, change nothing for a reporting tool, or Choose per area and grant read or read & change area by area. The Inventory & orders integration button fills in what a stock and order sync needs: catalog, stock, orders and order events. It leaves out refunds and all of your store settings. The Stock levels area is the bulk stock route (below); stock typed on a product or variant is still a catalog change, so a key that can change the catalog can change stock that way, whatever you choose for stock levels, unless the store’s stock is set to another system (below), which refuses a typed stock change.
Scopes
A scope is <area>.read or <area>.write. Write includes
read: orders.write can also list orders. A request outside the key’s scopes is
answered 403 with the title api_key_scope, and creating a key with a
scope that does not exist is refused (400 api_key_scope_unknown) rather than
stored and silently ignored.
| Area | Covers |
|---|---|
catalog | Products and their variants, product images, collections, and AI catalog generation (it creates products) |
inventory | Stock levels set in bulk from another system
(PUT …/inventory-levels, details) and who owns the stock
(…/inventory-source, details). Stock edited on a
product or variant still counts as catalog |
orders | Orders, their status, tracking, the back-office reference
(PUT …/external), and the sales insights built from them. Reading an order includes its
refunds |
refunds | Sending money back: refunding an order, and answering for a
refund the payment provider never confirmed. orders.write does not include it, so
an order-sync key cannot refund. No route needs refunds.read yet: an order’s
refunds are read with the order (orders.read) |
webhooks | The order-event endpoints: add, re-address, rename,
pause, resume, remove, and their delivery lists. webhooks.manage is accepted as another name for
webhooks.write |
content | Pages, navigation, the blog, the media library, the theme,
the section catalogue, and AI page and store drafting. Some of this changes the live store
at once: content.write can publish or unpublish a blog article, replace the
navigation, and delete media |
settings | Store email (SMTP), domain, publishing and unpublishing the store itself, display currencies, changing VAT (reading it is part of the store profile), payment methods, cart recovery, shipping zones, discount codes, reviews, and the inbox (form messages, subscribers, replies) |
store | The store profile and its tax posture (GET
…/vat). Every key can read the profile of the stores it reaches, whatever its scopes, so an
integration can find the store it is pointed at. Writing it means creating a store |
Four older forms keep their meaning, so keys made before scopes existed are unaffected:
* and an empty list mean full access, write means every read and
every change, and read means every read. The admin’s Full access mints
*.
Before 10 October 2026 any scope ending in .write allowed every change in the API.
A key that held, say, only orders.read,orders.write now reaches orders and nothing
else — not even refunds, which need refunds.write — so check the scopes of an older
key if a call that used to work now answers 403. The 403’s
detail names what was missing, for example
This API key does not grant refunds.write.
Which key am I?
A key can describe itself, whatever its scopes:
curl https://commerce.dev.colorsx74.com/api/v1/auth/api-key \
-H "X-Api-Key: cx74_…"
{
"name": "Stock sync",
"prefix": "cx74_ab12cd3",
"storeId": "7c1f…",
"scopes": ["catalog.write", "inventory.write", "orders.write", "webhooks.write"],
"expiresAtUtc": null,
"access": { "catalog": "write", "inventory": "write", "orders": "write", "refunds": "none",
"webhooks": "write", "content": "none", "settings": "none", "store": "read" }
}
scopes is the list as stored; access is what it adds up to. Check
access when your integration wants to refuse a key that can do more than it needs:
an empty scopes list means full access, not none. The response never carries the
key or its hash. Called from a signed-in browser session instead of with a key, the route
answers 404 with the title no_api_key: there is no key to describe.
Variant labels in two languages
A variant's options are shown to shoppers as its label ({"size": "L"} reads “L”).
To give the Arabic storefront its own wording, add the same key with _ar on the end:
{"size": "L", "size_ar": "كبير"}. Keys without an Arabic twin show their plain value
in both languages, and the order keeps both labels as they were at checkout.
Revoking takes effect on the next request. Key management itself is deliberately unavailable to API keys — only a signed-in human can mint or revoke one.