Concepts
Errors
The shape of every error response, and what each code means.
Every error from the Sellvik API has the same JSON shape. Codes are stable
across versions — branch on error.code, never on error.message.
Shape
{
"error": {
"code": "invalid_body",
"message": "Optional human-readable explanation. Will not be present on every error.",
"issues": [
{ "path": ["price"], "message": "Expected string, received number" }
]
}
}error.code— machine-readable string. Stable.error.message— human-readable text. May change between versions.error.issues— present on400 invalid_bodyfrom Zod validation. Per-field problems.- Additional fields (e.g.
retryAfteron429,requiredon403 insufficient_scope) may be present per code.
HTTP status codes
We use a small, conventional subset.
| Status | Meaning |
|---|---|
| 200 | OK. |
| 201 | Created — for POST that yields a new resource. |
| 204 | No content — for DELETE and a few POST revocation endpoints. |
| 400 | Validation failed (bad body, bad query). Look at error.issues. |
| 401 | Missing, malformed, or revoked credential. |
| 403 | Credential is valid but lacks the required capability or scope. |
| 404 | Resource doesn't exist in this shop. (Wrong shop ⇒ also 404.) |
| 409 | Conflict — duplicate slug, in-use category, etc. |
| 429 | Rate limited. Honour the Retry-After header. |
| 500 | We broke something. Filed automatically in our error tracker. |
We never return 418.
Catalogue of error codes
The codes below are documented; new ones may appear over time (additive change). Treat unknown codes as a generic error of their HTTP class.
Authentication (401)
| Code | When |
|---|---|
missing_or_malformed_authorization | No Authorization header on an admin route. |
missing_publishable_key | No X-Sellvik-Key on a store route. |
invalid_key | Key not found, revoked, or wrong type for route. |
invalid_customer_token | Customer JWT failed verification. See reason. |
missing_customer_token | Route requires customer JWT; none was sent. |
invalid_customer_token includes a reason:
| Reason | Cause |
|---|---|
expired | Access token TTL elapsed. Refresh it. |
bad_signature | Token tampered with or signed with wrong secret. |
wrong_audience | Token issued for a different shop. |
wrong_kind | Refresh token sent as access token, or vice versa. |
malformed | Not a valid JWT structure. |
invalid | Generic catch-all on /auth/refresh. |
revoked | Refresh token chain was revoked (e.g. after replay). |
replayed | Refresh token already used. Revokes the family. |
Authorization (403)
| Code | Detail field | When |
|---|---|---|
insufficient_scope | required: "<scope>" | Admin key missing the scope this route needs. |
headless_mode_disabled | — | Publishable key, but shop hasn't enabled headless mode. |
origin_not_allowed | — | CORS preflight from an origin not in allowlist. |
Validation (400)
| Code | When |
|---|---|
invalid_body | Request body failed Zod validation. issues has the why. |
invalid_query | Query string failed validation. |
invalid_params | Path parameter failed validation (e.g. malformed UUID). |
Resource (404, 409)
| Code | Status | When |
|---|---|---|
not_found | 404 | Resource doesn't exist in this shop. |
duplicate_slug | 409 | A product/category with that slug already exists. |
has_subcategories | 409 | Can't delete a category that still has children. |
email_exists | 409 | Customer signup with an email already on file (per shop). |
Rate limit (429)
| Code | Detail |
|---|---|
rate_limited | retryAfter: <seconds>, limit: <per-minute> |
The Retry-After response header carries the same number — prefer it over
parsing the body.
Server (500)
| Code | When |
|---|---|
internal_error | Unhandled exception in the handler. Logged on our side. |
Recommended error handling
const res = await fetch(url, init)
if (!res.ok) {
const { error } = await res.json()
switch (error.code) {
case "rate_limited":
await sleep(Number(res.headers.get("Retry-After") ?? 1) * 1000)
// retry
break
case "invalid_customer_token":
if (error.reason === "expired") await refreshAccessToken()
break
case "invalid_body":
console.error("validation failed", error.issues)
throw new Error("client bug")
default:
throw new Error(`${res.status} ${error.code}`)
}
}