Aller au contenu principal
← Tous les projets
En construction
Messaging infrastructure · SaaS
Extracted from IntiGo
Q3 2026

Intigo Chat SaaS

Le backbone de messagerie colis qu'on fait tourner en interne, mis en produit sur intigo.chat — une seule couche chat pour tous les acteurs de la chaîne, vendue à d'autres opérateurs.

IntiGo fait déjà tourner un backbone de chat en production : il coud le chauffeur, le client, le marchand et l'agence dans un même fil par colis, à travers EnaGo, BizGo, le portail logistique, le portail partenaire et l'app client. Ce qui a démarré comme une feature dans le monolithe a gagné son propre repo (chat-server) — et devient aujourd'hui un SaaS multi-tenant sur intigo.chat. Mêmes fils, mêmes événements, mêmes couleurs de rôle — packagés pour que n'importe quel autre opérateur de livraison puisse insérer un token et récupérer la couche de messagerie qu'on aurait aimé pouvoir acheter.

Écrit par , ingénieur full-stack senior à Tunis. LinkedIn · GitHub

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

“Et si la couche de messagerie d'une entreprise de livraison s'achetait au lieu de se construire ?”

Le pari, en un paragraphe

Chaque opérateur logistique finit par reconstruire le même chat : un fil par expédition, cinq participants maximum (client, chauffeur, marchand, agence, système), accusés de lecture, notes vocales, push, un webhook qui remonte vers le backend de l'opérateur. Ils le reconstruisent parce qu'aucun SaaS de chat générique ne parle colis — ils parlent salons et canaux. On a déjà construit la version colis en interne. Le pari, c'est qu'en la transformant en produit tenant-scopé, elle vaut plus que si elle restait enfermée dans le monolithe.

Le chemin de migration est le nôtre : intigo-client sera le premier tenant payant — même UI, mêmes utilisateurs, mais qui parle à l'API SaaS via le SDK exactement comme le ferait un partenaire. Chaque cas d'erreur passe par IntiGo avant qu'un partenaire ne signe.

Pourquoi maintenant — trois conditions

D'abord, le noyau devait déjà exister. chat-server est séparé d'intigo-client depuis six mois ; il parle déjà WebSockets, gère déjà la création idempotente, envoie déjà du push via APNs et FCM. On ne construit pas le produit — on l'emballe.

Ensuite, les tenants devaient être réels. EnaGo, BizGo, le portail logistique, le portail partenaire et l'app client sont cinq surfaces indépendantes qui postent aujourd'hui dans les mêmes fils. Si ça tient sur cinq apps internes aux cycles de vie très différents (natif mobile, SPA, rendu serveur), ça tiendra sur les apps d'autres opérateurs.

Enfin, l'angle commercial devait être défendable. On ne vend pas du chat générique — on vend un SaaS en forme de colis à des entreprises qui expédient déjà des colis. Le démo d'onboarding, c'est un fil live avec une vraie référence colis, pas une page marketing.

Cinq rôles, un enum fermé

Le design a un contrat non-négociable : cinq rôles — client, chauffeur, marchand, agence, système — avec des couleurs fixes livrées par le SDK. Pas « personnalisables », pas « thémables », pas « extensibles ». Chaque app qui lit un message affiche la même couleur pour le même rôle sans un seul appel de config. C'est ce qui permet à un chauffeur d'ouvrir l'app pour la première fois et de savoir instantanément quelle bulle est celle du client.

Fermer l'enum force toutes les questions dures dès le début : qu'est-ce qu'un dispatcheur ? Une agence. Qu'est-ce qu'une mise à jour de statut automatique ? Système. Qu'est-ce que le chauffeur d'un sous-traitant ? Un chauffeur, scopé par workspace_id. À la seconde où tu autorises un sixième rôle, la promesse visuelle s'effondre.

La surface SaaS — ce qu'un tenant obtient vraiment

Un workspace avec deux clés (une secrète côté backend, une publiable pour le widget), un endpoint webhook signé, un flux de mint de JWT de 15 minutes, et sept SDKs qui lisent tous le même token : chat-js, chat-react, un widget clé en main, iOS (Swift), Android (Kotlin), Flutter, plus une spec OpenAPI pour tous ceux pour qui on n'a pas encore écrit de client.

Le modèle mental est en forme de Stripe : tu publies côté serveur, tu mint un JWT court par utilisateur, tu montes un composant côté client — le SDK fait le reste. La facturation est indexée sur les participants actifs mensuels (MAP) : 1 000 gratuits, 25 000 pour l'offre Pro, custom pour l'enterprise (single-tenant, résidence des données en Tunisie, propre IdP). La facturation au siège est une mauvaise forme pour la messagerie colis — un courrier onboarde 200 chauffeurs et des centaines de milliers de clients ; MAP suit la valeur qui est réellement livrée.

