Skip to main content
← All projects
Building now
Messaging infrastructure · SaaS
Extracted from IntiGo
Q3 2026

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.

Written by , senior full-stack engineer in Tunis. LinkedIn · GitHub

Multi-tenant SaaSChat APIWebSocketsPush NotificationsAPNs / FCMWebhooksJWTHMACREST APIOpenAPITypeScriptNode.jsReactReact NativeSwiftKotlinFlutterPostgreSQLRedisPostgres partitioningIdempotencyRate limitingObservabilityDeveloper ExperienceProduct-led onboardingSpec-FirstGSD (Get Shit Done)OpenSpecClaude Code /loop

“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.

The product surface

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.

intigo.chat01 · Marketing page
Built for logistics operators

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.

Create a workspaceOpen the sandbox
Free up to 1 000 monthly active participants · no card required
parcel 8016397L12live
IS
Merchant · via BizGoCustomer wants delivery tomorrow morning. Possible?
SA
Agency · via LogisticsYes, moving it to the 9 a.m. round.
L4
Driver · via EnaGoParcel loaded, arriving around 9:30.
app.intigo.chat/keys02 · Developer console

API keys and tokens

One secret key per environment. Tokens are scoped to your workspace — every conversation you create belongs to it.

Workspace: co_transtu_9f31c0a4⧉ Copy
Test secretsandbox · full access
sk_test_7f4••••••••••••••••••••••••••••••RevealRotate
Live secretproduction · full access
sk_live_c93••••••••••••••••••••••••••••••RevealRotate
Publishablewidget · client-side
pk_live_2a71f6be9c4d40518e7b03cf9a12d557RevealRotate
Endpoints and webhooks
RESThttps://api.intigo.chat/v2⧉
Thread WSwss://ws.intigo.chat/conversations/{id}⧉
Inbox WSwss://ws.intigo.chat/inbox⧉
OpenAPIhttps://api.intigo.chat/openapi.json⧉
Webhook URLhttps://api.transtu.tn/hooks/intigo-chat
app.intigo.chat/sandbox03 · /ws/inbox event log
One socket per user. Same events as production, sandbox side, no push actually sent.wss://ws.intigo.chat/inbox
09:12:04message{ conversationId: "cv_9f31", senderLabel: "Driver #4652", origin: "enago" }⧉
09:12:04read{ userId: "merch_1180", cursor: "msg_88213" }⧉
09:11:52typing{ userId: "driver_4652", state: true }⧉
09:11:47push{ transport: "fcm", topic: "cv_9f31.merch_1180", collapseKey: "cv_9f31" }⧉
09:10:31message{ conversationId: "cv_9f31", senderLabel: "Sana Trabelsi", origin: "logistics2" }⧉
app.intigo.chat/billing04 · Usage-based billing

Usage and billing

Cycle 1 – 14 August 2026 · Pro
Messages sent184 302+12 % vs last cycle
Active participants12 48050 % of plan
Parcel threads6 914+8 %
Current charge890 TNDNo overage
Monthly active participants12 480 / 25 000 · 50 %
1
2
3
4
5
6
7
8
9
10
11
12
13
14

Role hues never change: client · driver · merchant · agency · system. One palette a reader can follow across five apps without confusion.

Customer#3d7ba8Driver#2e7d52Merchant#e2884aAgency#7a5ca8System#6b7683
Architecture deep-dive

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.

The extraction — what leaves, what stays

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.

Tenant + identity — two keys, one short JWT

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.

KeyWhere it livesScopeRotation
sk_live_…tenant backendentire workspace24 h overlap
pk_live_…widget · client-sidethread create, readon demand
Bearer <jwt>client apps · SDKs1 user · 1 role · 15 minsilent refresh
whsec_…webhook signatureoutbound originon demand
Data model — four rows, five roles

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.

workspacesThe tenant. id · slug · plan · rate_limits · created_at. Every other row in the system carries a workspace_id.
conversationsA thread. 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.
participantsWho can see the thread, and how. conversation_id · user_id · role (client|driver| merchant|agency|system) · muted · last_read_at. Role is a closed enum — five values, never six.
messagesThe write-heavy row. 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.
Notifications — one fan-out, five destinations

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.

Intigo Chat notification fan-out diagramA sender goes through chat-server, which dispatches in parallel to WebSocket inbox, APNs, FCM, email digest, and the tenant webhook.SenderPOST /v2/messageschat-servercommit · emits message.createdWebSocket · /inbox~1 s · onlineAPNsiOS · backgroundedFCMAndroid · backgroundedEmail digestabsent · +15 minTenant webhookHMAC · retry
Deployment matrix — surface × layer
SurfaceREST /v2WS threadWS inboxPushWebhook
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/conversations with the same context.ref returns 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.
--:--:--·TUN