ColorsX74 Commerce

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.

EventWhen it fires
order.createdCheckout completed. For card and BNPL this is before money is confirmed.
order.paidPayment confirmed.
order.fulfilledThe merchant marked the order shipped.
order.cancelledThe 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.refundedA refund was recorded on the order: each partial refund too, so one order can send several.
order.refund_reversedA 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:

FieldMeaning
paymentMethodHow 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[].variantIdThe variant that was sold. null once the merchant deleted that variant: the line itself stays.
lines[].skuThe 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[].restockedQuantityHow 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[].returnedThe 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.
paidAfterCancelAtUtcWhen 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.
updatedAtUtcWhen the order or any of its refunds last changed, to the microsecond. It is the cursor of the pull below.
externalRef, externalStatus, externalMessageWhat 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.

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": ""}'
FieldUp toWho sees it
externalRef100 charactersThe merchant on the order, and the shopper on their order confirmation page, as the invoice number. Put nothing private in it.
externalStatus40 charactersThe merchant only
externalMessage300 charactersThe 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:

HeaderValue
X-Webhook-Signaturesha256= + HMAC-SHA256 of the raw body, keyed with your signing secret, hex-encoded
X-Webhook-Eventthe event name
X-Webhook-Deliverya 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

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.

CallWhat it does
GET …/webhooksThe store's endpoints, oldest first, each with id, name, url, isActive and waiting (events held or not yet sent)
POST …/webhooksAdd 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}/pauseHold this endpoint's events
POST …/webhooks/{endpointId}/resumeSend what was held, oldest first
GET …/webhooks/{endpointId}/deliveriesThis endpoint's recent deliveries, newest first
POST …/webhooks/{endpointId}/deliveries/{deliveryId}/retrySend one dead-lettered delivery again, under the same id (details)
POST …/webhooks/{endpointId}/deliveries/retry-allSend 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:

CodeMeaning
webhook_endpoint_limitThe store already has 5 endpoints. Remove one first
duplicate_endpoint_urlAnother endpoint of this store already has that address
multiple_endpointsA singular-route write on a store with more than one endpoint. Address the one you mean by id
endpoint_managed_by_collectionA 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:

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"}'
FieldMeaning
priceThe new price, from 0 to 999,999,999 with at most three decimal places. Omitted or null keeps the stored price.
compareAtPriceThe new compare-at (“was”) price, with the same bounds. Omitted or null keeps it.
clearCompareAtPricetrue 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.
skuUp to 100 characters after trimming. Omitted or null keeps it; an empty string clears it.
externalRefYour 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.

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.

FieldWhen it is left outWhen it is sent
priceKeptSet. null keeps it
compareAtPriceKeptSet; null removes it
skuKeptSet; null or an empty string removes it
optionsKeptReplaced by the object; null empties them
trackInventoryKeptSet. null keeps it. On a store whose stock another system sets, a value other than the stored one is 409 stock_managed_externally
stockQuantityKeptSet, 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.

FieldWhen it is left outWhen it is sent
nameEnKeptSet, trimmed. null keeps it. An empty or blank name is a validation 400, as when creating
nameAr, descriptionEn, descriptionArKeptSet; null keeps it, an empty string clears it
statusKeptSet: 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.

CallWhat it does
PUT …/inventory-levelsSet the stock of up to 200 variants; answers what became of each
DELETE …/inventory-levels/sequencesForget 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:

FieldMeaning
variantIdRequired. A variant of this store (find one by SKU with GET …/variants?sku=).
quantityRequired. How many your system says are available, 0 or more.
sequenceRequired. 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.
ackedBelowOrderNumberOptional: 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.
skuOptional 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.

ResultMeaning
appliedSet. quantityApplied is the stock now stored, unacknowledged the units taken off your quantity, quantityBefore the stock it replaced
staleThe variant already holds this sequence or a newer one. Nothing changed
sku_mismatchYou sent a sku and the variant’s is different. Nothing changed; look the variant up again
unknown_variantNo variant of this store has that id (it was deleted, or it belongs to another store). Nothing changed
not_trackedThe 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:

CodeMeaning
too_many_itemsMore than 200 items. Split the push
duplicate_variantThe same variantId twice. Send each variant once
invalid_itemAn 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.

CallWhat it does
GET …/inventory-sourceAnswers {"source": "store"} or {"source": "external"}. Needs inventory.read
PUT …/inventory-sourceBody {"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:

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:

CallWhat it is forScope
GET …, GET …/vatFind the store, its currency and its tax postureany
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-sourceTake ownership of the stock (details)inventory.write
PUT …/inventory-levelsSet stock, net of the sales you have not seen (details)inventory.write
DELETE …/inventory-levels/sequencesOnce, when your system takes over from another (details)inventory.write
POST …/webhooks, GET …/webhooksRegister 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}/externalWrite 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:

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.

AreaCovers
catalogProducts and their variants, product images, collections, and AI catalog generation (it creates products)
inventoryStock 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
ordersOrders, their status, tracking, the back-office reference (PUT …/external), and the sales insights built from them. Reading an order includes its refunds
refundsSending 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)
webhooksThe order-event endpoints: add, re-address, rename, pause, resume, remove, and their delivery lists. webhooks.manage is accepted as another name for webhooks.write
contentPages, 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
settingsStore 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)
storeThe 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.

Beta. These endpoints are live on the dev environment and are what the ColorsX74 ERP connector is being built against.

Open the builder