Notifications — un fan-out, cinq destinations

La partie difficile de tout produit de messagerie, ce n'est pas le message — c'est la notification qui suit. Quand un message est écrit, un seul commit Postgres émet un événement message.created, et le dispatcher réveille cinq canaux en parallèle : WebSocket /inbox (pour ceux qui sont en ligne, ~1s), APNs (pour iOS en arrière-plan), FCM (pour Android en arrière-plan), un digest email pour les participants qui n'ont pas ouvert l'app depuis 15 minutes, et le webhook du tenant pour que son backend puisse réagir (SMS, alertes internes, escalade à un humain).

Chaque canal a son propre retry avec son propre back-off et sa propre queue dead-letter. Si APNs est en panne, FCM part quand même. Si le webhook du tenant est mal configuré, le client reçoit quand même son push. Un opérateur peut désactiver le digest email pour un workspace sans toucher aux quatre autres canaux.

Où j'en suis, et ce qui vient

M1 — extraction du noyau — est fait. Le code chat-server vit dans son propre repo, sa propre CI, son propre pipeline de déploiement. M2 — tenant et identité — est en cours : provisioning des workspaces, gestion des clés, mint de JWT, console admin. M3 — le widget clé en main — vient après : un script tag, sans build, qui affiche un fil colis n'importe où. M4 — les SDKs partenaires et le motion commercial — ferme la boucle.

L'inconnue qui me tient honnête, c'est le pricing. MAP a l'air juste sur le papier ; le marché me dira s'il survit à une vraie conversation client. Tout le reste — la tech, le chemin de migration, le design — est dé-risqué parce qu'on le fait déjà tourner en production.

La surface produit

Quatre écrans, une seule épine dorsale.

Les captures ci-dessous sont des composants React rendus en direct — pas des images. Les cinq couleurs de rôle, les endpoints, les événements WebSocket sont ceux de la référence du design system.

intigo.chat01 · Page marketing
Fait pour les opérateurs logistiques

Messagerie colis,
prête à embarquer.

Une seule épine dorsale de chat pour toute la chaîne — client, chauffeur, marchand, agence. Un token, des fils scopés au colis, accusés de lecture, notes vocales et événements d'inbox en temps réel.

Créer un workspaceOuvrir le sandbox
Gratuit jusqu’à 1 000 participants actifs / mois · sans carte
parcel 8016397L12en direct
IS
Marchand · via BizGoLe client veut être livré demain matin. Faisable ?
SA
Agence · via LogisticsOui, on la met sur la tournée de 9 h.
L4
Chauffeur · via EnaGoColis chargé, arrivée vers 9 h 30.
app.intigo.chat/keys02 · Console développeur

Clés API et tokens

Une clé secrète par environnement. Les tokens sont scopés à votre workspace — chaque conversation créée lui appartient.

Workspace: co_transtu_9f31c0a4⧉ Copier
Clé de testsandbox · accès complet
sk_test_7f4••••••••••••••••••••••••••••••RévélerRégénérer
Clé liveproduction · accès complet
sk_live_c93••••••••••••••••••••••••••••••RévélerRégénérer
Clé publiablewidget · côté client
pk_live_2a71f6be9c4d40518e7b03cf9a12d557RévélerRégénérer
Endpoints et 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⧉
URL webhookhttps://api.transtu.tn/hooks/intigo-chat
app.intigo.chat/sandbox03 · Journal /ws/inbox
Un socket par utilisateur. Les mêmes événements que la prod, en local, sans notification envoyée.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 · Facturation à l’usage

Utilisation et facturation

Cycle 1 – 14 août 2026 · Pro
Messages envoyés184 302+12 % vs cycle précédent
Participants actifs12 48050 % du plan
Fils par colis6 914+8 %
Facturation en cours890 TNDAucun dépassement
Participants actifs mensuels12 480 / 25 000 · 50 %
1
2
3
4
5
6
7
8
9
10
11
12
13
14

Les couleurs de rôle ne changent jamais : client · chauffeur · marchand · agence · système. Une palette qu’on peut lire à travers cinq apps sans confusion.

Client#3d7ba8Chauffeur#2e7d52Marchand#e2884aAgence#7a5ca8Système#6b7683
Architecture en profondeur

Un backbone, plusieurs surfaces.

Intigo Chat SaaS n'ajoute pas une messagerie au produit — il la sépare du produit. Le noyau chat-server, jusqu'ici couplé à intigo-client, devient un service multi-tenant scopé à un workspace, exposé sur un même backbone à cinq surfaces (EnaGo, BizGo, portail logistique, portail partenaire, app client) et à des tenants externes via des SDKs et des webhooks. Trois contrats tiennent l'ensemble : un token par utilisateur, un même événement quel que soit son origine, une fan-out des notifications qui ne saute jamais un canal.

