--- name: inbox-integration description: "Integrate an app with Axis Inbox end-to-end — register the application, publish its scope catalogue, mint a scoped service key, resolve a workspace tenant, then read and reply to conversations across WhatsApp, Meta, SMS, email and live chat, receive events over WebSocket/SSE/webhooks, and run campaigns and flows. Use when an app needs messaging, an inbox, contacts, campaigns or automation from Axis Inbox, when connecting a channel, or when debugging inbox auth/tenancy/idempotency/webhook problems." version: 1.0.0 --- # Axis Inbox — Integration Skill Everything needed to take an app from "no messaging" to "reading and replying to real conversations", in one pass. Every shape here was read from the service's own handlers — these are the endpoints as implemented, not as a specification describes them. Inbox is a NestJS service on Fastify (Postgres + Redis + BullMQ) that fronts every messaging channel behind one contract. Consumers touch it four ways: - **`@~inbox/sdk`** — the typed client, for a server. - **Raw HTTP** to `/v1/*`, for anything that is not Node. - **Realtime** — WebSocket or SSE for a browser, signed outbound webhooks for a server. - **Public endpoints** — the live-chat widget and campaign click tracking, which hold no credential. ## The one thing to get right: credential and tenant are separate questions Every request answers two questions, and conflating them is the most common mistake. | | Who is calling | Which workspace | |---|---|---| | Carried by | `Authorization: Bearer …` (or `x-api-key`) | `X-Axis-Tenant: ws_` | | Values | an Accounts `sk_` service key, **or** a user's session id | one workspace's namespaced Accounts user-group id | | Omitting it | `401 missing_credentials` | `400 tenant_missing` — **or** the operator surface | | Getting it wrong | `401 invalid_api_key` | **silence** — see below | **A wrong tenant does not error.** Tenant resolution is an upsert: `resolveOrProvision(appId, externalRef)`. A typo, a local database id, or last environment's workspace id all provision a brand-new empty workspace and return 200. "My data is missing" is almost always this, not a bug. **The absence of the tenant header is meaningful.** It is how you reach the cross-tenant operator surface. That surface is gated by the `stats` scope, which `admin` does **not** grant. ## Read this before designing anything **Identity is not stored here.** Inbox holds no users, no sessions, no workspaces. Every `ownerUserId`, `assignedUserId`, `authorUserId` is an Axis Accounts id, hydrated at read time. Users, groups and app registration all live in [Axis Auth](/docs); Inbox defers to it entirely. **Threads cannot be created.** There is no create-thread endpoint and there will not be one. A thread appears when a message arrives — from a provider webhook, or from the backfill that runs after a successful connect. Anything that looks like "start a conversation" is either a template send (`POST /v1/connections/{id}/send-template`) or a campaign. **Every write needs an `Idempotency-Key`,** and the requirement is driven by the HTTP verb rather than declared per route. Several POSTs that only read still demand one. See `references/consuming-the-api.md`. ## Do it with the script, not by hand ```sh node ~/.claude/skills/inbox-integration/scripts/onboard.mjs \ --config ./consumer.json # see references/config.example.json # INBOX_BASE_URL, ACCOUNTS_BASE_URL and ACCOUNTS_ADMIN_TOKEN from the environment ``` It is **idempotent** (an existing app, scope catalogue or key is reused), supports `--dry-run` (needs no credentials — it prints the plan and writes nothing), and `--json` for piping. It performs the whole chain in order, because each step's output feeds the next: 1. `POST /api/auth/apps` (Accounts) — register the consuming application 2. `PUT /api/auth/app-scopes/{appId}` (Accounts) — publish Inbox's `inbox:*` scope catalogue, so a key can be issued with those scopes at all 3. `POST /api/auth/api-keys` (Accounts) — mint a scoped `sk_` key (**shown once**) 4. Resolve a **tenant** — a workspace's Accounts user-group id, namespaced `ws_` 5. **Verifies** — calls `GET /v1/whoami` with the new key and asserts the resolved app, mode, scopes and tenant are what was asked for, then lists threads to prove the tenant resolves Step 5 matters more than it looks. A key that authenticates but resolves the *wrong app*, or a tenant that silently provisioned empty, both look like success until someone wonders where the data went. ## The shortest path to a working integration ```sh # 1. Does the service answer, and are its backends wired? curl -s $INBOX/v1/health # 2. Does my credential resolve, and to which app, scopes and tenant? curl -s $INBOX/v1/whoami \ -H "authorization: Bearer $KEY" \ -H "x-axis-tenant: ws_$GROUP_ID" # → { "data": { "appId": "…", "mode": "bearer", "scopes": [...], "tenantId": "…" } } # 3. Is there anything in this workspace? curl -s "$INBOX/v1/inbox/threads?limit=5" \ -H "authorization: Bearer $KEY" -H "x-axis-tenant: ws_$GROUP_ID" ``` If step 2 returns a `tenantId` but step 3 returns an empty list on a workspace you know has traffic, you have provisioned a new tenant with a wrong `ws_` value. Compare it against the workspace's Accounts user-group id. ## Then, in code ```ts import { Inbox } from '@~inbox/sdk'; const inbox = new Inbox({ baseUrl: process.env.INBOX_BASE_URL!, appKey: process.env.INBOX_SERVICE_KEY!, // sk_… — or `session` for a real user tenant: `ws_${accountsUserGroupId}`, }); const { data: threads } = await inbox.inbox.threads.list({ status: 'open' }); await inbox.inbox.threads.reply(threads[0].id, { message: 'On it — thanks for waiting.' }); ``` `appKey` and `session` are mutually exclusive and exactly one is required. Prefer **`session`** whenever a real user is driving: ticket assignment, connection ownership and group-scoped visibility all attribute to the resolved user, and a machine key has nobody to attribute to — it is *refused*, not silently skipped. ## What to read next | File | When | |---|---| | `references/authentication.md` | Credentials, tenancy, the scope taxonomy, operator access | | `references/consuming-the-api.md` | Envelopes, idempotency, pagination, retries, the SDK surface | | `references/receiving-events.md` | WebSocket, SSE, outbound webhooks and signature verification | | `references/channels.md` | Connecting WhatsApp, Meta, email, SMS and live chat | | `references/manual-onboarding.md` | The raw curl sequence, for debugging what the script did | | `references/troubleshooting.md` | Every failure code and its real cause | Full documentation, including generated references that cannot drift from the running service, is at `/docs` on any deployed instance, indexed for agents at `/llms.txt`. ## Rules that will save you a debugging session 1. **Unwrap on the presence of `data`, not its truthiness.** `{ "data": null }` is a legitimate answer. `json?.data ?? json` hands you the whole envelope, and a consumer that reads a field off it silently gets `undefined`. This shipped once as "you have used your free SMS" when the truth was "we could not tell you". 2. **`admin` is a superset on tenant routes and grants nothing on operator routes.** Cross-tenant reads need `stats` explicitly; cross-tenant writes need `operator:connections:write` on top. 3. **Use slugs, never Accounts app UUIDs.** They differ per environment. `INBOX_ACCOUNTS_APP_SLUG` and `INBOX_OPERATOR_APP_SLUG` are matched by slug for exactly this reason. 4. **The 24-hour messaging window is real.** A free-form DM to someone who has not messaged you in 24 hours returns `messaging_window_expired` (422). Open the conversation with an approved template instead. 5. **Verify webhooks against the raw body.** Re-serializing the JSON changes the bytes and every signature fails. 6. **The package READMEs are stale.** `packages/sdk/README.md` documents the retired `axs_` key format, a required `tenant`, and no `session` option. The SDK now hard-rejects anything that is not `sk_`. Trust `/docs` and this skill.