OccaStore Bridge API — v1

v1 Design Preview. Endpoints marked "Phase 1 ✓ Live" are deployed on staging and production. This is a design-preview reference and may be extended before v1 GA; the published page is noindex until GA.

Environment Base URL
Staging (Design Preview / integration testing) https://dev-api.occastore.com
Production (go-live traffic) https://api.occastore.com

API base path: /v1 on both. Credentials are per environment — see Environments.

Not sure which integration you have? See Integration types (EDI/API over HTTPS vs drop-ship SFTP file exchange).

This reference covers the customer-facing order and read endpoints. Admin/operator endpoints are internal and are intentionally not documented here.

Endpoint Doc
GET /status Public reachability probe (no auth) — Quickstart
POST /v1/orders post-orders.md Phase 1 ✓ Live
GET /v1/orders/:bridge_order_id get-order.md Phase 1 ✓ Live
POST /v1/orders/:bridge_order_id/cancel cancel-order.md Phase 1 ✓ Live
GET /v1/stock, GET /v1/stock/:sku stock.md Phase 1 ✓ Live
GET /v1/catalogue, GET /v1/catalogue/:sku catalogue.md Phase 1 ✓ Live
GET /v1/pricing, GET /v1/pricing/:sku pricing.md Phase 1 ✓ Live
Authentication (HMAC) authentication.md
Error codes error-codes.md

Authentication

Every request is signed with HMAC-SHA256 over a canonical representation of the request, using four headers:

X-Bridge-Client-Id: cli_<...>
X-Bridge-Timestamp: <unix seconds>
X-Bridge-Nonce:     <unique per request, e.g. a UUID>
X-Bridge-Signature: base64( HMAC_SHA256( secret, canonical ) )

where canonical is METHOD\nPATH\n<sha256-hex of raw body>\ntimestamp\nnonce. The timestamp must be within 5 minutes of server time; each nonce is single-use within a 10-minute window.

See authentication.md for the canonical-string rules, a fully-worked signing example, the seven authentication-failure reasons, the account-status codes, and the per-endpoint scopes. Don't duplicate that logic from here — it's the single source for signing.

Scopes

Each endpoint requires a per-integration scope grant (e.g. orders.write, orders.read, orders.cancel, stock.read, catalogue.read, pricing.read). Missing/disabled → 403 scope_disabled. The full mapping is in authentication.md.

Idempotency

Write endpoints (POST /v1/orders, POST /v1/orders/:id/cancel) require an Idempotency-Key header (a UUID you generate per logical operation).

  • Same key + same body within 24 hours → the original stored response is replayed (no duplicate side effect).
  • Same key + different body → 409 idempotency_conflict.
  • Key already used by a different account → 400 idempotency_key_conflict.
  • Missing header → 400 missing_idempotency_key.

Error envelope

All errors share one flat shape (never a nested object):

{ "error": "<http-class word>", "reason": "<stable_snake_code>" }

Some errors add a sibling field (e.g. field, conflicting_order_id). Switch on reason — it is the stable, documented identifier; error is just the HTTP-class word and may differ across surfaces for the same status. Money is always integer pence (never a float/decimal). Every response includes an X-Request-Id header for support correlation.

See error-codes.md for the complete reference.

Rate limiting

Per (account, scope) token bucket — defaults 20 requests/second burst, 60/minute, 10 000/day (tunable per account). Exceeding any limiter → 429 rate_limited with Retry-After and X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-Reset headers. Full detail in Rate limits.

Read endpoints & pagination

The read endpoints (stock, catalogue, pricing) serve cached, eventually-consistent data. Each has a single-item form (/:sku) and a bulk list form.

Single-item reads return the item object directly. The bulk list endpoints share one pagination envelope:

{
  "items": [ /* ... */ ],
  "page": 1,
  "per_page": 100,
  "total_pages": 5,
  "total_items": 437,
  "has_more": true
}
  • page — query parameter; default 1, minimum 1.
  • per_page — query parameter; default 100, maximum 500.
  • total_pages0 when there are no items, otherwise ceil(total_items / per_page).
  • has_moretrue when page < total_pages.

See stock.md, catalogue.md, and pricing.md for each endpoint's response fields.

Quickstart — v1 Phase 1 ✓ Live

EDI/API only. If your onboarding mentions SFTP folders and CSV purchase orders, start at Integration types → Drop-ship SFTP.

From zero to a created-and-cancelled test order in a few minutes. This is the golden path; each step links the detail and the tooling that does the work for you.

Fastest path: if you'd rather not hand-roll anything yet, jump straight to the Terminal tester (--check proves your signing offline; --create / --cancel drive a real order) or the Postman collection (import it and every request is auto-signed). Come back here for the end-to-end shape.

1. Get your credentials

You need two values, issued to your account by OccaStore (contact your account manager — there is no self-serve signup during Design Preview). Credentials are per environment — staging and production use different Client IDs and secrets. See Environments.

  • Client ID — sent on every request as X-Bridge-Client-Id.
  • HMAC secret — the signing key. Keep it server-side; never put it in a URL, a query string, or client code.

2. The headers on every request

Every authenticated call carries four headers (writes add a fifth). Full rules in Authentication:

Header Value
X-Bridge-Client-Id your Client ID
X-Bridge-Timestamp current unix seconds (must be within ±5 min)
X-Bridge-Nonce a fresh UUID per request (10-min replay window)
X-Bridge-Signature the base64 HMAC (next step)
Idempotency-Key writes only — a fresh UUID; your retry-safety handle

3. Sign the request

X-Bridge-Signature = base64( HMAC_SHA256( secret, canonical ) ), where the canonical string is five fields joined by \n: METHOD\nPATH\nSHA256_HEX(body)\nTIMESTAMP\nNONCE.

Don't take our word for it — prove your signer against the published signing vectors, and copy a ready-made signer from reference signers (Node / Python / PHP / C#). The bridge-api-tester.mjs --check runs this as an offline self-test with zero setup.

4. Your first authenticated GET

Start with the public health probe (no auth), then a signed read:

GET /status                       # 200, no auth — confirms reachability
GET /v1/stock/YOUR-SKU            # signed — confirms your HMAC is accepted

Any non-401 on the signed read means your signing works. See Stock for the response shape and Rate limits for the X-RateLimit-* headers you'll see.

5. Create a test order

POST /v1/orders with an Idempotency-Key and a JSON body (full contract in Create order). A minimal body:

{
  "reference": "QUICKSTART-001",
  "items": [{ "sku": "YOUR-SKU", "quantity": 1, "unit_price_pence": 1000 }],
  "ship_to": {
    "name": "Example Customer",
    "address1": "1 Example Street",
    "town": "Leeds",
    "postcode": "LS1 1AA",
    "country": "GB"
  }
}

Money is integer pence. On 201 you get back a bridge_order_id — keep it. If you get a 4xx, look the reason up in Error codes (the Remediation column tells you the next action).

6. Read it back, then cancel it

GET  /v1/orders/{bridge_order_id}          # see Get order
POST /v1/orders/{bridge_order_id}/cancel   # see Cancel order

See Get order and Cancel order. A successful cancel returns status: "cancelled". That's the full lifecycle — you've signed, created, read, and cancelled.

Getting help

Every response — success or error — carries an X-Request-Id header. Quote that X-Request-Id (and the reason if it was an error) when you contact your account manager or support; it lets us find the exact request in our logs. The bridge-api-tester.mjs and the Postman collection are the quickest way to reproduce an issue cleanly for a support request.

Integration types — v1

OccaStore supports two partner integration paths today. They share the same fulfilment backbone but use different transport. Start here before you read endpoint or file-format detail.

Path Transport Status Partner docs start here
EDI / API Signed HTTPS (/v1) Phase 1 ✓ Live (staging + production) Quickstart
Drop-ship SFTP SFTP file exchange Per-partner go-live Drop-ship SFTP (below)

If you are unsure which path your account uses, ask your OccaStore account manager. Credentials and folder layout are issued per integration; there is no self-serve signup during Design Preview.


EDI / API

Phase 1 ✓ Live on two HTTPS gateways:

Environment Base URL Typical use
Staging https://dev-api.occastore.com Design Preview, integration testing, conformance
Production https://api.occastore.com Live traffic after go-live

Full detail: Environments.

Use this path when your account has a Bridge Client ID and HMAC secret for the environment you are calling. Production credentials are issued at go-live and are not interchangeable with staging credentials.

What you can do

Capability Doc
Reachability probe (no auth) GET /status — see Quickstart
Create and cancel orders Create order, Cancel order
Poll order status Get order, Order lifecycle
Read stock levels Stock
Read catalogue metadata Catalogue
Read trade pricing Pricing

Golden path

  1. Authentication — canonical signing rules and scopes.
  2. Quickstart — credentials → sign → first GET → test order.
  3. Conventions — integer pence, dates, references, idempotency (single source; do not re-derive from examples).
  4. Error codes — switch on stable reason tokens.

Tooling: Terminal tester and Postman collection. Set BRIDGE_BASE_URL / baseUrl to https://dev-api.occastore.com while integrating on staging; switch to https://api.occastore.com with your production credentials after go-live.

This path does not use SFTP folders or CSV purchase-order drops. If your onboarding pack mentions inbound PO files on SFTP, use the drop-ship section below instead.


Drop-ship SFTP

Per-partner go-live. SFTP file exchange is enabled by OccaStore when your account is activated and your integration is switched on. Until then, no files are picked up or generated even if you can reach the server.

Use this path when OccaStore exchanges CSV files over SFTP: you upload purchase orders; OccaStore uploads stock and despatch feeds back. There is no /v1 HMAC signing step for file exchange.

Your integration pack (issued by your account manager) is the authoritative spec for SFTP credentials, directory paths, filename patterns, and CSV column layout. This page describes the shape of the integration only; it does not replace your pack.

Overview

You  ──SFTP upload──►  Inbound PO CSV  ──►  OccaStore fulfilment
OccaStore  ──SFTP upload──►  Outbound stock CSV
OccaStore  ──SFTP upload──►  Outbound despatch CSV

Fulfilment runs inside OccaStore after your PO is accepted; there is no immediate SFTP acknowledgement file.

Direction Feed Purpose
Inbound (you → OccaStore) Purchase order CSV Order lines to fulfil
Outbound (OccaStore → you) Stock CSV Availability and optional catalogue fields per mapped SKU
Outbound (OccaStore → you) Despatch CSV Tracking and despatch confirmation

Outbound stock CSV column sources

Your integration pack lists the exact headers OccaStore emits. Each column maps a source to your chosen CSV header:

Source kind Examples Meaning
SKU map partnerItemNo, sku Partner item number or OccaStore's fulfilment-system SKU
Stock quantity available, reserved, onOrder, dueIn, earliestExpiry Per-location stock cache ( available respects your configured basis and safety buffer )
Catalogue barcode, title, retailPrice, weight, taxRate, stockItemId, height, width, depth Catalogue data held for the SKU in OccaStore's fulfilment system
Location location Your configured stock location name
Constant { "constant": "GBP" } Fixed value in every row
Custom attribute { "attribute": "Brand" } Any custom attribute recorded against the SKU in OccaStore's fulfilment system

If a catalogue field is not cached for a SKU, the cell is left blank (the stock feed still uploads). Your account manager configures the column layout in your integration pack.

Getting set up

Your account manager provides:

  • SFTP host, username, and authentication details (including host-key policy)
  • The inbound directory where you place PO files
  • The outbound directories where stock and despatch files will appear
  • A written file specification: delimiter, encoding, headers, and column meanings
  • Whether your account uses review or auto processing (see below)

Transport is SFTP (SSH file transfer), not FTPS or plain FTP. OccaStore connects to your server as the SFTP client, or uses a mutual drop zone, depending on your contract.

Review vs auto (stated in your pack):

  • Review — after OccaStore accepts your PO file, an operator approves it before a fulfilment order is created.
  • Auto — once ingest validation passes, OccaStore proceeds to fulfilment without operator approval.