L’extraction — ce qui sort, ce qui reste

Ce qui part : le noyau chat-server (Node.js / TypeScript · WebSockets · fils par colis) et ses schémas Postgres — mais réécrits tenant-first, chaque table partitionnée par workspace_id. Ce qui reste dans intigo-client : l'UI actuelle, qui parle désormais à l'API SaaS via un SDK, exactement comme le ferait un client tiers. La même surface est déjà cliente de son propre backend — tous les cas d'erreur sont donc éprouvés en interne avant qu'un partenaire ne signe.

Tenant et identité — deux clés, un JWT court

Chaque workspace reçoit une clé secrète (serveur seulement, elle ne quitte jamais le backend du tenant) et une clé publiable (le widget peut la voir). Le token qui atteint la base de code du client — et donc le WebSocket — est un JWT de 15 minutes émis par POST /v2/auth/token, scopé à un utilisateur, un rôle, et un workspace. Rotation des clés : la précédente reste valide 24 h.

CléOù elle vitPortéeRotation
sk_live_…backend du tenantworkspace complet24 h de recouvrement
pk_live_…widget · côté clientcréation de fils, lectureà la demande
Bearer <jwt>apps clientes · SDKs1 utilisateur · 1 rôle · 15 minsilencieuse
whsec_…signature webhooksorigine sortanteà la demande
Modèle de données — quatre lignes, cinq rôles

Toute la donnée du chat vit en quatre tables, toutes partitionnées sur workspace_id. Le rôle est fermé — cinq valeurs, jamais six — c'est ce qui rend la couleur d'un message décodable à travers toutes les apps sans jamais renégocier.

workspacesLe tenant. id · slug · plan · rate_limits · created_at. Toute autre ligne du système a un workspace_id.
conversationsUn fil. id · workspace_id · context (type + ref) · created_by · updated_at. Un fil par colis — le context.ref est l'identifiant du colis, la clé d'idempotence côté tenant.
participantsQui peut voir le fil, et comment. conversation_id · user_id · role (client|driver| merchant|agency|system) · muted · last_read_at. Le rôle est fermé — cinq valeurs, jamais six.
messagesLa ligne write-heavy. id · conversation_id · sender_user_id · sender_role · body · attachments[] · origin (enago|bizgo|logistics|partner|api) · created_at. Indexée par (conversation_id, created_at), partitionnée par mois.
Notifications — un fan-out, cinq destinations

Quand un message est écrit, un seul commit Postgres suffit : il émet un message.created. Le dispatcher réveille en parallèle cinq canaux — WebSocket inbox, APNs, FCM, un digest email pour les absents, et le webhook du tenant qui doit réagir côté backend. Chaque canal a son propre retry et son propre dead-letter — un canal cassé n'en tue pas quatre autres.

Diagramme de fan-out des notifications Intigo ChatUn émetteur passe par chat-server, qui distribue en parallèle vers WebSocket inbox, APNs, FCM, email digest et webhook tenant.ÉmetteurPOST /v2/messageschat-servercommit · émet message.createdWebSocket · /inbox~1 s · en ligneAPNsiOS · fond d’écranFCMAndroid · fond d’écranEmail digestabsents · +15 minWebhook tenantHMAC · retry
Matrice de déploiement — surface × couche
SurfaceREST /v2WS threadWS inboxPushWebhook
Widget (script)✓✓———
@intigo/chat-react✓✓✓——
SDKs natifs (iOS · Android · Flutter)✓✓✓APNs · FCM—
Backend tenant (Node · Python · PHP)✓———✓ HMAC
  • Une même forme d'événement partout. Le message qui part d'EnaGo, du widget web ou d'un webhook tiers a exactement les mêmes champs — role, origin, context.ref. Les surfaces en aval sont aveugles à la provenance.
  • Cinq rôles, jamais six. L'énumération est fermée dans la base et dans les SDKs. Ajouter un rôle est une migration et un breaking-change de version majeure — c'est le contrat qui rend les couleurs décodables sans négociation.
  • La création est idempotente. POST /v2/conversations avec le même context.ref renvoie le même fil. L'origine reste responsable de sa clé — la SaaS ne devine pas.
  • Les webhooks sont signés et rejouables. HMAC sur le corps, fenêtre de replay de 5 minutes, une queue dead-letter exposée dans la console tenant.
  • Un canal cassé n'en tue pas quatre. Retry par canal, back-off exponentiel indépendant, dead-letter séparés. Un opérateur peut désactiver un canal sans que les autres s'en aperçoivent.
--:--:--·TUN