Troubleshooting
Every response carries meta.requestId, echoed as the x-request-id header. Quote it — it is the
only handle that ties a client-side symptom to a server-side log line.
| Symptom | Cause | Fix |
|---|---|---|
| Everything works, workspace is empty | Wrong X-Axis-Tenant — it provisioned rather than errored |
Compare the ws_ value against the workspace's Accounts user-group id |
Cannot find module '@~inbox/types' |
Workspace packages not built | yarn build at the repo root |
Missing required env var INBOX_KEK |
.env not loaded; nothing loads it automatically |
Use the yarn scripts, or pass --env-file ../../.env |
401 invalid_api_key |
Key not found, revoked, expired, or resolves to no local app | Re-check the key and its app registration; debug by requestId |
403 insufficient_scope on /v1/operator/* with an admin key |
admin does not grant stats |
Issue a key with inbox:stats |
403 on /v1/operator/* for a human |
INBOX_OPERATOR_APP_SLUG unset or mismatched |
Set it to the operator app's slug |
400 idempotency_key_required on a read-only POST |
Requirement is verb-driven, not per route | Send a UUID Idempotency-Key |
409 idempotency_conflict |
Same key, different body | Use a new key, or send the identical body |
422 messaging_window_expired |
Outside the 24-hour window | Send an approved template instead |
422 validation_failed on ticket assign |
A machine key cannot validate an assignee | Call with a user session |
409 conflict on ticket open |
One-open-ticket invariant | Reuse the thread's existing open ticket |
| Inbound webhook 401 | Wrong signature scheme for that adapter | Match the provider's scheme; verify against the raw body |
| Published, nothing happened | Worker not running, or Redis absent | Check backends.redis; check the worker started |
| Realtime connects, no events | Proxy not upgrading WebSocket | Add the upgrade headers |
| Writes succeed, nothing persists | INBOX_DATABASE_URL unset; silent in-memory fallback |
Set it; confirm backends.database is true |
My data is missing
The most common integration bug, and it never presents as an error.
Tenant resolution is an upsert on (appId, externalRef) — resolveOrProvision. A typo, a local
database id, or last environment's workspace id all provision a brand-new empty workspace and return
200. There is no "unknown tenant" error to catch.
curl -sS "${AUTH[@]}" $INBOX/v1/whoami # resolves — but to WHICH tenant?
curl -sS "${AUTH[@]}" "$INBOX/v1/inbox/threads?limit=5" # empty on a workspace you know has traffic?
If whoami returns a tenantId and the list is empty, you have provisioned a new tenant. Compare
your ws_ value against the workspace's Accounts user-group id. Also check you are not using an
Accounts app UUID anywhere — those differ per environment; slugs are the stable key.
Local setup failures
Cannot find module '@~inbox/types' — the service imports three workspace packages that must be
compiled first. yarn build from the repo root; Turbo orders them before the service.
Missing required env var INBOX_KEK — .env lives at the repo root, not in apps/service/,
and nothing in the app loads it. dotenv is a devDependency that is never imported. The yarn dev
and yarn start:dev scripts pass --env-file ../../.env; running nest start directly does not.
INBOX_KEK must be exactly 32 bytes (64 hex chars), and INBOX_REALTIME_JWT_SECRET at least 32
characters.
EADDRINUSE :6979 — an orphaned dist/main from a previous run. Kill the server, not the
watcher: pkill -f 'dist/main'. Killing nest start leaves the re-parented child holding the port.
401 invalid_api_key
One code covers four causes, deliberately — a credential error must not tell an attacker which check failed:
- the key does not exist in Accounts;
- it has been revoked;
- it has expired;
- it introspects fine but resolves to no local app.
That last one is the surprising one, and it is usually a mismatched INBOX_ACCOUNTS_APP_SLUG. The
service log names the real reason against the request id.
Also confirm the key format: the SDK throws locally on anything that does not start sk_. The
legacy axs_ format is gone, and the package README still documents it.
403 insufficient_scope
On a tenant route: the credential genuinely lacks the scope. Note that admin satisfies every
@RequireScopes here, so if an admin key is rejected on a tenant route you are almost certainly on
an operator route without realising.
On an operator route with an admin key: admin grants nothing on /v1/operator/*. The
guard checks stats and does not consult admin at all — cross-tenant reach is a separate, explicit
grant so a workspace-confined admin key can never read the whole estate. Mint a key with
inbox:stats; add inbox:connections:write for the mutating connection-request routes.
On an operator route for a signed-in human: a session caller must also satisfy user.isOperator,
which is computed by matching INBOX_OPERATOR_APP_SLUG against the caller's Accounts apps by
slug. Unset or mistyped, the expression short-circuits and every human loses operator access
silently — while machine keys keep working, because they carry no user. That asymmetry is what
makes it hard to spot. Slug, never id: Accounts app ids are environment-specific.
Idempotency errors
400 idempotency_key_required on a POST that only reads. The policy is driven by the HTTP verb:
dataWrite: meta?.dataWrite ?? isWriteMethod(req.method). Every POST/PUT/PATCH/DELETE is a data
write unless a route opts out with @ReadOnlyPost() — only /v1/campaigns/preflight,
/v1/reports/query and /v1/reports/export do. Send a UUID; nothing there is unsafe to repeat.
409 idempotency_conflict. The same key arrived with a different fingerprint, computed as
sha256(method + "\n" + url + "\n" + rawBody). Note that url includes the query string and
rawBody is the bytes as sent — so a re-serialised body with different key ordering is a different
fingerprint even when the JSON is semantically identical. Serialise once and reuse the exact string
across retries.
409 idempotency_in_progress means the first request has not finished. Wait and retry; a handler
that throws releases its reservation, so a genuine failure does not strand the key for 24 hours.
Sending failures
422 messaging_window_expired. The 24-hour messaging window is real. A free-form message to
someone who has not messaged you inside 24 hours is refused. Open the conversation with an approved
template: POST /v1/connections/:id/send-template.
422 validation_failed on ticket assign — "Assignment requires a user session; a service key
cannot validate an assignee." Inbox writes the assignment but Accounts validates it; a machine
key carries no user and no session token, so there is nothing to validate against. An unvalidated
assignment would write an Accounts UUID that nothing can resolve. Call the route with a session.
409 conflict on opening a ticket. One open ticket per thread, enforced by a partial unique
index in Postgres rather than a read-then-write check. The service does not check first — it lets
the database refuse and translates the refusal into a clean 409. The application-code version of this
check raced in axis-api, where two concurrent opens each read "none open" and each inserted, leaving
93 bad rows behind. If you get this, the thread already has an open ticket; fetch it rather than
creating one.
Inbound webhook 401
Each provider signs differently, and the verification runs in the handler before any persistence:
| Integration key | Scheme |
|---|---|
instagram.native, facebook.native |
HMAC in x-hub-signature-256, sha256= prefix required, plus the Meta challenge |
aggregator |
HMAC in x-late-signature, x-hub-signature-256 or x-signature |
whatsapp |
Shared secret in x-infobip-auth, or HMAC in x-hub-signature-256; Meta-style challenge |
whatsapp_session |
HMAC in x-openwa-signature, x-webhook-signature or x-signature — the scheme follows the header |
sms |
HMAC in x-signature |
email |
Plain shared secret in x-infobip-auth or x-axis-signature — not an HMAC |
livechat, ai |
Always { ok: false } — inert by design |
Two rules cover most failures: verify against the raw body bytes (re-serialising changes the digest), and confirm the secret is the one configured for that integration. See Signature verification.
"Published but nothing happened"
The write succeeded and the row exists; the work never ran.
curl -s $INBOX/v1/health— ifbackends.redisis false you are on in-memory queues, and jobs do not survive a restart or fan out across instances.- Confirm a worker started. Enqueueing works from construction, but consumption needs
start(). - Grep the logs for
Failed to enqueue.outbound-retry,publish-dispatchandcampaign-sendare best-effort on enqueue: the row is persisted and recoverable, so a failed enqueue is logged, not thrown. - Read the domain rows, not the queue — campaign recipients,
PostDispatchrows andImportJob.statuseach record their own progress. Re-drive withPOST /v1/posts/:id/retryorPOST /v1/admin/webhook-inbox/:id/requeue.
See Queues and workers.
Realtime connects, then nothing arrives
The socket opens, the token mints, the client reports connected — and no events ever come. That is almost always a reverse proxy that is not forwarding the WebSocket upgrade. Everything else works, because everything else is ordinary HTTP.
location /v1/realtime {
proxy_pass http://127.0.0.1:6999;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
If the proxy cannot be changed, fall back to SSE: POST /v1/realtime/sse/init then
GET /v1/realtime/sse/stream.
Data written but not persisted
INBOX_DATABASE_URL did not reach the process, and the service fell back to in-memory stores
without complaint. Writes succeed, reads are consistent within the process, and everything vanishes
on restart.
curl -s $INBOX/v1/health
# {"status":"ok","env":"…","backends":{"database":false,…}} ← broken
backends.database: false is the single most important line of the health check, and it is easy to
miss because status still reads ok — liveness and correctness are different questions. Reports
are a secondary tell: the whole reports module is Prisma-only, so those routes 404 rather than
returning empty.