Timing

Once your account is live, OccaStore polls SFTP on a short schedule agreed at setup. Your integration pack states the expected pickup and generation windows.

  • Inbound PO files are picked up on that schedule after you place them.
  • Stock files are generated on a periodic schedule from warehouse data, not immediately when you upload a PO.
  • Despatch files appear after the order is despatched in fulfilment, not when the PO file lands.

After you place a test PO file, allow time for ingest, fulfilment, and the outbound windows before expecting stock or despatch CSVs.

What to test

  1. Place a test PO file in the inbound directory using the filename and columns from your integration pack.
  2. Confirm with your account manager that OccaStore received and accepted it.
  3. Check the outbound directories after the agreed windows for stock and despatch files.
  4. Open the CSVs and confirm quantities, SKUs, and tracking match your pack.

Reads over HTTPS

Some partners use SFTP for order files and the EDI/API path for stock, catalogue, or pricing reads. Ask your account manager whether your account has Bridge scopes for those endpoints.

Support

Layout, filename, or column changes go through your OccaStore account manager. For /v1 signing, scopes, or error reasons, see Authentication and Error codes.

Environments — v1 Phase 1 ✓ Live

OccaStore Bridge runs two EDI/API gateway environments. They are separate systems: different databases, different credentials, and different rate-limit counters. Always match your Client ID, HMAC secret, and base URL to the same environment.

Staging Production
Base URL https://dev-api.occastore.com https://api.occastore.com
Purpose Design Preview integration testing Live partner traffic
Credentials Issued for onboarding / conformance Issued at go-live (distinct from staging)
Reachability GET https://dev-api.occastore.com/status GET https://api.occastore.com/status
When to use Build and test your signer; conformance batteries After your account manager confirms production go-live

Both environments expose the same /v1 contract documented in this reference. Behaviour and data are not shared — an order created on staging does not appear on production, and vice versa.

Design Preview vs go-live

  • Staging (dev-api) is the default integration target during Design Preview. Use it for development, Postman/terminal-tester runs, and onboarding conformance tests unless told otherwise.
  • Production (api) is deployed and available for per-partner go-live. Your account manager promotes you when integration testing is complete and issues new production credentials (client_id + HMAC secret). Staging credentials do not work on production.

If you are unsure which environment your credentials target, ask your account manager before sending traffic.

Tooling variables

Set the gateway base URL in your tooling to match the environment:

Tool Variable Staging Production
Terminal tester BRIDGE_BASE_URL https://dev-api.occastore.com https://api.occastore.com
Partner diagnostic BRIDGE_BASE_URL https://dev-api.occastore.com https://api.occastore.com
Postman collection baseUrl https://dev-api.occastore.com https://api.occastore.com

See Terminal tester, Partner diagnostic, and Postman collection.

Drop-ship SFTP

Drop-ship partners use SFTP file exchange, not these HTTPS base URLs. SFTP hosts and paths are in your integration pack. Some accounts also have Bridge scopes for HTTPS reads — your account manager confirms whether those use staging or production credentials.

Support

Quote the X-Request-Id from the environment where the issue occurred, and state whether the call was to dev-api or api.

Authentication — v1 Phase 1 ✓ Live

Every /v1 request is authenticated with an HMAC-SHA256 signature over a canonical representation of the request. There are no bearer tokens, no cookies, and no OAuth (OAuth is a later-phase design, not live) — each request is independently signed.

Base URLs — see Environments:

  • Staging: https://dev-api.occastore.com
  • Production: https://api.occastore.com

Credentials

When your integration is provisioned you receive:

  • a Client ID (cli_…) — public; sent on every request.
  • a secret — issued once at credential creation and never recoverable afterwards. Store it securely. To obtain a new secret, rotate the credential (which revokes the old one).

Request headers

Four headers on every /v1 request:

X-Bridge-Client-Id: cli_<your client id>
X-Bridge-Timestamp: <Unix time, integer seconds>
X-Bridge-Nonce:     <unique value per request, e.g. a UUIDv4>
X-Bridge-Signature: <base64 HMAC-SHA256 — see below>

Signature

X-Bridge-Signature = base64( HMAC_SHA256( secret, canonical ) )

The canonical string is exactly five lines joined by a single \n (newline, 0x0A):

<HTTP METHOD>
<request path, no query string>
<body hash>
<timestamp>
<nonce>
  • <HTTP METHOD> — uppercase, e.g. POST, GET.
  • <request path, no query string> — e.g. /v1/orders. Exclude any ?….
  • <body hash>lowercase hex SHA-256 of the exact raw request body bytes. For a request with no body (e.g. a GET, or a cancel with an empty body) use the SHA-256 of the empty string: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
  • <timestamp> — the same integer sent in X-Bridge-Timestamp.
  • <nonce> — the same value sent in X-Bridge-Nonce.

The secret is the HMAC key; the signature is the base64 of the raw HMAC digest.

Worked example

Take this exact request body for POST /v1/ordersminified, no trailing newline. The body hash is computed over the exact bytes you send, so whitespace matters; sign the same bytes you put on the wire.

{"reference":"PO-EXAMPLE-001","items":[{"sku":"WPI-CHOC-2KG","quantity":2,"unit_price_pence":4999}],"ship_to":{"name":"Jane Example","address1":"1 Example Way","town":"Leeds","postcode":"LS1 1AA","country":"GB"}}

Its lowercase-hex SHA-256 — which you can reproduce (printf '%s' '<body>' | sha256sum) — is:

2dfdf33221c21e7353f774968a184f7b63785c37e95a34b023ba7b69cff221d3

With timestamp 1716123456 and nonce b3f1a8c2-9d44-4e21-8a77-0c2e3f1a8c2e, assemble the canonical string from five fields joined by a single \n (0x0A), no trailing newline. As a single line, escaping the joins explicitly:

POST\n/v1/orders\n2dfdf33221c21e7353f774968a184f7b63785c37e95a34b023ba7b69cff221d3\n1716123456\nb3f1a8c2-9d44-4e21-8a77-0c2e3f1a8c2e

— which is, line by line:

POST
/v1/orders
2dfdf33221c21e7353f774968a184f7b63785c37e95a34b023ba7b69cff221d3
1716123456
b3f1a8c2-9d44-4e21-8a77-0c2e3f1a8c2e

Then sign the UTF-8 bytes of that canonical string with your issued secret:

X-Bridge-Signature = base64( HMAC_SHA256( <your issued secret>, canonical ) )

The signature value depends on your secret, so it is not reproduced here — compute it with your secret. Send the same 1716123456 and b3f1a8c2-… values as the X-Bridge-Timestamp and X-Bridge-Nonce headers you signed over.

Timestamp window

X-Bridge-Timestamp must be within 300 seconds (5 minutes) of the server's clock in either direction. Outside that window → 401 timestamp_skew. Keep your client clock NTP-synced.

Nonce / replay protection

X-Bridge-Nonce must be unique per request. A nonce is remembered for 600 seconds (10 minutes); reusing one within that window → 401 nonce_replay. The nonce is checked before the signature, so replaying a previously-valid request is rejected as nonce_replay (not bad_signature). Use a fresh UUID per request.

Authentication failures — the seven stages

Authentication runs seven checks in a fixed order; the first one that fails determines the response. All seven return HTTP 401 with { "error": "unauthorized", "reason": "<below>" }:

Order reason Meaning
1 missing_headers One or more of the four X-Bridge-* headers is absent.
2 timestamp_skew Timestamp is non-integer/malformed, or outside the 5-minute window.
3 nonce_replay This nonce was already used within the last 10 minutes.
4 unknown_client The Client ID is not recognised.
5 revoked_credential The credential has been revoked.
6 expired_credential The credential has passed its expiry.
7 bad_signature The signature does not match the canonical string.

On any 401, re-check your signing inputs (clock, nonce uniqueness, canonical construction) before retrying. unknown_client / revoked_credential / expired_credential indicate a credential problem that retrying will not fix — rotate or contact support.

Account status

Once the signature is valid, the account itself is checked. These are distinct from the signing failures above:

HTTP reason Meaning
403 account_suspended Account is suspended (or onboarding has not reached go-live).
401 account_revoked Account has been revoked.
403 account_pending Account is provisioned but not yet activated.

During onboarding, OccaStore may open a short conformance window on a pending account so you can place a real test order and prove signing works before go-live. While that window is open, authenticated /v1 calls are permitted as if the account were active. Outside the window (or without a window), pending accounts always receive 403 account_pending.

Note: account_revoked returns 401, while account_suspended and account_pending return 403. This is deliberate: a revoked account is a terminated principal — like a revoked credential, it is an authentication failure (401), whereas suspended / pending accounts authenticate fine but are not currently permitted to act, an authorization state (403). Switch on reason, not on the HTTP status word.

Scopes

Beyond authentication, each endpoint requires a per-integration scope grant. The current scopes are:

Scope Grants
orders.write POST /v1/orders
orders.read GET /v1/orders/:bridge_order_id
orders.cancel POST /v1/orders/:bridge_order_id/cancel
stock.read GET /v1/stock, GET /v1/stock/:sku
catalogue.read GET /v1/catalogue, GET /v1/catalogue/:sku
pricing.read GET /v1/pricing, GET /v1/pricing/:sku

A missing or disabled scope → 403 scope_disabled.

See error-codes.md for the complete error reference.

Authentication troubleshooting — v1 Phase 1 ✓ Live

Getting a 401? The reason in the response body tells you which signing stage failed; the checklist below is ordered by how often each cause is the culprit. Two tools resolve most cases without guessing: the signing vectors (prove your signer offline, byte-for-byte), the terminal tester's --check (an offline signing self-test), and the partner diagnostic (full step-by-step run with request_id capture for support).

First, read the reason

reason Which stage failed
missing_headers A required X-Bridge-* header is absent.
timestamp_skew Timestamp malformed, or outside the time window.
nonce_replay The nonce was reused.
unknown_client The Client ID isn't recognised.
revoked_credential / expired_credential The credential is no longer valid.
bad_signature The signature didn't match the canonical string.

The checklist (most common first)

1. Sign the bytes you actually transmit → bad_signature

The single most common cause. You must hash the exact bytes you put on the wire — no pretty-printing, no re-serialisation after hashing, no added or stripped whitespace. If you build a JSON object, hash it, then let an HTTP library re-stringify or reformat it before sending, the transmitted bytes differ from what you hashed and the signature won't match.

Fix: capture the final request body once, hash those bytes, and send those bytes. An empty body hashes to e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855. Prove your signer against the signing vectors.

2. Stale or rotated secret → bad_signature

Worked yesterday, fails today? You may be signing with an old secret. After a credential rotation the previous secret stops working.

Fix: sign with your current secret. (A revoked credential returns revoked_credential and an expired one expired_credential — distinct from bad_signature.)

3. Clock skew → timestamp_skew

X-Bridge-Timestamp must be the current time in unix seconds, within ±5 minutes (300 seconds) of server time. A static/old timestamp, milliseconds instead of seconds, or an unsynced clock all trip this.

Fix: send Math.floor(Date.now() / 1000); keep the host clock NTP-synced.

4. Nonce replay → nonce_replay

X-Bridge-Nonce must be a fresh UUID for every request. A nonce cannot be reused within the 10-minute (600-second) replay window — a hard-coded nonce, or a retry that reuses the previous one, will fail here.

Fix: generate a new UUID per request. (A retried write should reuse its Idempotency-Key but still send a new nonce.)

5. Sign the path without the query string → bad_signature

Fails only on requests that carry a ?query? The PATH field of the canonical string is the path excluding the query string — e.g. /v1/stock/SKU, not /v1/stock/SKU?foo=bar.

Fix: strip everything from ? onward before building the canonical string.

6. Header names + canonical field order → missing_headers / bad_signature

