Intigo Chat SaaS
The parcel-messaging backbone we run internally, being spun out at intigo.chat — one chat layer for every actor in the delivery chain, sold to other operators.
IntiGo already runs a chat backbone in production: it stitches driver, customer, merchant and agency into one thread per parcel, across EnaGo, BizGo, the logistics portal, the partner portal and the customer app. What started as a feature inside the monolith earned its own repo (chat-server) — and is now becoming a standalone multi-tenant SaaS at intigo.chat. Same threads, same events, same role hues — packaged so any other logistics operator can put a token in and get the messaging layer we wish we'd bought.
“What if the messaging layer of a delivery company could be bought instead of built?”
The bet, in one paragraph
Every logistics operator ends up rebuilding the same chat: a thread per shipment, five participants at most (customer, driver, merchant, agency, system), read receipts, voice notes, push, a webhook back to the operator's own backend. They rebuild it because none of the generic chat SaaS platforms speak parcels — they speak channels and rooms. We already built the parcel-shaped version internally. The bet is that turning it into a tenant-scoped product is worth more than keeping it locked inside the monolith.
The migration path is our own: intigo-client will be the first paying tenant — same UI, same users, but talking to the SaaS API through the SDK exactly like a third-party would. Every error case gets battle-tested at IntiGo before a partner signs.
Why now — three things had to be true
First, the core had to already exist. chat-server has been separated from intigo-client for six months; it already speaks WebSockets, already handles idempotent creation, already sends push through APNs and FCM. We're not building the product — we're wrapping it.
Second, the tenants had to be real. EnaGo, BizGo, the logistics portal, the partner portal and the customer app are five independent surfaces each posting into the same threads today. If it works across five in-house apps with wildly different lifecycles (native mobile, SPA, server-rendered), it will work across other operators' apps.
Third, the sales angle had to be defensible. We're not selling generic chat — we're selling a parcel-shaped SaaS to companies that already ship parcels. The onboarding demo is a live thread with a real parcel reference, not a marketing site.
Five roles, one closed enum
The design has one non-negotiable contract: five roles — customer, driver, merchant, agency, system — with fixed colours the SDK ships with. Not "themeable", not "customizable", not "extensible". Every app that reads a message renders the same colour for the same role without a single config call. This is what lets a driver open the app for the first time and instantly know which bubble is the customer's.
Closing the enum forces every hard question up-front: what is a dispatcher? An agency. What is an automated status update? System. What is a subcontractor's driver? A driver, scoped by workspace_id. The moment you allow a sixth role, the visual promise collapses.
The SaaS surface — what a tenant actually gets
A workspace with two keys (a secret one for the backend, a publishable one for the widget), a signed webhook endpoint, a 15-minute JWT mint flow, and seven SDK packages that all read the same token: chat-js, chat-react, a drop-in widget, iOS (Swift), Android (Kotlin), Flutter, plus an OpenAPI spec for anyone we haven't written a client for yet.
The mental model is Stripe-shaped: publish once server-side, mint a short-lived JWT per user, mount a component in the client — everything else the SDK handles. The paying surface is monthly active participants: 1 000 free, 25 000 for the Pro tier, custom for enterprise (single-tenant, data-residency in Tunisia, own identity provider). Pricing by seat is the wrong shape for parcel messaging — a courier onboards 200 drivers and hundreds of thousands of customers; MAP tracks the value that's actually delivered.
Notifications — one fan-out, five destinations
The hard part of any messaging product isn't the message — it's the notification that follows. When a message is written, one Postgres commit emits a message.created event, and the dispatcher wakes five channels in parallel: WebSocket /inbox (for anyone online, ~1s), APNs (for backgrounded iOS), FCM (for backgrounded Android), an email digest for participants that haven't opened the app in 15 minutes, and the tenant's own webhook so their backend can react (SMS, in-house alerts, escalation to a human).
Every channel retries independently with its own back-off and its own dead-letter queue. If APNs is down, FCM still fires. If the tenant webhook is misconfigured, the customer still gets their push. An operator can disable email digest for a workspace without touching the other four.
Where I am, and what's next
M1 — core extraction — is done. The chat-server codebase lives in its own repo, its own CI, its own deploy pipeline. M2 — tenant and identity — is in progress: workspace provisioning, key management, JWT mint, admin console. M3 — the drop-in widget — comes after: one script tag, no build step, renders a parcel thread anywhere. M4 — partner SDKs and the sales motion — closes the loop.
The unknown that keeps me honest is pricing. MAP looks right on paper; the market will tell me whether it survives a real customer conversation. Everything else — the tech, the migration path, the design — is de-risked because we already run it in production.
Four screens, one backbone.
The frames below are live React components — not screenshots. The five role hues, the endpoints, the WebSocket events are all pulled straight from the design-system reference.
Parcel messaging,
ready to ship.
One chat backbone for the whole delivery chain — customer, driver, merchant, agency. Drop in a token, get threads scoped to a parcel, read receipts, voice notes and real-time inbox events.
API keys and tokens
One secret key per environment. Tokens are scoped to your workspace — every conversation you create belongs to it.
Usage and billing
Cycle 1 – 14 August 2026 · ProRole hues never change: client · driver · merchant · agency · system. One palette a reader can follow across five apps without confusion.
One backbone, many surfaces.
Intigo Chat SaaS is not adding messaging to the product — it is separating messaging from it. The chat-server core, until now coupled to intigo-client, becomes a multi-tenant service scoped to a workspace, exposed on the same backbone to five surfaces (EnaGo, BizGo, logistics portal, partner portal, customer app) and to external tenants through SDKs and webhooks. Three contracts hold it together: one token per user, one event shape regardless of origin, and a notification fan-out that never skips a channel.
What leaves: the chat-server core (Node.js / TypeScript · WebSockets · parcel-scoped threads) and its Postgres schemas — but rewritten tenant-first, every table partitioned on workspace_id. What stays in intigo-client: today's UI, which now talks to the SaaS API through the SDK exactly like a third-party tenant would. The same surface is already a client of its own backend — every error case is battle-tested internally before a partner signs.
Every workspace gets a secret key (server-side only, never leaves the tenant's backend) and a publishable key (the widget is allowed to see it). The token that reaches the client codebase — and therefore the WebSocket — is a 15-minute JWT minted by POST /v2/auth/token, scoped to one user, one role, one workspace. Key rotation: the previous key stays valid for 24 h.
| Key | Where it lives | Scope | Rotation |
|---|---|---|---|
sk_live_… | tenant backend | entire workspace | 24 h overlap |
pk_live_… | widget · client-side | thread create, read | on demand |
Bearer <jwt> | client apps · SDKs | 1 user · 1 role · 15 min | silent refresh |
whsec_… | webhook signature | outbound origin | on demand |
All chat data lives in four tables, every one partitioned on workspace_id. The role is a closed enum — five values, never six — which is what lets the colour of a message decode across every app without renegotiating anything.
id · slug · plan · rate_limits · created_at. Every other row in the system carries a workspace_id.id · workspace_id · context (type + ref) · created_by · updated_at. One thread per parcel — the context.ref is the tenant's parcel id, the idempotency key on the tenant side.conversation_id · user_id · role (client|driver| merchant|agency|system) · muted · last_read_at. Role is a closed enum — five values, never six.id · conversation_id · sender_user_id · sender_role · body · attachments[] · origin (enago|bizgo|logistics|partner|api) · created_at. Indexed on (conversation_id, created_at), partitioned by month.When a message is written, one Postgres commit is enough: it emits a message.created. The dispatcher wakes five channels in parallel — WebSocket inbox, APNs, FCM, an email digest for the absent, and the tenant webhook for backend reactions. Each channel has its own retry and its own dead- letter — a broken channel never kills the other four.
| Surface | REST /v2 | WS thread | WS inbox | Push | Webhook |
|---|---|---|---|---|---|
| Drop-in widget | ✓ | ✓ | — | — | — |
@intigo/chat-react | ✓ | ✓ | ✓ | — | — |
| Native SDKs (iOS · Android · Flutter) | ✓ | ✓ | ✓ | APNs · FCM | — |
| Tenant backend (Node · Python · PHP) | ✓ | — | — | — | ✓ HMAC |
- One event shape everywhere. A message from EnaGo, the web widget or a third-party webhook has the exact same fields —
role,origin,context.ref. Downstream surfaces are blind to where it came from. - Five roles, never six. The enum is closed in the DB and in every SDK. Adding a role is a migration and a major- version breaking change — that's the contract that lets colours decode without negotiation.
- Creation is idempotent.
POST /v2/conversationswith the samecontext.refreturns the same thread. Origins own the key — the SaaS never guesses. - Webhooks are signed and replayable. HMAC on the body, 5-minute replay window, dead-letter queue exposed in the tenant console.
- A broken channel never kills four others. Per-channel retry, independent exponential back-off, separate dead-letter queues. An operator can disable one channel without the others noticing.