Appearance
Errors
Read the body's code, not the status. 403 covers six different reasons and a client that branches on the status cannot tell a missing scope from a downgraded plan. Three of the six are about three different things, and they are worth holding apart: plan_excludes is the organisation's plan, scope_missing is what this key was minted to reach, and ip_not_allowed is the key's own allowlist. Only the middle one is fixed by minting a wider key.
The envelope
Every keyed refusal is the same shape:
json
{
"code": "scope_missing",
"message": {
"code": "scope_missing",
"desc": "This key cannot read Order book.",
"doc_url": "https://docs.tightly.io/api/guides/errors#scope_missing",
"request_id": "req_6d2b7e1490af4c338b0e51d9c7a2e4b6",
"service": "UNKNOWN",
"severity": "ERROR"
}
}| Field | What it is |
|---|---|
code | The stable string to branch on. It never changes within a version train. |
message.desc | The sentence a person reads. Show it verbatim; do not rewrite it. |
message.doc_url | A link to the section of this page that explains what to do. |
message.request_id | The handle on this call. It matches the X-Request-Id header. |
Successful responses use the same envelope with the payload under data. X-Request-Id is on every response, refused or not, and so is X-Tightly-Region, which names the home that served the call and reads us for every organisation today.
js
const response = await fetch(url, { headers })
const body = await response.json()
if (!response.ok) {
switch (body.code) {
case 'key_invalid': return replaceTheKey()
case 'wrong_region': return pointTheClientAt(body.data.api_base_url)
case 'scope_missing': return askForWiderReach(body.message.desc)
case 'rate_limited': return retryAfter(response.headers.get('Retry-After'))
case 'plan_excludes': return tellTheCustomer(body.message.desc)
default: throw new Error(`${body.code}: ${body.message.desc}`)
}
}The one refusal to branch on by status rather than by code is 503. Three seams raise it and they carry two codes between them, so read the status first. All three name the seconds to wait, so obey Retry-After rather than backing off on a guess:
js
if (response.status === 503) {
return comeBackAfter(Number(response.headers.get('Retry-After') ?? 10))
}The codes
key_invalid
401. "The API key is not valid."
One refusal covers five states: malformed, unknown, revoked, expired, and stopped after a replacement. They are deliberately not distinguished, because a refusal that said "revoked" would confirm a key existed.
A request with no Authorization header at all never reaches this refusal. It is 400, before any key is looked for, with the sentence "The provided request does not have required Authorization header. Please verify the request." Send Authorization: Bearer tly_live_....
What to do: check the key you are sending against the list in Settings, Developer, API keys. If it is expired or revoked, mint or replace it. Do not retry: no amount of retrying makes an invalid key valid.
wrong_region
421. "This key belongs to a workspace in Tightly's European Union region. Send requests to https://api.eu.tightly.io."
The key is real and was minted in a different region from the one you called. Tightly runs one cell per region and a key lives where its workspace does, so a European workspace's key sent to api.app.tightly.io is not invalid, only misdirected. It is the one credential answer that is not key_invalid, and it is given only when the directory knows the key lives elsewhere: a made-up key is still 401 key_invalid.
The body carries the home and its address under data, the same two fields POST /auth/where answers:
json
{
"code": "wrong_region",
"data": { "region": "eu", "api_base_url": "https://api.eu.tightly.io" },
"message": {
"code": "wrong_region",
"desc": "This key belongs to a workspace in Tightly's European Union region. Send requests to https://api.eu.tightly.io.",
"doc_url": "https://docs.tightly.io/api/guides/errors#wrong_region",
"request_id": "req_6d2b7e1490af4c338b0e51d9c7a2e4b6",
"service": "UNKNOWN",
"severity": "ERROR"
}
}X-Tightly-Region on the same response names the cell that answered, which is the one to stop calling. Where data is absent, this cell knows the region but has no address for it, and the sentence says "Send requests to that region's API." instead of naming one.
What to do: set your client's base URL to data.api_base_url and send the same request again. Nothing about the key changes, and no retry against the cell that refused you will succeed. Hold the base URL beside the key in your configuration: a key and its region travel together.
not_public
403. "This operation is not part of the public API."
The operation is not on the public surface, whatever it is. One answer covers a staff operation, an operation with no scope mapped to it, and an operation gated above what a key can ever hold.
What to do: check the reference. An operation not in the published spec is not part of the contract, whatever a route happens to answer today.
scope_missing
403. "This key cannot read Order book."
The key is live and the operation is public, but this key was not minted to reach it. The sentence names the resource's word and the verb.
What to do: widen the key. Open its row menu, choose Replace, tick the reach you need, and set how long the old key keeps working. See Scopes.
plan_excludes
403. "This organisation's plan does not include Tightly Connect. It is sold with Essentials+."
The organisation's plan does not include what this operation is sold with. Entitlement is asked on every request rather than frozen at mint, so a key minted while the plan included this stops the day that plan moves down.
The plan is asked twice, and this one code carries both answers. First the public API itself, which is sold with Essentials: an organisation whose plan does not include it is refused on every operation, and the sentence reads "This organisation's plan does not include the public API. It is sold with Essentials." Then whatever the resource behind the path is sold with, if anything. So plan_excludes on an ungated resource such as Products is about holding a key at all, and never about that resource. Read message.desc to tell the two apart: it names which of them is missing.
The first question is asked of keys minted since the public API became something a plan includes. A key older than that is never refused by it and goes straight to the second question, whatever the plan holds, because the capability nobody could have been asked to hold is not one to refuse them on. A key minted from here on is asked both, on every request.
The sentence names the capability and the rung in the product's own words, and both are rewritten when the price list moves: Tightly Connect was sold with Pro until 5 September 2026 and is sold with Essentials+ now. Branch on code, never on the text.
What to do: this is a conversation with the customer, not a retry. Show message.desc as it came.
organization_mismatch
403. "The organization in the path is not this key's organization."
A path that carries an organisation id was given one that is not this key's. It is refused, never substituted.
What to do: use the key's own organisation in the path. A key reaches exactly one organisation; if you work across several, hold one key per organisation.
account_mismatch
403. "This key is issued to account tp_selfridges and can reach the order book for that account only. It named tp_harrods."
A key can be issued to one trading account rather than to the whole organisation, which is how a retailer is given a key to their own side of your book. Two things are refused under this one code, because to the holder they are one fact:
- It named another account. In
trading_partner_idon the order book, inaccount_idon sell-out, or in the path on an account read. - It asked for a figure that covers every account. The order book's headline, its bookings curve, its firm and walkable split, its claims, its account comparison and one style across every account are each an aggregate over your whole book. A total recomputed over one stockist is not a smaller version of that total, so it is refused rather than narrowed, and the sentence names
GET /order-book/account-stylesandGET /order-book/account-anchor, which are that account's own.
One read does neither: GET /api/v1/wholesale/retailers narrows a bound key to its own account and answers, because "of these accounts, which are mine" is a fair question with a true answer. Expect one row, not a refusal.
What to do: call the account-scoped operation with the key's own account, or use a key that is not bound to one. An unbound key sees no change: this code cannot reach a key that is not issued to an account.
ip_not_allowed
403. "This key cannot be used from this address."
The key carries an address allowlist and this request did not come from it.
What to do: add the calling address to the key's Allowed addresses, or leave the list empty for any address. Check your egress address rather than your office address: a call from a cloud worker leaves from the worker's address.
rate_limited
429. Too many requests for this key this minute.
What to do: sleep for Retry-After seconds, then retry. Read X-RateLimit-Remaining on successful responses and slow down before you reach zero. A refused request still spends from the allowance, so hammering the door costs the same as using it. See Rate limits.
postgres_unavailable
503. "The database is unavailable right now. Try again in a few seconds."
Every operation can answer this. It is about Tightly and not about your request or your key: the database went briefly out of reach, which from outside is what a failover or a full connection pool looks like. A Retry-After header comes with it, naming the seconds to wait.
A second sentence carries the same status on a write: "This write was not attempted: the idempotency store could not be reached, so a retry could not be told apart from a new request. Nothing has changed. Retry with the same Idempotency-Key." That one carries Retry-After too, and it says plainly that nothing was written.
What to do: wait, then send the request again. Where the sentence says nothing was written, the same request is safe to send again. Otherwise a read is safe to repeat and a write is not certain either way. The connection was lost in the middle of it, so the change may or may not have landed. Read the object back before you send the write a second time. An Idempotency-Key does not settle that one: a write that raised releases its key, so the retry is a fresh attempt rather than a refusal for the next day.
Branch on the status, not the code. code is POSTGRES_UNAVAILABLE or SERVICE_UNAVAILABLE depending on which of the three seams refused, and the same condition can reach either of them. The status and the sentence are the reliable parts.
idempotency_conflict
409. "This Idempotency-Key was already used for a different request. Use a new key for a new write, or resend the original request unchanged to replay its answer."
The key was reused for a different body, or on a different operation. Always a client bug, and usually one key generated once and reused in a loop.
What to do: generate a key per write. Retrying this one will be refused again for as long as the first key is remembered, which is 24 hours.
idempotency_in_flight
409. "A request with this Idempotency-Key is still being processed. Retry in a moment to read its answer."
Two of your workers retried at once and the first is still running. It carries Retry-After: 1, because what you are waiting for is a request already in flight rather than a queue.
What to do: sleep the second and send the same request again. It will replay the first one's answer.
Both were conflict until 5 September 2026, which left a client reading the sentence to tell a refusal that never resolves from one that resolves in a second. A handful of operations answer 409 for reasons of their own under the bare code conflict, and each states its sentence on its own entry in the reference.
The codes an operation answers with
Beside the seam's codes above, a few operations refuse for reasons of their own and point their doc_url at this page. Most operation-specific codes are stated on the operation's own entry in the reference instead; the three below have a section because a live refusal links to one.
bad_request
400. "No account named Harrods."
The request named something this organisation does not hold, or sent a body that does not parse. POST /api/v1/order-book/lines is the operation that answers it: an account the book does not carry refuses the whole request before a single line is written, rather than minting a second account for a misspelling.
What to do: read message.desc, which names what was not found. Correct it and send the request again. Nothing was written, so there is nothing to undo.
validation_error
400. "scope[0]: Must be one of: SS, FW."
A parameter's value is outside what the operation takes, and the sentence names the parameter and the values it will accept. GET /api/v1/otb/{mfp_id}/rollup answers it for scope=FY, because the rollup is a per-season pool and two disjoint selling windows rolled into one running variance produce a crossing week that means nothing.
What to do: send a value the sentence names. Retrying the same request will be refused again.
customer_not_on_file
404. "No customer with that id is on file."
GET /api/v1/sales/customers/{customer_id} was given an id this organisation has no customer for. A customer is created by the order writers and by the sync and never by a call of your own, so an id that answers today is one an order or a sync already made.
What to do: take the id off the customer's own order, or list customers with GET /api/v1/sales/customers and match on the display name or, with an @ in the search term, on the email's pseudonym.
Where that list comes back empty and its withheld_words reads Not held, there is nothing to match on and nothing has gone wrong: this organisation keeps no shopper details at all, so the customer and ship_to on its orders are null because Tightly does not hold them rather than because nobody gave them. Get a sales order carries the same field for the same reason. A null withheld_words means the organisation may hold a person, and an empty list then means it has none yet. The changelog entry for withheld_words says what decides it.
Retrying a write safely
Send Idempotency-Key: <any string up to 255 characters> on a POST, PATCH, PUT or DELETE. Send the same key twice and the second request is answered with the first one's status and body instead of writing again, so a client that retried after a timeout ends holding the record's id rather than creating a second one.
bash
curl -X POST "https://api.app.tightly.io/api/v1/order-book/lines" \
-H "Authorization: Bearer $TIGHTLY_API_KEY" \
-H "Idempotency-Key: 018f3a2c-7c11-7c9d-9b0e-2f1a6c4d5e77" \
-H "Content-Type: application/json" \
--data @lines.jsonReusing a key with a different body is refused idempotency_conflict and reusing one while the first request is still running is refused idempotency_in_flight. Keys answer for 24 hours and belong to the API key that used them, so two integrators on one organisation may pick the same strings without meeting each other. A write that failed releases its key, so a retry with the same string writes for real. Sending no header behaves exactly as it always did. The server ignores a key longer than 255 characters rather than refusing it, and the write goes through without the guarantee, but the header is a declared parameter on every write in the reference, carrying maxLength: 255, so a generated client both offers you the argument and is likely to stop a longer one before it leaves your process.
Everything else
The API also returns the ordinary HTTP refusals: 400 for a request the operation will not take (see bad_request and validation_error), 404 for an object that is not there, 409 for a conflict, 422 for a body that fails validation, 500 when something broke on our side. All of them use the same envelope, so message.desc is always the sentence and X-Request-Id is always the handle.
A 500 is worth a support message with the request id in it. That id finds the call.
Finding a call afterwards
Every keyed call is recorded against its key for 90 days. Open Settings, then Developer, then the key's Usage, and paste a request id into Find a request. It returns the method, the operation, the status and when. If nothing carries that id, the page says so.