Check the exact header names — X-Bridge-Client-Id, X-Bridge-Timestamp, X-Bridge-Nonce, X-Bridge-Signature (writes also need Idempotency-Key and Content-Type: application/json) — and that the canonical string is the five fields in order, joined by \n:

METHOD
PATH
SHA256_HEX(body)
TIMESTAMP
NONCE

A wrong order or a missing field changes the signature.

Fix: copy a correct implementation from reference signers.

Rotating credentials (zero-downtime)

Your account can hold more than one active credential at a time — that's how you rotate a secret without an outage. Each credential is an independent (client_id, secret) pair, and signing with any active one authenticates.

To roll over with no downtime:

  1. Have a new credential issued — you now have two active.
  2. Deploy your signer with the new client_id + secret and confirm it works (the terminal tester --check is the quickest proof).
  3. Once all your traffic is on the new credential, have the old one revoked.

A revoked credential then returns 401 revoked_credential; an expired one 401 expired_credential. (Rotating the secret of a single credential in place — same client_id, new secret — is also supported, but the two-credential overlap above is the zero-downtime path.)

Still stuck?

Run the terminal tester --check — an offline signing self-test. If that fails, your crypto is wrong (fix it against the signing vectors). If it passes but a real call still 401s, the problem is on the live request — timestamp, nonce, secret, or headers — work points 2–6 above. When you contact support, quote the X-Request-Id from the response.

HMAC signing vectors — v1 Phase 1 ✓ Live

Generated file — do not hand-edit. Regenerate with node scripts/gen-hmac-vectors.js. Every value below is produced from the live gateway signing code (shared/crypto.js) and re-derived + asserted in CI by tests/unit/hmac-vectors.test.js, so this page cannot silently drift from the implementation.

These are reproducible worked examples of the HMAC signature, complete with the resulting X-Bridge-Signature. They use a public, obviously-fake dummy secret so you can confirm your signer produces the exact same signature, byte-for-byte, before you ever send a real request.

⚠️ The dummy secret authenticates nothing. Never sign a real request with it — use your own issued secret. These vectors exist only to validate your signing code.

Dummy secret: bridge-docs-example-secret-DO-NOT-USE

The canonical string is five fields joined by a single \n (0x0A), no trailing newline: METHOD, the request PATH (no query string), the lowercase-hex SHA-256 of the exact raw body bytes, the TIMESTAMP, and the NONCE. An empty body hashes to e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.

Vector 1 — GET (empty body)

A read with no request body — e.g. GET /v1/stock/:sku. The body hash is the SHA-256 of the empty string.

Field Value
Method GET
Path /v1/stock/WPI-CHOC-2KG
Timestamp 1716123456
Nonce d6f5e4c3-1a2b-4c3d-8e9f-0a1b2c3d4e5f
Body SHA-256 (hex) e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

Request body: (empty) — the body hash is the SHA-256 of the empty string.

Canonical string — five fields joined by a single \n (0x0A), no trailing newline. As one line with the joins escaped:

GET\n/v1/stock/WPI-CHOC-2KG\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n1716123456\nd6f5e4c3-1a2b-4c3d-8e9f-0a1b2c3d4e5f

— line by line:

GET
/v1/stock/WPI-CHOC-2KG
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
1716123456
d6f5e4c3-1a2b-4c3d-8e9f-0a1b2c3d4e5f

X-Bridge-Signature = base64( HMAC_SHA256( dummy_secret, canonical ) ):

rve5pdeYT2mMlDyuj14q81YCndcI4zc6snPhn622NAg=

Vector 2 — POST (JSON body)

A write with a JSON body — e.g. POST /v1/orders. The body hash is over the EXACT raw bytes sent (minified, no trailing newline); sign the same bytes you put on the wire.

Field Value
Method POST
Path /v1/orders
Timestamp 1716123456
Nonce b3f1a8c2-9d44-4e21-8a77-0c2e3f1a8c2e
Body SHA-256 (hex) 2dfdf33221c21e7353f774968a184f7b63785c37e95a34b023ba7b69cff221d3

Request body (minified, no trailing newline):

{"reference":"PO-EXAMPLE-001","items":[{"sku":"WPI-CHOC-2KG","quantity":2,"unit_price_pence":4999}],"ship_to":{"name":"Jane Example","address1":"1 Example Way","town":"Leeds","postcode":"LS1 1AA","country":"GB"}}

Canonical string — five fields joined by a single \n (0x0A), no trailing newline. As one line with the joins escaped:

POST\n/v1/orders\n2dfdf33221c21e7353f774968a184f7b63785c37e95a34b023ba7b69cff221d3\n1716123456\nb3f1a8c2-9d44-4e21-8a77-0c2e3f1a8c2e

— line by line:

POST
/v1/orders
2dfdf33221c21e7353f774968a184f7b63785c37e95a34b023ba7b69cff221d3
1716123456
b3f1a8c2-9d44-4e21-8a77-0c2e3f1a8c2e

X-Bridge-Signature = base64( HMAC_SHA256( dummy_secret, canonical ) ):

1YwQVYpLIql5XAup8updkYUtduSAHGgUmXU0aOgyrgQ=

Reference signers — v1 Phase 1 ✓ Live

Copy-paste HMAC signers in four languages. Each one reproduces the published signing vectors byte-for-byte — run it against the dummy secret and confirm you get the exact signatures below before you sign a real request. If your signature matches the vector, your signer is correct.

These implement the same canonical signing recipe as the live gateway (shared/crypto.js). The Node snippet is re-executed against the vectors in CI (tests/unit/signing-recipes.test.js), so it cannot silently drift from the gateway. See Authentication for the full header/replay rules and Signing vectors for the worked examples these reproduce.

⚠️ The dummy secret bridge-docs-example-secret-DO-NOT-USE authenticates nothing — it exists only so you can validate your signing code. Sign real requests with your own issued secret.

The recipe (recap)

Build the canonical string — five fields joined by a single \n (0x0A), no trailing newline:

METHOD
PATH                       (no query string)
SHA256_HEX(body)           (lowercase hex; empty body → e3b0c4…b855)
TIMESTAMP                  (unix seconds)
NONCE                      (uuid)

then X-Bridge-Signature = base64( HMAC_SHA256( secret, canonical ) ).

Each signer below, run with the dummy secret, must produce these exact signatures (the signing vectors):

Vector Expected X-Bridge-Signature
GET /v1/stock/WPI-CHOC-2KG (empty body) rve5pdeYT2mMlDyuj14q81YCndcI4zc6snPhn622NAg=
POST /v1/orders (example JSON body) 1YwQVYpLIql5XAup8updkYUtduSAHGgUmXU0aOgyrgQ=

Node.js

Node ≥ 14 — standard library only (node:crypto).

const crypto = require('node:crypto');

// SHA-256 of the raw request body, lowercase hex.
// Empty body → the well-known e3b0c4…b855 constant.
function computeBodyHash(body) {
  return crypto.createHash('sha256').update(body ?? '', 'utf8').digest('hex');
}

// Returns the base64 X-Bridge-Signature for one request.
function signRequest({ secret, method, path, body, timestamp, nonce }) {
  const bodyHash = computeBodyHash(body);
  const canonical = [method, path, bodyHash, timestamp, nonce].join('\n');
  return crypto.createHmac('sha256', secret).update(canonical, 'utf8').digest('base64');
}

// Example:
//   signRequest({ secret: 'bridge-docs-example-secret-DO-NOT-USE', method: 'GET',
//     path: '/v1/stock/WPI-CHOC-2KG', body: '', timestamp: '1716123456',
//     nonce: 'd6f5e4c3-1a2b-4c3d-8e9f-0a1b2c3d4e5f' })
//   === 'rve5pdeYT2mMlDyuj14q81YCndcI4zc6snPhn622NAg='

Python

Python 3 — standard library only (hashlib, hmac, base64).

import hashlib
import hmac
import base64


def compute_body_hash(body: str) -> str:
    """SHA-256 of the raw request body, lowercase hex. Empty body -> e3b0c4...b855."""
    return hashlib.sha256(body.encode("utf-8")).hexdigest()


def sign_request(secret: str, method: str, path: str, body: str,
                 timestamp: str, nonce: str) -> str:
    body_hash = compute_body_hash(body)
    canonical = "\n".join([method, path, body_hash, timestamp, nonce])
    digest = hmac.new(secret.encode("utf-8"),
                      canonical.encode("utf-8"),
                      hashlib.sha256).digest()
    return base64.b64encode(digest).decode("ascii")


if __name__ == "__main__":
    sig = sign_request("bridge-docs-example-secret-DO-NOT-USE", "GET",
                       "/v1/stock/WPI-CHOC-2KG", "", "1716123456",
                       "d6f5e4c3-1a2b-4c3d-8e9f-0a1b2c3d4e5f")
    assert sig == "rve5pdeYT2mMlDyuj14q81YCndcI4zc6snPhn622NAg=", sig
    print(sig)

PHP

PHP ≥ 7 — standard library only (hash, hash_hmac, base64_encode).

<?php

// SHA-256 of the raw request body, lowercase hex. Empty body -> e3b0c4...b855.
function compute_body_hash(string $body): string {
    return hash('sha256', $body);
}

function sign_request(string $secret, string $method, string $path,
                      string $body, string $timestamp, string $nonce): string {
    $bodyHash  = compute_body_hash($body);
    $canonical = implode("\n", [$method, $path, $bodyHash, $timestamp, $nonce]);
    // raw_output=true -> raw binary HMAC, then base64-encode it.
    return base64_encode(hash_hmac('sha256', $canonical, $secret, true));
}

$sig = sign_request('bridge-docs-example-secret-DO-NOT-USE', 'GET',
                    '/v1/stock/WPI-CHOC-2KG', '', '1716123456',
                    'd6f5e4c3-1a2b-4c3d-8e9f-0a1b2c3d4e5f');
assert($sig === 'rve5pdeYT2mMlDyuj14q81YCndcI4zc6snPhn622NAg=');
echo $sig, PHP_EOL;

C#

.NET 6+ — standard library only (System.Security.Cryptography). Uses SHA256.HashData and Convert.ToHexString (both .NET 5+).

using System;
using System.Security.Cryptography;
using System.Text;

public static class BridgeSigner
{
    // SHA-256 of the raw request body, lowercase hex. Empty body -> e3b0c4...b855.
    public static string ComputeBodyHash(string body)
    {
        byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(body ?? string.Empty));
        return Convert.ToHexString(hash).ToLowerInvariant();
    }

    public static string SignRequest(string secret, string method, string path,
                                     string body, string timestamp, string nonce)
    {
        string bodyHash  = ComputeBodyHash(body);
        string canonical = string.Join("\n", method, path, bodyHash, timestamp, nonce);
        using var hmac   = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
        byte[] signature = hmac.ComputeHash(Encoding.UTF8.GetBytes(canonical));
        return Convert.ToBase64String(signature);
    }

    public static void Main()
    {
        string sig = SignRequest("bridge-docs-example-secret-DO-NOT-USE", "GET",
            "/v1/stock/WPI-CHOC-2KG", "", "1716123456",
            "d6f5e4c3-1a2b-4c3d-8e9f-0a1b2c3d4e5f");
        Console.WriteLine(sig); // rve5pdeYT2mMlDyuj14q81YCndcI4zc6snPhn622NAg=
    }
}

Verifying your signer

Run any snippet above with the dummy secret and the GET-vector inputs; it must print rve5pdeYT2mMlDyuj14q81YCndcI4zc6snPhn622NAg=. Then swap in your own issued secret and the real request fields. If the signature matches the vector but the gateway returns 401, check the authentication rules: the timestamp must be within ±5 minutes, the nonce must be unused (10-minute replay window), and the path must exclude the query string.

Drift-guard coverage. The Node snippet on this page is extracted and executed against the published vectors in CI (tests/unit/signing-recipes.test.js), so it can never drift from the gateway. The Python, PHP and C# snippets are verified against the same published vectors (their output is the vector signature, shown inline), but a runtime-matrix CI job that executes them in their own runtimes is a tracked follow-up (those runtimes aren't available in the Node/jest test environment).

