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.

Related

Tenancy · Data model · Glossary