Architecture
Inbox is a single NestJS service in a Yarn workspaces monorepo, fronted by Fastify, backed by Postgres and Redis. Nothing about it is distributed; the complexity is in the pipeline every request passes through and in the adapter seam that lets a new channel be added without touching that pipeline.
Stack
| Layer | Choice |
|---|---|
| Runtime | Node 22, TypeScript, ESM |
| Framework | NestJS 11 on Fastify (rawBody: true, for webhook signature verification and idempotency fingerprints) |
| Database | PostgreSQL via Prisma 6 |
| Queues | BullMQ + Redis |
| Realtime | ws (WebSocket) and SSE, both on short-lived signed tokens |
| Monorepo | Yarn workspaces + Turbo |
Fastify is configured with two non-default limits. maxParamLength is raised to 512 because the virtual
comment-post thread id (comment-post:{connectionId}:{externalPostId}) spends 39 characters on its
prefix alone, and Fastify answers 404 — not 414 — for an over-long path parameter, which would
present as "that thread doesn't exist". bodyLimit is raised to 25 MB for the historical-import
endpoints, where one batch of rich campaign-recipient rows exceeds Fastify's 1 MB default.
There is no global route prefix — setGlobalPrefix and enableVersioning are never called. Each
controller carries its own full prefix instead (@Controller('v1/tickets'), @Controller('v1/operator'),
and a handful of bare @Controller('v1')), which is why the inbound webhook routes and campaign click
tracking sit outside /v1 entirely and always will.
The request pipeline
Guards run in a fixed order, then interceptors, then the exception filter.
HTTP request
│
┌────────▼────────┐
│ RateLimitGuard │ bucketed pre-auth, per credential
└────────┬────────┘
┌────────▼────────┐
│ AuthGuard │ → AuthContext { mode, appId, scopes, user? }
└────────┬────────┘ public routes short-circuit
┌────────▼────────┐
│ TenantGuard │ → TenantContext from X-Axis-Tenant
└────────┬────────┘ skipped on @Operator() routes; fails closed otherwise
┌────────▼────────┐
│ OperatorGuard │ cross-tenant: needs `stats` (+ operator:connections:write on writes)
└────────┬────────┘
┌────────▼────────┐
│ ScopeGuard │ per-route @RequireScopes; `admin` satisfies all
└────────┬────────┘
│
┌────────────────▼────────────────┐
│ IdempotencyInterceptor │ outermost — reserve-before-handler on writes
│ ┌───────────────────────────┐ │
│ │ ResponseInterceptor │ │ wraps the return in { data, meta }
│ │ ┌─────────────────────┐ │ │
│ │ │ BusinessEventInter- │ │ │ innermost — taps the RAW handler return,
│ │ │ ceptor │ │ │ before reshaping, to derive event subject ids
│ │ │ handler │ │ │
│ │ └─────────────────────┘ │ │
│ └───────────────────────────┘ │
└────────────────┬────────────────┘
│
AllExceptionsFilter → { error: { code, message, requestId, details? } }
Two ordering decisions carry weight. TenantGuard runs before OperatorGuard but explicitly skips
operator routes, because those are cross-tenant by design and carry no X-Axis-Tenant; failing them
closed for lacking one would break the whole operator surface. And BusinessEventInterceptor is
innermost so it sees the handler's raw return value — once ResponseInterceptor has wrapped it in an
envelope, the subject ids it needs are no longer at the top level.
Guards fail closed. A non-public route with no authContext is rejected rather than passed through.
The three packages
apps/service the NestJS API
packages/types contracts: DTOs, adapter interface, scopes, headers, signing spec
packages/sdk the typed client apps consume
packages/constraints platform and surface rules
| Package | Version | Role |
|---|---|---|
@~inbox/types |
0.1.7 | Source of truth. DTOs, the IntegrationAdapter interface, SCOPES, HEADERS, error codes. Both the service and the SDK compile against it. |
@~inbox/sdk |
0.5.4 | Typed client, published to npm. Entrypoints . (tenant-scoped) and ./operator (cross-tenant). |
@~inbox/constraints |
0.1.0 | Platform/surface rules — what a given network accepts. |
Because the contracts live in a package rather than in the service, a change to a DTO is a compile error
in the SDK rather than a runtime surprise in a consumer. Build the packages before the service:
yarn build at the root, or you get Cannot find module '@~inbox/types'.
The adapter seam
The extension point is IntegrationAdapter, defined in packages/types/src/capabilities.ts:
interface IntegrationAdapter extends ConnectionAdapter {
key: string;
kind: IntegrationKind;
connectBindingScope: 'wildcard' | 'owned-capabilities';
supports(capability, network?): boolean;
publishing?: PublishingProvider;
dm?: DmProvider;
comments?: CommentProvider;
}
ConnectionAdapter adds startConnect, completeConnect, disconnect, verifyWebhook,
verifyChallenge and normalizeWebhook — the last of which turns a provider payload into
NormalizedEvent[].
Adding a channel is one adapter. Threads, deduplication, unread counts, contact resolution, realtime,
retries and outbound webhook fan-out are all channel-agnostic and already built; an adapter only has to
speak its provider's dialect. The adapters registered today are aggregator, the native Meta pair
(instagram.native / facebook.native), whatsapp, whatsapp_session, sms, email, livechat and
ai.
Runtime routing is binding-driven: credentials always come from the connection's ConnectionBinding,
never shared across integrations. Routing defaults live in integrations/integration.registry.ts.
Queues
Six BullMQ queues, all Redis-backed. Store selection is env-driven — with no INBOX_REDIS_URL the
service silently falls back to in-memory queues, which behave correctly within one process and lose
everything on restart.
| Queue | Work |
|---|---|
webhook-retry |
Redelivery of inbound provider webhooks |
outbound-retry |
Redelivery of outbound webhooks to consuming apps |
publish-dispatch |
Per-surface post dispatch |
campaign-send |
Bulk campaign sends |
flow-tick |
Flow run advancement |
inbox-import |
Historical import jobs |
Owned here, consumed from Accounts
| Concern | Owner |
|---|---|
Connections, bindings, credentials (AES-256-GCM at rest via KeyVaultService) |
Inbox |
| Threads, entries, participants | Inbox |
| Tickets, labels, notes, canned replies | Inbox |
| Contacts, identifiers, consent, segments | Inbox |
| Templates, campaigns, flows, warm-up | Inbox |
| Posts, dispatches, publishing | Inbox |
| Inbound and outbound webhooks | Inbox |
| Users, sessions | Axis Accounts — Inbox stores no users |
| User groups (workspaces) | Axis Accounts |
| Apps | Axis Accounts; Inbox keeps a mirror row for local config |
The mirror exists for one mechanical reason: Tenant.appId is a foreign key to the local app table,
so a request authenticated with an Accounts key would fail the tenant upsert until its app exists here.
Mirroring happens on the auth path, which keeps it self-healing — there is no separate provisioning step
to forget.