Try it from your terminal — v1 Phase 1 ✓ Live

A zero-dependency Node script that signs and sends real Bridge requests — the fastest way to confirm your HMAC signing works end-to-end. Download it, set a few environment variables, and run.

Download: bridge-api-tester.mjs

Pure Node ≥ 20 (built-in fetch + node:crypto), no npm install. It uses the exact canonical signing recipe from the signing vectors and reference signers, so if it works, your own signer should too.

Configure

All configuration is via environment variables — the secret is never passed on the command line and is never printed:

Variable Purpose
BRIDGE_BASE_URL Gateway base URL — see Environments (https://dev-api.occastore.com for staging integration; https://api.occastore.com after go-live)
BRIDGE_CLIENT_ID Your issued Client ID (required to sign)
BRIDGE_SECRET Your issued HMAC secret — the signing key (required to sign)
BRIDGE_SKU Line SKU for --create (default EXAMPLE-SKU)
BRIDGE_PRICE Unit price in integer pence (default 1000)
BRIDGE_REFERENCE Order reference, ≤ 60 chars [A-Za-z0-9._-] (default EXAMPLE-<time>)

Run

--check (default) — prove your signing, create nothing:

node bridge-api-tester.mjs

With no BRIDGE_BASE_URL, this runs only the offline signing self-test: it reproduces the published signing vectors locally (no credentials, no network). If that passes, your crypto is correct. Set BRIDGE_BASE_URL to also probe GET /status, and add BRIDGE_CLIENT_ID + BRIDGE_SECRET for a non-gating HMAC auth probe (any non-401 proves your signature is accepted).

--create — make a real authenticated order:

BRIDGE_BASE_URL=https://dev-api.occastore.com \
BRIDGE_CLIENT_ID=your_client_id \
BRIDGE_SECRET=your_secret \
node bridge-api-tester.mjs --create

On 201 it prints the bridge_order_id. A non-201 prints the reason — look it up on the Error codes page for the remediation.

--cancel — cancel that order:

node bridge-api-tester.mjs --cancel <bridge_order_id>

Exit codes: 0 ok · 1 a check/op failed · 2 misconfiguration.

For full step-by-step troubleshooting (stock, catalogue, pricing, optional create, and a SHARE WITH OCCASTORE report with request_id values), use the Partner diagnostic.

How it signs

The tester builds the canonical string and signs it exactly as documented:

X-Bridge-Signature = base64( HMAC_SHA256( secret, METHOD\nPATH\nSHA256_HEX(body)\nTIMESTAMP\nNONCE ) )

It sends X-Bridge-Client-Id, X-Bridge-Timestamp, X-Bridge-Nonce, and X-Bridge-Signature on every call (plus an Idempotency-Key on writes). If a real call returns 401, work through the canonical recipe on the signing vectors page — sign the exact bytes you send, keep the timestamp within ±5 minutes, and use a fresh nonce per request.

Partner diagnostic — v1 Phase 1 ✓ Live

Step-by-step troubleshooting for integration issues. Run it from your own network with your credentials; it records connection errors, HTTP status, latency, and X-Request-Id per call so OccaStore support can look up the exact server-side trace.

Download (both files — same folder):

Pure Node ≥ 20, no npm install. For a lighter sign-and-send tool see Terminal tester.

When to use this

  • Create order (POST /v1/orders) returns an unexpected status
  • Intermittent 401 / 403 / 5xx and you need a reproducible trace
  • You want to confirm stock, catalogue, and pricing paths before attempting a write
  • OccaStore support asked for request_id values from failed calls

The default run is read-only (no order created). Add --create only when you intend to place a real order on the environment you target (staging uses the live fulfilment-system tenant).

Configure

All configuration is via environment variables. The secret is never printed.

Variable Purpose
BRIDGE_BASE_URL Gateway base URL — see Environments (default https://dev-api.occastore.com; use https://api.occastore.com with production credentials after go-live)
BRIDGE_CLIENT_ID Your issued Client ID — identifies your account in the report
BRIDGE_SECRET Your issued HMAC secret
BRIDGE_SKU SKU to probe on read paths and on --create (default EXAMPLE-SKU)
BRIDGE_PRICE unit_price_pence for --create (default 1000)
BRIDGE_REFERENCE Order reference for --create (auto-generated if unset)
BRIDGE_LABEL Optional friendly name in the report (e.g. your company name)

Run

Place both .mjs files in the same directory, then:

Read-only diagnostic (safe — no order):

node partner-diagnostic.mjs

Save a JSON report (send this file or its contents to support):

node partner-diagnostic.mjs --json bridge-diag-report.json

Include create-order (real write — supervised):

node partner-diagnostic.mjs --create --json bridge-diag-create.json

Offline signing only (no credentials, no network):

node partner-diagnostic.mjs --self-test

Exit codes: 0 all critical steps passed · 1 a step failed · 2 misconfiguration.

What it checks

Step Call Pass signal
Signing offline vectors Local HMAC matches signing vectors
Health GET /status status: ok, version + DB/Redis
Auth enforce bad signature → GET /v1/stock/:sku 401 on invalid signature
Stock GET /v1/stock/:sku 200 or documented 404
Catalogue GET /v1/catalogue/:sku 200 (required before create)
Pricing GET /v1/pricing/:sku 200 or documented 404
Create POST /v1/orders Only with --create; expect 201

Reporting results to OccaStore

The script prints a SHARE WITH OCCASTORE block at the end. Copy that section into your support ticket. Include:

  • client_id (your Client ID)
  • base_url you tested against
  • Every request_id from failed steps

OccaStore correlates request_id with server request_log and fulfilment-system audit rows.

Common create-order failures

Switch on the reason field in the JSON body. Full list: Error codes.

HTTP reason Usual cause
401 bad_signature Wrong secret, body bytes changed after signing, clock skew
401 unknown_client Wrong Client ID
400 missing_idempotency_key Idempotency-Key header missing on POST
403 scope_disabled orders.write not granted
403 account_suspended Account disabled
404 sku_not_found SKU not in your catalogue cache
422 insufficient_stock Quantity exceeds available
409 reference_conflict reference already used
503 channel_not_ready Sales channel not yet verified for your account

For 401 issues, work through Authentication troubleshooting first.

Cloudflare / scripted clients

Automated HTTP clients hitting the public hostname may receive a Cloudflare challenge page (HTML instead of JSON). The diagnostic flags this explicitly. If you see it, retry from an allowed network or ask OccaStore to run the same diagnostic from the server-side loopback URL.

Postman collection — v1 Phase 1 ✓ Live

Import this collection and every request is automatically signed for you — a collection-level pre-request script builds the canonical string and computes the X-Bridge-Signature on every send. Set three variables, hit Send, done.

Download: occastore-bridge.postman_collection.json

The pre-request script uses the exact canonical recipe from the signing vectors and reference signers, so a request that sends from Postman will sign identically to your own client.

Import & go

  1. In Postman: Import → drop in the downloaded occastore-bridge.postman_collection.json.
  2. Open the collection → Variables tab and set:
    • baseUrl — the gateway base URL — see Environments:
      • https://dev-api.occastore.com — staging / Design Preview integration
      • https://api.occastore.com — production (after go-live; use production credentials)
    • bridgeClientId — your issued Client ID
    • bridgeSecret — your issued HMAC secret (stored as a secret-type variable; never leaves Postman, never committed)
  3. Open any request and click Send. The pre-request script signs it automatically — you do not touch the X-Bridge-* headers.

The sku and bridgeOrderId variables fill in the path placeholders for the stock/catalogue/pricing reads and the get/cancel-order calls.

What the pre-request script does

On every send it:

  1. resolves {{variables}} in the path and body (so it signs the exact bytes Postman will send),
  2. computes bodyHash = SHA-256(body) (hex; empty body → e3b0c4…b855),
  3. builds the canonical string METHOD\nPATH\nbodyHash\nTIMESTAMP\nNONCE with a fresh unix-seconds timestamp and a fresh UUID nonce,
  4. sets X-Bridge-Signature = base64( HMAC_SHA256( bridgeSecret, canonical ) ),

then the request's headers pull X-Bridge-Client-Id, X-Bridge-Timestamp, X-Bridge-Nonce, and X-Bridge-Signature from the variables it just set. Writes also send a fresh Idempotency-Key. If a call returns 401, re-check the signing vectors: the timestamp must be within ±5 minutes and the nonce must be unused (10-minute replay window).

The signing logic in this collection is verified in CI against the published signing vectors (tests/unit/postman-collection.test.js) — the Postman signer cannot drift from the gateway.

Error code reference — v1

Every error response shares one flat shape (never a nested object):

{ "error": "<http-class word>", "reason": "<stable_snake_code>" }

Some errors add a sibling field (field, conflicting_order_id). Switch on reason — it is the stable contract identifier; error is just the HTTP-status word and is not guaranteed consistent across surfaces for the same status. Every response carries an X-Request-Id header (quote it in support requests). The fulfilment system's internals are never echoed in error bodies. Money is always integer pence. The upstream_* reasons (upstream_rejected / upstream_unavailable / upstream_circuit_open / upstream_error) all refer to the fulfilment system — the backend Bridge writes your orders to.

This reference covers every error the live (Phase 1 ✓ Live) endpoints can return. Not every endpoint can return every code — the per-endpoint docs list the subset each one emits. The Remediation column is your next action; see Retry guidance below for the transient-vs-terminal rule.


Authentication — 401 (see authentication.md)

The seven HMAC signing stages, in the order they are checked. All are { "error": "unauthorized", "reason": … }:

HTTP reason When Remediation
401 missing_headers One or more X-Bridge-* headers absent. Send all four X-Bridge-* headers (and Idempotency-Key on writes).
401 timestamp_skew Timestamp malformed, or outside the ±5-minute window. Send current unix-seconds; sync your clock (NTP).
401 nonce_replay Nonce reused within the 10-minute replay window. Generate a fresh UUID nonce for every request.
401 unknown_client Client ID not recognised. Check X-Bridge-Client-Id; confirm your account is provisioned.
401 revoked_credential Credential revoked. Obtain a new credential from your account contact.
401 expired_credential Credential past its expiry. Rotate to a current credential.
401 bad_signature Signature does not match the canonical string. Re-check the canonical recipe (signing vectors); sign the exact bytes you send.

Account status — 403 / 401

Checked after the signature is valid:

HTTP error reason When Remediation
403 forbidden account_suspended Account suspended, or onboarding not yet at go-live. Contact your account manager; do not retry until resolved.
401 unauthorized account_revoked Account revoked. Contact your account manager. Terminal.
403 forbidden account_pending Account provisioned but not yet activated. Wait for activation confirmation, then retry.

Authorization (scope) — 403 / 503

HTTP error reason When Remediation
403 forbidden scope_disabled The required scope is not granted/enabled for your account. Request the scope from your account contact; do not retry until granted.
503 service_unavailable scope_check_unavailable The scope lookup failed and the gateway fails closed. Transient — back off and retry with the same Idempotency-Key.

Request validation — 400

HTTP error reason When Remediation
400 bad_request invalid_request Bad partner input — one reason across all v1 endpoints. On order endpoints: malformed/unknown/missing body field (field names the offending path; unknown top-level / address / item fields are rejected — strict, no field smuggling), or a non-UUID path id. On read endpoints (/v1/stock|catalogue|pricing/:sku): the :sku path segment fails the allowed pattern ^[A-Za-z0-9_-]{1,64}$ (response carries field: "sku"). Fix the named field (or the :sku) and resend. Terminal — do not retry unchanged.
400 bad_request missing_idempotency_key A write endpoint was called without the Idempotency-Key header. Add an Idempotency-Key (a fresh UUID) and resend.
400 bad_request idempotency_key_conflict The supplied Idempotency-Key is already in use by a different account. Use a UUID you generated yourself; resend.

Conflict — 409

HTTP error reason When Remediation
409 conflict reference_conflict The order reference has already been used for your account (body adds conflicting_order_id). One reference = one order, forever, per customer. Use a new unique reference, or treat conflicting_order_id as the existing order. Do not retry the same reference.
409 conflict idempotency_conflict The same Idempotency-Key was reused with a different request body. Use a fresh Idempotency-Key for the new body. Terminal.

Business rules — 422

HTTP error reason When Remediation
422 unprocessable invalid_sku A SKU is not in your catalogue. Remove/correct the SKU (the offending SKU follows the token); resend.
422 unprocessable order_item_unmapped One or more line SKUs are in your catalogue but cannot be mapped to a stock item in OccaStore's fulfilment system. The order was not created; the offending SKU(s) are named after the token (order_item_unmapped:<sku>). Contact your account contact to map the named SKU(s); do not retry until mapped.
422 unprocessable invalid_country The ship_to.country could not be resolved to a supported country. The order was not created. Send an ISO-3166 alpha-2 code (e.g. GB); resend.
422 unprocessable reference_too_long The order reference exceeds the 60-character limit. The order was not created. Shorten reference to ≤ 60 chars; resend.
422 unprocessable reference_invalid The order reference contains characters outside the allowed set [A-Za-z0-9._-]. The order was not created. Strip disallowed characters; resend.
422 unprocessable insufficient_stock Strict stock check enabled and stock is short (a cache miss is soft-allowed). Reduce quantity or wait for restock; do not retry the same quantity.
422 unprocessable no_shipping_service_configured The requested shipping service could not be resolved and you have no default. Send a valid shipping mapping, or ask your account contact to set a default.
422 unprocessable shipping_service_id_unresolved The shipping service on the order isn't mapped on your account yet. The order was not created; the service name follows the token (shipping_service_id_unresolved:<name>). Send the X-Request-Id to your account contact; do not retry until resolved.
422 unprocessable invalid_order_payload The order couldn't be set up — usually a missing account setting (for example, no stock location enabled). The order was not created. Send the X-Request-Id to your account contact; do not retry until resolved.
422 unprocessable price_unavailable A line item has neither a supplied price nor a contract price (price_unavailable:<sku> on the wire). Body may include summary, sku. Send unit_price_pence on the line, or have a contract price configured.
422 unprocessable price_variance Price check Hold is enabled and a sent unit_price_pence breaches tolerance vs the configured reference (price_variance:<sku> on the wire). Body includes summary (plain English), sku, sent_pence, expected_pence. Read summary — contract price does not match what you sent. Correct the line price or contact your account contact.
422 unprocessable order_dispatched Cancel attempted on an order already dispatched/delivered. Too late to cancel — handle via returns. Terminal.
422 unprocessable order_not_yet_synced Cancel attempted before the order reached the fulfilment system. Retry the cancel shortly (reconciliation in progress).
422 unprocessable order_not_cancellable Order is in an unexpected, non-cancellable state. Inspect the order; contact support with the X-Request-Id if unexpected.
422 unprocessable upstream_rejected The fulfilment system rejected the operation (no upstream detail echoed). Terminal for this request — review the order/cancel; contact support with the X-Request-Id.

Some 422 reasons for POST /v1/orders carry an inline detail suffix on the wire (e.g. invalid_sku:<sku>, price_unavailable:<sku>, price_variance:<sku>, order_item_unmapped:<sku>, shipping_service_id_unresolved:<name>). Match on the stable token before the :.

price_variance / price_unavailable detail fields (additive, v0.1.307+): the body may also include summary (human-readable), sku, and for price_variance also sent_pence and expected_pence (integer pence). Example:

{
  "error": "unprocessable",
  "reason": "price_variance:WPI-CHOC-2KG",
  "sku": "WPI-CHOC-2KG",
  "sent_pence": 5499,
  "expected_pence": 4999,
  "summary": "Contract price does not match for WPI-CHOC-2KG: you sent £54.99 but the contract price is £49.99."
}

Not found — 404

All are { "error": "not_found", "reason": … }. Existence is never leaked: a resource that belongs to another account is indistinguishable from one that does not exist.

HTTP reason When Remediation
404 not_found No such order for your account (order get/cancel). Check the bridge_order_id; it must be one your account created.
404 sku_not_found Single-SKU stock/catalogue lookup: the SKU is not visible to you or has no data. Confirm the SKU is in your enabled catalogue/locations.
404 sku_not_priced Single-SKU pricing lookup: no price is available for the SKU. Have a price configured for the SKU; or send price on the order line.
404 stock_locations_unconfigured Your account has no enabled stock locations configured. Ask your account contact to enable a stock location.
404 catalogue_categories_unconfigured Your account has no enabled catalogue categories configured. Ask your account contact to enable a catalogue category.

The bulk list endpoints (GET /v1/stock, /v1/catalogue, /v1/pricing) do not return sku_not_found / sku_not_priced — an empty or filtered result is a normal 200 with items: [].

Rate limiting — 429

HTTP error reason When Remediation
429 too_many_requests rate_limited Per-(account, scope) quota exceeded. Honour Retry-After, then retry with the same Idempotency-Key on writes. See Rate limits.

Service & upstream — 502 / 503

HTTP error reason When Remediation
503 service_unavailable channel_not_ready Your channel is not yet verified. Wait for channel verification; do not retry until verified.
503 service_unavailable upstream_circuit_open Fulfilment-system circuit breaker open. Honour Retry-After: 60, then retry with the same Idempotency-Key.
502 bad_gateway upstream_unavailable The fulfilment system failed after the retry policy. On POST /v1/orders the order may or may not have reached the fulfilment system. Retry the same Idempotency-Key (deduped) — never a new key. See below.
503 service_unavailable upstream_error An unexpected internal error. Transient — retry with the same Idempotency-Key.
503 service_unavailable writes_paused Bridge globally paused for writes by the operator (planned maintenance / incident). Read endpoints continue to serve. Honour Retry-After: 60; retry the write with the same Idempotency-Key.
503 service_unavailable draining Bridge draining for restart/maintenance — new requests rejected, in-flight allowed to complete. Honour Retry-After: 60, then retry with the same Idempotency-Key.
503 service_unavailable kill_switch_check_unavailable Internal kill-switch state read failed; the gateway fails closed. Transient — retry with the same Idempotency-Key.

Retry guidance

  • Transient — retry (429 / 502 / 503): back off and retry with the same Idempotency-Key (safe; no duplicate side effect). Honour any Retry-After.
  • Terminal — do not retry unchanged (400 / 409 / 422, and an authentication/credential 401): fix the request, signing, or credential first. Retrying the identical request will return the identical error.
  • The 502 upstream_unavailable rule (writes). On POST /v1/orders a 502 means the order may already exist in the fulfilment system. Retry the SAME Idempotency-Key — never issue a new key, never retry blind. Same key + same body within the dedup window replays the original outcome (returning the already-created order rather than creating a duplicate). A new key would create a second order. The same rule applies to a 502/503 on cancel.

Internal / defence-in-depth codes

These exist in the gateway but are not part of the normal partner contract — a correctly-authenticated integration should not see them in routine operation. Listed for completeness:

HTTP error reason Note
500 server_error idempotency_storage_failed Idempotency store read failed (infrastructure fault). Rare; retry.
401 unauthorized missing_auth_context Defence-in-depth guard behind authentication; not reachable with a valid signature.
503 service_unavailable kill_switch_active Defensive path for a mis-configured route switch; not partner-triggerable in normal config.
401 unauthorized admin_auth_required Admin-only diagnostics surface; not part of the partner API.

Conventions — v1 Phase 1 ✓ Live

The cross-cutting rules that apply across every /v1 endpoint, documented once here. The endpoint pages link back to this page rather than restating them.

Money — integer pence

All monetary amounts are integer pence (e.g. unit_price_pence). Never send or expect a decimal, a float, or a currency symbol: £49.99 is 4999. This avoids floating-point rounding error end to end.

Timestamps — ISO 8601, UTC

Response timestamps (e.g. as_of) are ISO 8601 in UTC (e.g. 2026-06-07T10:30:00Z). Treat them as UTC.

Don't confuse this with the X-Bridge-Timestamp auth header, which is unix seconds (not ISO) and must be within ±5 minutes — see Authentication.

Country — ISO 3166-1 alpha-2

ship_to.country must be an ISO 3166-1 alpha-2 code — two letters, e.g. GB, IE, FR. A value that cannot be resolved is rejected with 422 invalid_country (see Error codes).

Order reference

Your per-order reference must be:

  • ≤ 60 characters (longer → 422 reference_too_long);
  • composed only of the characters [A-Za-z0-9._-] — letters, digits, dot, underscore, hyphen (anything else → 422 reference_invalid);
  • unique per account, forever — one reference maps to exactly one order; a reuse returns 409 reference_conflict with the existing conflicting_order_id.

Idempotency — Idempotency-Key

Every write (POST /v1/orders, cancel) requires an Idempotency-Key header (a fresh UUID you generate):

  • Same key + same body within the dedup window replays the original outcome — it returns the order you already created rather than creating a second one. This is your retry-safety handle.
  • Same key + a different body409 idempotency_conflict.
  • A key already used by a different account → 400 idempotency_key_conflict.
  • On a 502 upstream_unavailable, retry the SAME key — never a new key: the order may already exist, and the same key is deduped. A new key would create a duplicate. See Error codes → Retry guidance and Rate limits.

Nonce & replay

X-Bridge-Nonce is a fresh UUID per request; a nonce is rejected if reused within the 10-minute replay window. Full signing rules: Authentication.

Rate limits — v1 Phase 1 ✓ Live

Every authenticated /v1 request passes three independent rate limiters, AND-composed — a request must satisfy all of them, and any one rejection returns 429. Limits are enforced per (account, scope): your stock.read traffic and your orders.write traffic are metered separately.

The default limits

Limiter Default Window Purpose
Burst 20 requests 1 second short-spike cap
Per-minute 60 requests 60 seconds sustained rate
Per-day 10 000 requests 24 hours daily ceiling

These are the out-of-the-box defaults. Your account may be provisioned with higher (or lower) limits — the response headers below always tell you the limit actually in force, so read them rather than hard-coding the defaults.

Response headers

Every rate-limited response (success and 429) carries, sourced from the most-constrained limiter at that moment:

Header Meaning
X-RateLimit-Limit The limit (points) of the tightest applicable limiter.
X-RateLimit-Remaining Requests remaining in the current window (0 on a 429).
X-RateLimit-Reset Unix-seconds time when that window refills.
Retry-After (429 only) seconds to wait before retrying.

The 429 response

{ "error": "too_many_requests", "reason": "rate_limited" }

Honour Retry-After: back off for that many seconds, then retry. A 429 is transient — the same request will succeed once the window refills, so retry with the same Idempotency-Key on writes (it is deduped; see Error codes → Retry guidance).

Practical guidance

  • Spread your requests. The burst limiter allows a short spike (20/s) but the per-minute limiter (60/min) is the sustained ceiling — a steady ~1 req/s is comfortably within both.
  • Reads and writes count separately (per scope), so a burst of stock reads does not consume your order-creation budget.
  • Trust the headers, not the table. If your account has custom quotas, the numbers above are not your numbers — X-RateLimit-Limit is authoritative.

Availability note (honest): rate limiting is DoS protection and fails open — if the limiter's backing store is briefly unavailable, requests are allowed through and the X-RateLimit-* headers are omitted for those responses. Authentication and authorization never fail open; only this layer does. So an occasional response with no X-RateLimit-* headers is expected and benign.

Versioning & deprecation — v1 Phase 1 ✓ Live

How the OccaStore Bridge contract evolves, and what you can rely on.

The version in the path

The major version is in the URL: /v1. A breaking change to the v1 contract after General Availability (GA) would ship under a new major (/v2) — a v1 integration keeps working unchanged. The current status banner is "v1 Design Preview" (see below).

Design Preview vs GA

Until Phase 7 GA, these docs are labelled "v1 Design Preview" and are noindex. During the preview window the contract may still change — including in breaking ways — as the surface is finalised with early integrators. Every such change is announced in the changelog. Once v1 reaches GA, the preview reservation ends and the compatibility promise below takes full effect.

What counts as a breaking vs an additive change

Additive (safe; may ship any time, announced in the changelog):

  • a new endpoint, a new optional request field, a new response field;
  • a new reason value for an existing error class;
  • a new webhook event type (when webhooks ship).

Write your client to ignore unknown fields and to treat an unrecognised reason as its HTTP status class. Then additive changes never break you.

Breaking (post-GA → new major; pre-GA → announced in the changelog):

  • removing or renaming a request/response field;
  • removing or renaming an error reason token;
  • changing an endpoint path, method, or success status code;
  • tightening validation in a way that rejects previously-accepted input.

Deprecation policy (post-GA)

When a contract surface is deprecated after GA, we will: (1) announce it in the changelog with the replacement and a removal date; (2) keep the old surface working through the stated notice period; and (3) where practical, emit both old and new in parallel during that window. The notice period is set per change and stated in the changelog entry.

Worked example — the upstream_* error rename (Design Preview)

The first applied example of this policy is a pre-GA breaking change made under the Design Preview reservation: two 422 error reason tokens that named an internal backend were retired in favour of vendor-neutral tokens —

  • the order-rejection reason (a 422 on create/cancel) is now upstream_rejected;
  • the cancel-before-propagation reason (a 422 on cancel) is now order_not_yet_synced.

This was a clean cut (no alias) because the change shipped during Design Preview, before any integration was live on those paths — exactly the window the preview banner reserves. It is recorded in the changelog. After GA, the same change would instead carry a deprecation notice period.

Backlog (logged, not yet decided): the response field pk_order_id may be renamed to a vendor-neutral name (e.g. fulfilment_order_id) before GA. It is the highest-blast-radius rename (it appears on every successful order response), so it is a deliberate, separately-announced pre-GA decision — not folded into the error-token rename above.

GET /v1/catalogue — product catalogue Phase 1 ✓ Live

Returns the catalogue record for a SKU that is visible to your account, served from Bridge's cache of your product catalogue. Visibility is gated by your enabled stock locations and catalogue categories.

  • Scope: catalogue.read
  • Auth: HMAC (see authentication.md)
  • Idempotency: n/a (read)
  • Kill switches: reads honour drain_mode (503 draining); not affected by the writes pause.

There are two forms: a single-SKU lookup and a paginated bulk list.

GET /v1/catalogue/:sku — single SKU

  • Path param: sku — must match ^[A-Za-z0-9_-]{1,64}$.

Request

GET /v1/catalogue/WPI-CHOC-2KG HTTP/1.1
Host: dev-api.occastore.com
# + the four X-Bridge-* auth headers (see Authentication)

Response — 200 OK

X-Cache: hit|miss header + body:

{
  "sku": "WPI-CHOC-2KG",
  "title": "Whey Protein Isolate — Chocolate 2kg",
  "barcode": "5012345678900",
  "category_id": "b2c3d4e5-6f70-4a81-9b2c-3d4e5f60718a",
  "retail_price_gbp": 54.99,
  "weight_kg": 2.05,
  "dimensions_cm": { "height": 24, "width": 16, "depth": 16 },
  "tax_rate": 20,
  "images": [
    { "source": "https://cdn.example/wpi-choc-2kg-thumb.jpg", "full_source": "https://cdn.example/wpi-choc-2kg.jpg", "is_main": true, "sort_order": 0 }
  ],
  "attributes": { "flavour": "chocolate" },
  "stock_item_id": "a1b2c3d4-...",
  "as_of": "2026-05-19T14:00:00.000Z"
}
Field Type Notes
sku string Echo of the requested SKU.
title string | null Product title.
barcode string | null Primary barcode.
category_id string | null Opaque category identifier (UUID) for the SKU — match it exactly; do not parse. null when the SKU has no category.
retail_price_gbp number | null Retail reference price in GBP. This is not your contract price — use pricing.md (price_pence) for the price you are charged.
weight_kg number | null Unit weight in kilograms.
dimensions_cm object { height, width, depth } in centimetres; each value a number or null.
tax_rate number | null VAT rate (percent).
images array Objects { source, full_source, is_main, sort_order }; [] when none.
attributes object Map of attribute name → value, filtered to the attributes enabled for your account. {} when none are enabled.
stock_item_id string | null Stock-item reference for the SKU; null where not yet set.
as_of string (ISO-8601) Timestamp of the cached record.

Most fields derive from your product catalogue and may be null where not populated in your data.

GET /v1/catalogue — bulk list

Paginated. Query params and the response envelope follow the shared Read endpoints & pagination contract in the API overview. Each items[] element is the same object as the single-SKU response above.

{
  "items": [ { "sku": "WPI-CHOC-2KG", "title": "Whey Protein Isolate — Chocolate 2kg", "barcode": "5012345678900", "category_id": "b2c3d4e5-6f70-4a81-9b2c-3d4e5f60718a", "retail_price_gbp": 54.99, "weight_kg": 2.05, "dimensions_cm": { "height": 24, "width": 16, "depth": 16 }, "tax_rate": 20, "images": [], "attributes": { "flavour": "chocolate" }, "stock_item_id": "a1b2c3d4-...", "as_of": "2026-05-19T14:00:00.000Z" } ],
  "page": 1,
  "per_page": 100,
  "total_pages": 5,
  "total_items": 432,
  "has_more": true
}

SKUs not visible to your account (location/category not enabled) are silently omitted — the bulk endpoint never returns sku_not_found. An empty result is a normal 200 with items: [].

Errors

HTTP reason Meaning
400 invalid_request :sku does not match ^[A-Za-z0-9_-]{1,64}$ (single-SKU only); response carries field: "sku".
401 unauthorized HMAC / credential failure — see authentication.md.
403 scope_disabled catalogue.read not granted.
404 stock_locations_unconfigured Your account has no enabled stock locations.
404 catalogue_categories_unconfigured Your account has no enabled catalogue categories.
404 sku_not_found The SKU is not visible to you or has no cached record (single-SKU only).
429 rate_limited Quota exceeded.
503 draining Bridge draining for restart/maintenance — Retry-After: 60.
503 upstream_error Unexpected internal error — transient, retry.

See error-codes.md for the full reference.

GET /v1/stock — stock levels Phase 1 ✓ Live

Returns cached, eventually-consistent stock levels aggregated across your enabled stock locations. Bridge serves these from a cache the prefetch worker refreshes — see refresh_cadence_seconds in the response for the cadence.

  • Scope: stock.read
  • Auth: HMAC (see authentication.md)
  • Idempotency: n/a (read)
  • Kill switches: reads honour drain_mode (503 draining); they are not affected by the writes pause.

There are two forms: a single-SKU lookup and a paginated bulk list.

GET /v1/stock/:sku — single SKU

  • Path param: sku — must match ^[A-Za-z0-9_-]{1,64}$.

Request

GET /v1/stock/WPI-CHOC-2KG HTTP/1.1
Host: dev-api.occastore.com
# + the four X-Bridge-* auth headers (see Authentication)

Response — 200 OK

X-Cache: hit|miss header + body:

{
  "sku": "WPI-CHOC-2KG",
  "available": 142,
  "reserved": 8,
  "on_order": 50,
  "due_in": null,
  "earliest_expiry": null,
  "as_of": "2026-05-19T14:00:00.000Z",
  "refresh_cadence_seconds": 300
}
Field Type Notes
sku string Echo of the requested SKU.
available integer Sum of available units across your enabled locations.
reserved integer Sum of reserved units.
on_order integer Sum of on-order units.
due_in null Reserved — always null in v1. Not yet populated; treat as nullable, do not depend on a value.
earliest_expiry string (YYYY-MM-DD) | null Earliest batch expiry across your enabled locations, from the stock-prefetch cache (Query Data script 8 EarliestExpiry and/or Linnworks batch inventory). null when the SKU is not batch-tracked or expiry is not yet in cache — run stock-prefetch (and ensure catalogue cache has stock_item_id for batch lookup).
as_of string (ISO-8601) Timestamp of the most recent per-location data in the aggregate.
refresh_cadence_seconds integer How often the underlying cache is refreshed (300).

GET /v1/stock — bulk list

Paginated. Query params and the response envelope follow the shared Read endpoints & pagination contract in the API overview. Each items[] element is the same object as the single-SKU response above.

{
  "items": [ { "sku": "WPI-CHOC-2KG", "available": 142, "reserved": 8, "on_order": 50, "due_in": null, "earliest_expiry": null, "as_of": "2026-05-19T14:00:00.000Z", "refresh_cadence_seconds": 300 } ],
  "page": 1,
  "per_page": 100,
  "total_pages": 3,
  "total_items": 247,
  "has_more": true
}

An empty or fully-paged result is a normal 200 with items: [] — the bulk endpoint never returns sku_not_found.

Errors

HTTP reason Meaning
400 invalid_request :sku does not match ^[A-Za-z0-9_-]{1,64}$ (single-SKU only); response carries field: "sku".
401 unauthorized HMAC / credential failure — see authentication.md.
403 scope_disabled stock.read not granted.
404 stock_locations_unconfigured Your account has no enabled stock locations.
404 sku_not_found The SKU has no cached data for your locations (single-SKU only).
429 rate_limited Quota exceeded.
503 draining Bridge draining for restart/maintenance — Retry-After: 60.
503 upstream_error Unexpected internal error — transient, retry.

See error-codes.md for the full reference.

GET /v1/pricing — your prices Phase 1 ✓ Live

Returns the price you are charged for a SKU — a per-SKU override if one is configured for your account, otherwise your channel baseline. There is no retail-price fallback: a SKU with no override and no channel price returns 404 sku_not_priced.

  • Scope: pricing.read
  • Auth: HMAC (see authentication.md)
  • Idempotency: n/a (read)
  • Kill switches: reads honour drain_mode (503 draining); not affected by the writes pause.

There are two forms: a single-SKU lookup and a paginated bulk list.

GET /v1/pricing/:sku — single SKU

  • Path param: sku — must match ^[A-Za-z0-9_-]{1,64}$.

Request

GET /v1/pricing/WPI-CHOC-2KG HTTP/1.1
Host: dev-api.occastore.com
# + the four X-Bridge-* auth headers (see Authentication)

Response — 200 OK

X-Cache: hit|miss header + body:

{
  "sku": "WPI-CHOC-2KG",
  "price_pence": 4999,
  "currency": "GBP",
  "source": "channel",
  "as_of": "2026-05-19T14:00:00.000Z",
  "refresh_cadence_seconds": 3600
}
Field Type Notes
sku string Echo of the requested SKU.
price_pence integer Your unit price in integer pence (never a float).
currency string Always "GBP" in v1.
source string Where the price came from — "override" (a per-SKU override on your account) or "channel" (your channel baseline). These are the only two values.
as_of string (ISO-8601) Timestamp of the price.
refresh_cadence_seconds integer How often the underlying channel price cache is refreshed (3600).

GET /v1/pricing — bulk list

Paginated. Query params and the response envelope follow the shared Read endpoints & pagination contract in the API overview. Each items[] element is the same object as the single-SKU response above (an override, where present, takes precedence over the channel price).

{
  "items": [ { "sku": "WPI-CHOC-2KG", "price_pence": 4999, "currency": "GBP", "source": "channel", "as_of": "2026-05-19T14:00:00.000Z", "refresh_cadence_seconds": 3600 } ],
  "page": 1,
  "per_page": 100,
  "total_pages": 4,
  "total_items": 318,
  "has_more": true
}

SKUs not visible or not priced for your account are silently omitted — the bulk endpoint never returns sku_not_priced. An empty result is a normal 200 with items: [].

Errors

HTTP reason Meaning
400 invalid_request :sku does not match ^[A-Za-z0-9_-]{1,64}$ (single-SKU only); response carries field: "sku".
401 unauthorized HMAC / credential failure — see authentication.md.
403 scope_disabled pricing.read not granted.
404 stock_locations_unconfigured Your account has no enabled stock locations.
404 catalogue_categories_unconfigured Your account has no enabled catalogue categories.
404 sku_not_priced No override and no channel price for the SKU (single-SKU only).
429 rate_limited Quota exceeded.
503 draining Bridge draining for restart/maintenance — Retry-After: 60.
503 upstream_error Unexpected internal error — transient, retry.

See error-codes.md for the full reference.

Order lifecycle — v1 Phase 1 ✓ Live

The status you see on GET /v1/orders/:bridge_order_id is Bridge's mirror of the order's state in the fulfilment system, kept eventually-consistent by the reconciliation worker. This page is the state model behind that field and behind the cancel tokens.

Status values

These are the only status values an order can hold:

Status Meaning Phase 1
accepted Recorded in Bridge and accepted for fulfilment — the initial state of every order. ✓ emitted
processing Being picked / packed in the fulfilment system. not emitted yet
dispatched Left the warehouse. ✓ emitted
delivered Delivered to the customer. not emitted yet
cancelled Cancelled — terminal. ✓ emitted

Phase 1 coverage (known limitation). Automatic updates currently resolve accepted → dispatched → cancelled only. processing and delivered are valid states but not emitted yet: the fulfilment system's open API exposes no pick/pack sub-state (so processing can't be distinguished from accepted) and delivery is carrier-tracking (a Phase 3 surface). Treat dispatched as "left the warehouse"; don't wait for processing or delivered to appear.

The lifecycle

  create
    │
    ▼
 accepted ─────► processing ─────► dispatched ─────► delivered
    │                │                   │                 │
    │ cancel         │ cancel            └──────┬──────────┘
    └────────┬───────┘                          │
             ▼                          too late to cancel
         cancelled                      (422 order_dispatched)
   (terminal — a repeat cancel
    is an idempotent 200)

An order is cancellable while it is accepted or processing. Once it is dispatched (or delivered) it is too late to cancel — handle that via returns. cancelled is terminal: cancelling an already-cancelled order returns an idempotent 200.

Cancellation windows

What a cancel attempt returns, mapped onto the state above (see Cancel order and Error codes):

Cancel result Order state What to do
200 (status: cancelled) was accepted / processing Done — it's cancelled.
422 order_not_yet_synced accepted, but not yet propagated to the fulfilment system (its pk_order_id is still null) Retry the cancel shortly — reconciliation is in progress.
422 order_dispatched dispatched / delivered Too late to cancel; handle via returns.
422 order_not_cancellable an unexpected, non-cancellable state Inspect; contact support quoting the X-Request-Id.

POST /v1/orders — create an order Phase 1 ✓ Live

Creates an order in the fulfilment system (Source = EDI, your SubSource) and returns the Bridge order record. The cross-cutting rules — integer pence, reference format, ISO country codes, and Idempotency-Key semantics — are in Conventions.

  • Scope: orders.write
  • Auth: HMAC (see README)
  • Idempotency-Key header: required
  • Content-Type: application/json

Request body

Field Type Req Notes
reference string Your PO / order reference — ≤60 chars, [A-Za-z0-9._-] only (else 422 reference_too_long / reference_invalid). One reference = one order, forever, per customer (reuse → 409 reference_conflict).
customer_reference string Your end-customer's reference (free text).
items array ≥1 item.
items[].sku string Must exist in your catalogue.
items[].quantity integer ≥1.
items[].unit_price_pence integer Integer pence. Omit → your contract price is used. If sent and it drifts from contract, it is accepted (warn-logged) — your price is authoritative — unless your account has price check → Hold configured (see Price check below). Neither sent nor contract → 422 price_unavailable.
ship_to object name, address1, town, postcode, country required; company, address2, address3, region, phone, email optional.
bill_to object Same shape as ship_to; defaults to ship_to. Ignored when your account has a centrally managed billing address configured (operator sets this in Bridge admin).
shipping_service string A configured bridge_service_name; omitted → your default. Unresolvable → 422 no_shipping_service_configured.
requested_dispatch_date string (ISO 8601) Defaults to now + 2 days (spec §4[13]).

Unknown top-level / address / item fields are rejected (400 invalid_request, strict no-smuggling).

Request

POST /v1/orders HTTP/1.1
Host: dev-api.occastore.com
Content-Type: application/json
Idempotency-Key: 7c9f0e2a-3b1d-4a6e-9f8c-1a2b3c4d5e6f
# + the four X-Bridge-* auth headers (see Authentication)

{
  "reference": "PO-EXAMPLE-001",
  "customer_reference": "WEB-EXAMPLE-44821",
  "items": [{ "sku": "WPI-CHOC-2KG", "quantity": 2, "unit_price_pence": 4999 }],
  "ship_to": {
    "name": "Jane Example", "company": "Example Trading Ltd",
    "address1": "1 Example Way", "town": "Leeds",
    "postcode": "LS1 1AA", "country": "GB", "email": "jane@example.test"
  },
  "shipping_service": "next_day_dpd"
}

Response — 201 Created

Bridge-Order-Id header + body:

{
  "bridge_order_id": "3f1a8c2e-9d44-4e21-8a77-0c2e3f1a8c2e",
  "reference": "PO-EXAMPLE-001",
  "status": "accepted",
  "pk_order_id": "123456",
  "items": [{ "sku": "WPI-CHOC-2KG", "quantity": 2, "unit_price_pence": 4999 }],
  "shipping_service": "next_day_dpd",
  "created_at": "2026-05-19T14:00:00.000Z"
}

Same Idempotency-Key + same body within 24h replays this exact 201.

Errors (this endpoint)

HTTP reason Meaning
400 invalid_request Body schema / unknown field / missing required (field names it).
400 missing_idempotency_key Idempotency-Key header absent.
400 idempotency_key_conflict The Idempotency-Key is already in use by a different account.
401 unauthorized HMAC / credential failure — see authentication.md for the seven granular reasons.
403 scope_disabled orders.write not granted.
403 account_suspended Customer not active (suspended, or onboarding not yet go-live).
409 reference_conflict reference already used (body includes conflicting_order_id).
409 idempotency_conflict Same key, different body.
422 invalid_sku A SKU is not in your catalogue.
422 order_item_unmapped A line SKU is in your catalogue but not mapped to a stock item in OccaStore's fulfilment system. The order was not created; the offending SKU(s) are named (order_item_unmapped:<sku>).
422 invalid_country ship_to.country could not be resolved to a supported country (use an ISO-3166 alpha-2 code, e.g. GB). The order was not created.
422 reference_too_long reference exceeds 60 characters. The order was not created.
422 reference_invalid reference contains characters outside [A-Za-z0-9._-]. The order was not created.
422 insufficient_stock Strict stock check on + short (cache miss is soft-allowed).
422 no_shipping_service_configured Shipping service unresolvable (none requested and no default).
422 shipping_service_id_unresolved The order's shipping service isn't mapped on your account yet. The order was not created; the service name follows the token (shipping_service_id_unresolved:<name>). Send the X-Request-Id to your account contact.
422 invalid_order_payload The order couldn't be set up — usually a missing account setting. The order was not created. Send the X-Request-Id to your account contact.
422 price_unavailable No sent price and no contract price. Response may include summary and sku.
422 price_variance Price check Hold is enabled and a sent unit_price_pence breaches tolerance against the configured reference price (price_variance:<sku> on the wire). Response includes summary, sku, sent_pence, expected_pence.
422 upstream_rejected The fulfilment system rejected the order (no upstream internals echoed).
429 rate_limited Quota exceeded (Retry-After).
502 upstream_unavailable The fulfilment system failed after retries — idempotency key stays valid, safe to retry.
503 channel_not_ready Channel not yet verified for this customer.
503 upstream_circuit_open Fulfilment-system circuit open — Retry-After: 60.
503 writes_paused Bridge globally paused for writes — Retry-After: 60.
503 draining Bridge draining for restart/maintenance — Retry-After: 60.
503 upstream_error Unexpected internal error — transient, safe to retry with the same key.

Price check (operator-configured)

Most accounts have no price check — contract drift on sent prices is warn-logged and the order is accepted (your sent price is authoritative).

When your account contact enables price check → Hold, Bridge compares each line's sent unit_price_pence against a reference price (contract or tradeit, within a configured tolerance). On breach the order is not created:

HTTP reason (wire) Meaning
422 price_variance:<sku> Sent price outside tolerance vs the reference. Body includes summary (e.g. Contract price does not match…), sent_pence, expected_pence.
422 price_unavailable:<sku> Reference price could not be resolved for that SKU. Body may include summary.

Example price_variance body:

{
  "error": "unprocessable",
  "reason": "price_variance:WPI-CHOC-2KG",
  "sku": "WPI-CHOC-2KG",
  "sent_pence": 5499,
  "expected_pence": 4999,
  "summary": "Contract price does not match for WPI-CHOC-2KG: you sent £54.99 but the contract price is £49.99."
}

Match on the stable token before the : (same pattern as other suffixed 422 reasons). Warn mode never rejects — breaches are operator-logged only.

Static billing (operator-configured)

When a centrally managed billing address is configured for your account, Bridge stamps that address on the fulfilment order and ignores any bill_to you send. ship_to is unchanged.

See error-codes.md. For a guided external test run with request_id capture, use the Partner diagnostic.

GET /v1/orders/:bridge_order_id — order detail Phase 1 ✓ Live

Returns one order from the Bridge mirror (Bridge's authoritative record of what you were told the order contained; kept eventually-consistent with the fulfilment system by the reconciliation worker). Amounts are integer pence and timestamps are ISO 8601 UTC — see Conventions.

  • Scope: orders.read
  • Auth: HMAC (see README)
  • Idempotency: n/a (read)
  • Path param: bridge_order_id — the UUID returned by POST /v1/orders (also the Bridge-Order-Id response header).

Request

GET /v1/orders/3f1a8c2e-9d44-4e21-8a77-0c2e3f1a8c2e HTTP/1.1
Host: dev-api.occastore.com
# + the four X-Bridge-* auth headers (see Authentication)

Response — 200 OK

X-Cache: hit|miss header + body:

{
  "bridge_order_id": "3f1a8c2e-9d44-4e21-8a77-0c2e3f1a8c2e",
  "reference": "PO-EXAMPLE-001",
  "status": "accepted",
  "pk_order_id": "123456",
  "items": [{ "sku": "WPI-CHOC-2KG", "quantity": 2, "unit_price_pence": 4999 }],
  "created_at": "2026-05-19T14:00:00.000Z",
  "updated_at": "2026-05-19T14:00:00.000Z"
}
  • statusaccepted · processing · dispatched · delivered · cancelled. Cached 30s for accepted/processing, 1h for terminal states (a status change may take up to ~30s to surface).
  • pk_order_id is null (not omitted) while an order is in the reconstruction-pending window.

Phase 1 status coverage (known limitation). In Phase 1, automatic status updates resolve to accepteddispatchedcancelled only. processing and delivered are not emitted yet: the fulfilment system's order API exposes no pick/pack sub-state to distinguish processing from accepted, and delivery confirmation is carrier-tracking (a Phase 3 surface). Treat dispatched as "left the warehouse"; do not wait for processing or delivered to appear in Phase 1. The full enum is reserved so values can be added without a contract change as later phases land.

Errors (this endpoint)

HTTP reason Meaning
400 invalid_request bridge_order_id is not a UUID.
401 unauthorized HMAC / credential failure — see authentication.md.
403 scope_disabled orders.read not granted.
404 not_found No such order for your account. Identical response whether the id does not exist or belongs to another customer — existence is never leaked.
429 rate_limited Quota exceeded.
503 draining Bridge draining for restart/maintenance — Retry-After: 60. (Reads honour drain_mode; they are not affected by the writes pause.)
503 upstream_error Unexpected internal error — transient, retry.

See error-codes.md.

POST /v1/orders/:bridge_order_id/cancel — cancel an order Phase 1 ✓ Live

Cancels an order in the fulfilment system and marks the Bridge record cancelled. "Cancel" is state-targeting: an already-cancelled order returns 200 idempotently (no second fulfilment-system call). Idempotency-Key semantics are shared across writes — see Conventions.

  • Scope: orders.cancel (distinct from orders.write — granted separately)
  • Auth: HMAC (see README)
  • Idempotency-Key header: required
  • Path param: bridge_order_id — the UUID from POST /v1/orders.
  • Request body: none required.

Request

POST /v1/orders/3f1a8c2e-9d44-4e21-8a77-0c2e3f1a8c2e/cancel HTTP/1.1
Host: dev-api.occastore.com
Idempotency-Key: 9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d
# + the four X-Bridge-* auth headers (see Authentication)

No request body.

Response — 200 OK

{
  "bridge_order_id": "3f1a8c2e-9d44-4e21-8a77-0c2e3f1a8c2e",
  "reference": "PO-EXAMPLE-001",
  "status": "cancelled",
  "pk_order_id": "123456",
  "items": [{ "sku": "WPI-CHOC-2KG", "quantity": 2, "unit_price_pence": 4999 }]
}

Behaviour by current order state:

State Result
accepted / processing Cancelled in the fulfilment system → 200, status:"cancelled".
cancelled Idempotent 200 (no fulfilment-system call).
dispatched / delivered 422 order_dispatched — too late to cancel.
accepted with pk_order_id still null 422 order_not_yet_synced — retry shortly (reconciliation in progress).

A fulfilment-system rejection (e.g. the order was already dispatched there) surfaces as 422 upstream_rejected — the fulfilment system itself is the final authority on whether a cancel is permitted.

Errors (this endpoint)

HTTP reason Meaning
400 invalid_request bridge_order_id not a UUID.
400 missing_idempotency_key Header absent.
400 idempotency_key_conflict The Idempotency-Key is already in use by a different account.
401 unauthorized HMAC / credential failure — see authentication.md.
403 scope_disabled orders.cancel not granted.
404 not_found No such order for your account (no existence leak).
409 idempotency_conflict Same key, different body.
422 order_dispatched Order already dispatched/delivered.
422 order_not_yet_synced Not yet in the fulfilment system; retry shortly.
422 order_not_cancellable Order in an unexpected non-cancellable state.
422 upstream_rejected The fulfilment system rejected the cancel.
502 upstream_unavailable The fulfilment system failed after retries — idempotency key stays valid, safe to retry.
503 upstream_circuit_open Fulfilment-system circuit open — Retry-After: 60.
503 writes_paused Bridge globally paused for writes — Retry-After: 60.
503 draining Bridge draining for restart/maintenance — Retry-After: 60.
503 upstream_error Unexpected internal error — transient, safe to retry with the same key.

See error-codes.md.

Sandbox — v1 Design preview

There is no live sandbox environment yet. This page describes what a sandbox will offer and is published as a design preview so you can plan. No credentials, no sandbox endpoint, and no SANDBOX traffic are accepted today. Do not point an integration at a "sandbox" URL — there isn't one.

What you can do today, with no sandbox

You can validate the entire signing path offline, right now, before you ever send a live request — that is the hardest part of integrating, and it needs no sandbox:

  • Signing vectors — reproducible worked examples; match them byte-for-byte to prove your signer is correct.
  • Reference signers — copy-paste signers in Node, Python, PHP, and C#, each reproducing those vectors.
  • Terminal tester — a zero-dependency script whose --check runs an offline signing self-test (no credentials, no network).
  • Postman collection — import it and the pre-request script signs every request for you.

Between them you can confirm your signing, headers, and request shapes are correct without any live or sandbox call.

What a sandbox will add (planned, not committed to a date)

A future sandbox is expected to offer an isolated environment where you can exercise the live request/response flow — create and cancel test orders, see real status transitions, and hit the real error paths — against isolated test data, without touching production fulfilment. The SANDBOX SubSource is reserved for this and is not assignable today.

A live sandbox environment and its credentialing flow are a separate backend project. When it ships, its full contract (base URL, how to get sandbox credentials, data-isolation guarantees) will be published here — and this page's banner will flip from "Design preview" to a live phase pill, the same way the live endpoints were documented only once they shipped.

Until then: treat anything "sandbox" as not available. Build and validate with the offline tooling above; go live against the documented production contract when your account is provisioned.

Webhooks — v1 Phase 3

Design preview — webhooks are not live yet. There is no endpoint to register, and no deliveries are sent today. This page describes the intended model so you can design for it; do not build an integration that depends on webhooks until this page's pill flips from Phase 3 to a live phase.

What webhooks will do

Push order lifecycle events to an endpoint you control, as they happen — so you don't have to poll. Planned event types map onto the Order lifecycle:

  • order.dispatched — the order left the warehouse (status: dispatched);
  • order.cancelled — the order was cancelled (status: cancelled);
  • order.tracking — carrier tracking detail, once available (a later Phase 3 surface).

The intended model

  • You register an HTTPS endpoint you own (e.g. https://your-app.example.com/webhooks).
  • Bridge POSTs a signed JSON event to it on each lifecycle change.
  • Each delivery is HMAC-signed with the same scheme as the API, so you verify it with the exact signing vectors / reference signers you already use — that's how you confirm a delivery genuinely came from us.
  • At-least-once delivery with retries; each event carries a stable id so you can dedupe.

The full contract (registration, the event schema, the signature header, and the retry policy) will be published here when webhooks ship — the same way every live endpoint was documented only once it shipped.

What to do today (poll instead)

Until webhooks ship, poll GET /v1/orders/:bridge_order_id and compare the status against the Order lifecycle. The reconciliation worker keeps the Bridge mirror current, so polling reflects the real transitions (accepted → dispatched → cancelled).

Register interest

If push delivery matters to your integration, tell your account manager — it helps us prioritise the Phase 3 work. There is nothing to configure yet.

Roadmap

These surfaces are planned for future phases and are not yet available. Do not integrate against them — there is no contract here to build on. Each one's full contract is published in these docs only when it ships, the same way the live endpoints above are documented.

Webhooks Phase 3

Push notifications for order lifecycle events (status changes, dispatch, delivery), HMAC-signed. See the Webhooks design preview for the intended model and what to do today.

Order tracking Phase 3

Carrier tracking detail per order, available after dispatch.

Orders list Phase 3

Paginated list and query of your orders.

OAuth2 Phase 4

OAuth2 client-credentials authentication as an alternative to HMAC.

Changelog — v1 Phase 1 ✓ Live

Generated build: build v0.1.405 · bcc1b9a · 2026-08-16T17:43:31.490Z

The contract-version changelog for the OccaStore Bridge v1 API. Newest first, one entry per change to the partner-facing surface (endpoint paths, request/response fields, error reasons, status enums, SubSource rules, webhook payloads) plus the developer tooling published alongside it. Build-stamp, styling, and internal-only changes are not listed.

While v1 is in Design Preview (see versioning) entries are dated under v1; formal v1.x minor tags begin at GA.


v1 — Design Preview

2026-07-30

  • AddedEnvironments: staging (dev-api) vs production (api) base URLs, per-environment credentials, and tooling variable guidance.
  • RevisedIntegration types, README, Authentication: production gateway is deployed; go-live uses new credentials issued by your account manager (staging creds do not work on production).

2026-06-30

  • AddedCreate order + Error codes: 422 price_variance:<sku> and price_unavailable:<sku> responses on POST /v1/orders now include additive fields summary (plain English), sku, and for price variance also sent_pence and expected_pence. The stable reason token is unchanged — match before the : as before.

2026-06-29

  • Added (operator-configured)Create order: accounts may have a centrally managed billing address (partner bill_to ignored when set) and an optional price check on lines with an explicit unit_price_pence. Default behaviour is unchanged (contract drift is warn-logged and accepted). When price check Hold is enabled, a breach returns 422 with reason price_variance:<sku> (or price_unavailable:<sku> when the reference price is missing). Documented in Error codes.

2026-06-26

  • DocumentedCreate order + Error codes: two 422 responses you may see on POST /v1/ordersshipping_service_id_unresolved (the order's shipping service isn't mapped on your account yet) and invalid_order_payload (the order couldn't be set up, usually a missing account setting). Both are configuration items on our side rather than a problem with your request, and the order is not created — quote the response X-Request-Id to your account contact and we'll resolve it.
  • ClarifiedCatalogue: category_id is an opaque identifier (UUID) — match it exactly rather than parsing it — and stock_item_id may be null where it isn't set (type string | null).

2026-06-19

2026-06-17

  • RevisedIntegration types: align EDI/API (staging dev-api, GET /status, prod not deployed) and drop-ship (per-partner go-live, review/auto, periodic stock + despatch-after-fulfilment timing) with gateway and DS worker behaviour.
  • DocumentedAuthentication: short conformance window exception for pending accounts during onboarding tests.
  • RevisedREADME: GET /status in endpoint table; rate limits per (account, scope). Postman + terminal-tester examples use staging URL for Design Preview.
  • RevisedIntegration types: drop-ship SFTP section trimmed to customer-facing scope only (file-exchange overview, integration pack as the authoritative spec, setup and test checklist). Operator config detail (cadence keys, dialect inheritance, buffers, billing hooks) removed from the public docs surface.
  • AddedIntegration types: branching guide for the two live partner paths — EDI/API (signed /v1 HTTPS; links the Quickstart and endpoint reference) and drop-ship SFTP (high-level CSV file exchange). Overview and Quickstart now link here.

2026-06-07

  • Documented — zero-downtime credential rotation: an account may hold multiple active credentials at once (issue new → migrate your signer → revoke old). See Authentication troubleshooting.
  • Added (design preview)Webhooks: a Phase 3 design preview of push delivery for order lifecycle events (HMAC-signed with the same scheme). Not live — no endpoint to register yet; poll GET /v1/orders/:id today.
  • AddedAuthentication troubleshooting: the 401 checklist (sign the transmitted bytes, stale secret, clock skew, nonce replay, path-excludes-query, header/field order) with the diagnostic tools.
  • AddedOrder lifecycle: the status state model (accepted / processing / dispatched / delivered / cancelled) with the cancellation windows and the cancel-token mapping.
  • AddedConventions: the single-source page for the cross-cutting rules (integer pence, ISO 8601 UTC dates, ISO 3166 country codes, reference format, Idempotency-Key semantics); endpoint pages now link to it.
  • AddedQuickstart: a golden-path onboarding guide (credentials → sign → first authenticated GET → create a test order → cancel), each step linking the relevant endpoint and tooling.
  • Breaking (pre-GA clean cut) — two 422 error reason tokens that named the backend (on the create-order and cancel paths) were retired in favour of vendor-neutral tokens: the order-rejection reason is now upstream_rejected and the cancel-before-propagation reason is now order_not_yet_synced. The public contract (docs + error bodies) no longer names the fulfilment system. Done under the Design-Preview reservation, before any integration was live on these paths; the first worked example of the versioning & deprecation policy.
  • AddedPostman collection: an import-and-go collection whose collection-level pre-request script auto-signs every request.
  • AddedTerminal tester: a zero-dependency bridge-api-tester.mjs with --check / --create / --cancel and an offline signing self-test.
  • AddedReference signers: copy-paste HMAC signers in Node, Python, PHP, and C#.
  • AddedSigning vectors: reproducible HMAC-SHA256 worked examples for validating your signer offline.

2026-06-06

  • Added (error reasons) — four 422 unprocessable validation reasons on POST /v1/orders: reference_too_long, reference_invalid, order_item_unmapped, and invalid_country. See error codes.

This page is regenerated on every docs build; the build stamp above is the authoritative record of which version/commit you are reading.