Back to the site

Integrations

How Ghala CRM takes data in from Ghala, SemaCall, Sarufi and anything else, and how it tells your systems what it did with it.

One pipeline in, one adapter per source:

delivery -> verify signature -> record raw -> dedupe -> normalise -> resolve identity -> timeline -> triage

Everything after "record raw" is idempotent, because at-least-once delivery with no ordering guarantee is the norm and pretending otherwise produces duplicate conversations.

One pipeline out, the mirror image: every case opened, moved, handed over, commented on or breached is written to an outbox in the same transaction as the change, and delivered to the webhook endpoints your workspace registers, signed the same way you sign deliveries to us. See Webhooks out below.

Every source signs. A delivery to a connection with no signing secret is refused with 403, whatever the source: the routing key is an external account id, printed in the source's own dashboard, and an unsigned delivery accepted on it would let anyone who learns that id plant verified identities, escalations and consent decisions in the tenant.

Every request has a handle. Send X-Correlation-ID (letters, digits, -, _, ., :, at most 128 characters) and it is stamped on every row and audit entry the delivery produces and echoed back; omit it and one is minted. Every response, refusals included, carries correlation_id in the body and X-Correlation-ID in the headers, so "CRM rejected delivery X" is a line somebody can follow to the reason.


Where each source stands

SourceStatusHow it reaches us
GhalaSpecified and implementedNative event subscriptions, HMAC-signed. Ghala already ships this
GmailSpecified and implementedOAuth + cron-driven cursor sync against Gmail's real API
SemaCallSpecified and implementedPulled from SemaCall's real application API on a cursor (below); the generic contract remains its push fallback
Messenger and InstagramSpecified and implementedFacebook Login for the Pages and the Instagram accounts linked to them, Meta's webhook signed with the app secret. See Messenger and Instagram below
SarufiSpecified and implementedNative adapter over the published contract: the conversation's turns, plus a captured lead and a handoff, HMAC-signed; also still reaches us through Ghala
Support portalSpecified and implementedA page we host, or the same page framed inside your product, where a customer asks for help and follows the answer. See Support portal below

Sarufi is worth being precise about, because it reaches us two ways and they overlap. Its output already arrives inside Ghala's events: a message carries source (AI vs HUMAN vs CAMPAIGN), and conversation.escalated carries the reason the agent gave up plus the customer's last message. That path covers every conversation Sarufi runs on a Ghala number, and needs nothing from Sarufi itself.

It now also posts the generic contract directly, which is the only path for a conversation that never touches Ghala at all: a web chat, a Sarufi-owned SMS or USSD session, or a WhatsApp number connected to Sarufi rather than Ghala. See Sarufi below.


SemaCall

Source of truth: apps/backend (calls.rs) and apps/mcp-server in the sema repository — the adapter is written against SemaCall's real CallRow, not an invented webhook.

SemaCall does not push events outward, so the CRM pulls: a cron tick (/api/cron/call-sync, every minute) walks GET {base}/app/v1/businesses/{business_id}/calls?since=... with the workspace's SemaCall API key as a bearer token, so SemaCall's own backend enforces tenancy on every fetch.

Setup.

bun run connect -- --workspace "Faida Microfinance" --source semacall \
  --account <business_id> --api-key <semacall api key> [--base-url https://...]

Set SEMACALL_API_BASE in the environment (or --base-url per connection for self-hosted SemaCall). A semacall connection without an API key is left alone by the sync and can still post the generic contract.

What a call becomes. One conversation per caller, one call interaction per call on its timeline: a person who rings twice is one thread with two calls on it, the way two WhatsApp messages are one thread, not two rows in the inbox. The thread is keyed on the number's digits; a withheld number, or an outbound call, keeps its own thread. Each call carries the caller number as a verified phone claim (the telco asserted it), a summary-first body, duration/agent/team/topic/sentiment and recording presence in attributes. Recordings stay in SemaCall (fetched by presign there); storing audio here waits on the object-storage trigger in ADR-014, the same rule email attachments follow.

Missed calls escalate. A missed inbound call or a voicemail sets the escalation flag, and the pipeline opens a case for the conversation unless one is already active — a customer who rang and was not answered is an escalation nobody had to type in. Outbound calls never escalate: an unanswered campaign call is the campaign's business.

History. A new connection backfills the ENTIRE call history, resumably: the walk pages newest-first up to 1,000 calls per cron tick and picks up where it left off, so thousands of calls drain in a few minutes without a tick ever overrunning. Set backfill_days in the connection's config to bound it. "Sync full history" on the channel card restarts the walk — already-known calls dedupe, so it is safe to press twice.

Enrichment. Transcription and analysis land minutes after a call ends. Each incremental fetch reaches 24 hours behind the cursor, and the delivery id carries a fingerprint of the enrichment milestones (ended_at, transcript_status, analytics_status): a call with new enrichment re-ingests and refreshes the same timeline row; an unchanged one dedupes like any webhook retry.


Ghala

Source of truth: docs/DEVELOPER_API.md in ghala-backend.

Setup. Register https://<your-domain>/api/ingest/ghala as an event subscription on the number. Ghala returns a signing secret exactly once. Store it on the connection:

bun run connect -- --workspace "Faida Microfinance" --source ghala \
  --account <credential_id> --secret whsec_... --country 255

--account is the credential_id Ghala puts in every envelope; it is how a delivery finds its workspace.

Delivery.

POST /api/ingest/ghala
X-Ghala-Signature: sha256=<hex(HMAC-SHA256(secret, "{timestamp}.{raw_body}"))>
X-Ghala-Timestamp: <unix seconds>
X-Ghala-Event:     message.received
X-Ghala-Delivery:  <ULID, stable across retries>

{ "event": "...", "occurred_at": "...", "credential_id": "...", "data": { ... } }

We verify over the exact received bytes, compare in constant time, and reject a timestamp more than 300s old.

What each event becomes.

EventTimeline rowNotes
message.receivedmessage, inboundContact phone and id become verified identifiers
message.sentmessage, outboundattributes.actor distinguishes AI from human
conversation.escalatedescalationattributes.needsCase is the hook phase 2 turns into a case
order.paidorderTotals kept as strings; a JSON float would re-round money
message.statusnoneRecorded for replay, not a timeline row
template.statusnoneDelivery plumbing, not something the customer did
account.errornoneSame

The last three are stored in inbound_events with status ignored, so if we later decide they belong on the timeline, the history is there to replay.


Gmail

The first email provider, behind the same conversation contract as every other channel. Unlike the webhook sources, Gmail is pulled: a workspace admin connects a mailbox with OAuth (/api/integrations/gmail/start?workspace=<slug>, scopes gmail.readonly and gmail.send), and a cron tick (/api/cron/email-sync, guarded by CRON_SECRET) drains each connected mailbox through the same record -> dedupe -> identity -> conversation pipeline.

Mailboxes connected before replies shipped hold a read-only grant and keep it. Google mints a token for the scopes consented to at the time, so an older connection can still ingest and cannot send, whatever the authorize URL now asks for. The granted scope string is recorded on the connection, replies are refused before a row is queued when it does not cover sending, and the fix is a human reconnecting the mailbox. A scope upgrade should be somebody's decision rather than something that happens to their mailbox because we deployed.

ConcernHow
CursorGmail historyId, stored on the connection; advanced one history record at a time, after every message in that record is durably ingested, at most 200 messages per tick per mailbox and within a per-mailbox share of the tick, so a busy inbox catches up across ticks instead of restarting each one. Expired cursors fall back to a recent window and dedupe absorbs the overlap
ThreadingGmail's own threadId becomes conversations.external_thread_id; Message-ID/In-Reply-To are kept in attributes, and a reply is assembled from both so the customer sees an answer rather than a new email
RepliesQueued through the same outbox and consent gate as WhatsApp, sent with users.messages.send on the thread's threadId. Refused before queueing when the customer has no email on record or the mailbox's grant is read-only
IdentityThe counterparty's address becomes an email identifier claim, verified only when Gmail's Authentication-Results shows dkim=pass or spf=pass. A spoofable header never auto-attaches to a customer
DirectionThe SENT label; the counterparty is From on inbound, the recipient on outbound
ContentSanitized text body, bounded at 20k chars (text/plain preferred, HTML flattened); attachments as metadata only until object storage lands (ADR-014)
Dedupegmail:<message id> as the delivery id, so replays and window overlaps store nothing twice
FailureSync errors are recorded on the connection (sync_error) and retried next tick; a revoked token asks for reconnection

Env: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET (OAuth client with the Gmail API enabled and <APP_URL>/api/integrations/gmail/callback as redirect URI), CRON_SECRET for the sync tick, CREDENTIALS_KEY to seal the refresh token at rest (the connection row never holds it in clear).


Messenger and Instagram

Two channels from one provider. A Facebook Page's Messenger inbox and an Instagram professional account's direct messages both arrive from Meta over one webhook and both reply through the same Send API, so they share an adapter (lib/sources/meta.ts) and a Graph client (lib/meta/graph.ts). The CRM still sees two sources, messenger keyed on the Page id and instagram keyed on the Instagram account id, because Meta identifies them by different ids and a person thinks of them as two channels.

An Instagram account has two ways in. Through the Facebook Page it is linked to, with Facebook Login, which also connects the Page's Messenger inbox and offers every Page the person manages. Or as itself, with Instagram Login, which needs no Page and connects one account. Both land on the same instagram row, keyed on the professional account's own id (the one Meta names it by in webhooks under either login), so an account connected one way and later the other keeps its threads and customers. The credentials say which way the row reaches Meta (lib/meta/graph.ts accessOf): credentials.page_access_token reads and sends as the Page on graph.facebook.com, credentials.instagram_access_token as the account on graph.instagram.com. A connect replaces the credentials whole, so the later way wins. The Instagram source page leads with Continue with Instagram and offers Continue with Facebook under it, each only when the deployment holds that provider's app (lib/source-specs.ts signInWays).

Connecting with Facebook. A workspace admin signs in with Facebook (/api/integrations/meta/start?workspace=<slug>). Meta sends the browser back to the site root, <APP_URL>/, because its dashboard keeps only the origin of a Facebook Login redirect URI and the dialog demands an exact match; the proxy recognises the visit by the signed state it carries and rewrites it to /api/integrations/meta/callback. The callback exchanges the code for a long-lived token, lists the granted Pages, and writes them down as an offer (meta_connect_offers, each Page token sealed, good for 15 minutes) rather than connecting anything. It then sends the admin to Choose what to connect (/crm/<slug>/channels/meta), which lists every granted Page with its Messenger inbox and the Instagram account linked to it, each with a box to tick. Only what is ticked becomes a channel: a messenger connection per chosen Page and an instagram connection per chosen account, the Page token sealed in credentials.page_access_token, and the Page subscribed to the app when either of its inboxes was chosen (an Instagram account's messages arrive through its Page's subscription). A channel already connected here is shown ticked and locked, stays connected, and has its token refreshed, which is also the recovery when Meta revokes one; ending it is its own channel page's job. A Page or account live in another workspace is shown locked with the reason, and stays there. One that was disconnected there does not stand in the way: uniqueness of (source, external_account_id) is over live rows only (connections_live_account_idx), so the old row keeps its history and the new workspace gets a row of its own. Each channel's page leads with the account it is, picture, name and handle, read from Meta through the Page token on each visit (the picture URLs are signed and expire) and from the copy connect stored when the token no longer works.

Connecting with Instagram. The admin signs in with Instagram (/api/integrations/instagram/start?workspace=<slug>), as the professional account itself, on instagram.com. The dialog asks for instagram_business_basic and instagram_business_manage_messages and comes back to the same site root, with a state whose audience the proxy routes to /api/integrations/instagram/callback. The callback exchanges the code at api.instagram.com for a short-lived token, trades it for a long-lived one (sixty days), asks graph.instagram.com /me which account that is (user_id, the professional account id), and connects it (lib/meta/instagram-login.ts): the row claimed or refreshed under the same rules as the picker, the token sealed in credentials.instagram_access_token with its expiry beside it, the account subscribed to the app (POST /{ig-id}/subscribed_apps), the connect audited. One account, so there is nothing to choose. A live row in another workspace refuses (instagram-in-use). Unlike a Page token the account's own expires, so /api/cron/instagram-tokens runs daily and renews every token that is a day old and within a month of expiring; one Meta refuses to renew is written on the channel as sync_error, where the channel page says to sign in again.

Receiving. Meta posts to /api/webhooks/meta, signed with the app secret (X-Hub-Signature-256) rather than a per-connection secret, and verifies the endpoint with a GET handshake against META_VERIFY_TOKEN first. The app's Instagram product signs with a secret of its own, so a delivery is accepted under META_APP_SECRET or INSTAGRAM_APP_SECRET. The entry id names the account under either login, and an account subscribed both ways dedupes on the message id. One request can carry many Pages and many messages; each message becomes its own delivery, keyed on Meta's message id, so a retry stores nothing twice. The answer is 200 for anything that was Meta's to send, connected here or not: Meta unsubscribes a Page after an hour of non-200s, so refusing an unknown Page would eventually silence a known one in the same batch.

ConcernHow
ThreadMeta scopes a person's id to the Page or account they wrote to (a PSID or IGSID), so the pair is one conversation; that scoped id is external_thread_id
IdentityThe scoped id becomes a verified external_id claim in the meta.messenger or meta.instagram namespace: Meta only issues it because a message arrived from that person. A person the workspace has not met is asked for their name once, through the Page token, so the customer is created under it
ContentText as the body; photos, videos, voice notes and files as mediaUrl plus an attachments list, with a one-line description when there are no words. A button press arrives with its title as the body
EchoesOur own sends come back as message_echoes and are stored outbound, so the thread shows what the customer was told
WindowsInside 24 hours a reply is a plain response. From one to seven days it goes out tagged HUMAN_AGENT, which the app must be approved for. After seven days Meta refuses everything, and the CRM refuses before queueing (window-closed) and seals the composer, because only the customer can reopen the thread
RepliesQueued through the same outbox and consent gate as WhatsApp, sent to /me/messages as the Page or as the account, whichever token the connection holds; the recipient is the thread's scoped id
Dedupe<platform>:<message id> as the delivery id; interactions dedupe on the message id too

On Meta's side. In the app's dashboard: under Facebook Login settings, add <APP_URL>/ (the origin with its trailing slash) to Valid OAuth Redirect URIs; add the Webhooks product, set the callback to <APP_URL>/api/webhooks/meta with META_VERIFY_TOKEN as the verify token, and subscribe the page object to messages, messaging_postbacks and message_echoes and the instagram object to messages and messaging_postbacks. The app needs pages_show_list, pages_messaging, pages_manage_metadata, pages_read_engagement, instagram_basic, instagram_manage_messages and business_management, in Advanced Access after App Review for Pages nobody on the app has a role on, plus the Human Agent feature for replies after 24 hours. An Instagram account must also have "Allow access to messages" on under its connected-tools settings.

For Instagram Login, add the Instagram product to the same app and set up "API setup with Instagram login": it shows an Instagram app id and secret of its own, which are INSTAGRAM_APP_ID and INSTAGRAM_APP_SECRET. Under its business login settings add <APP_URL>/ to the OAuth redirect URIs, and under its webhooks set the same callback, <APP_URL>/api/webhooks/meta, with META_VERIFY_TOKEN, subscribed to messages and messaging_postbacks. The product needs instagram_business_basic and instagram_business_manage_messages, in Advanced Access after App Review for accounts nobody on the app has a role on.

Env: META_APP_ID, META_APP_SECRET (signs the webhook and the login exchange), META_VERIFY_TOKEN (any string you choose, the same one entered in the dashboard), META_GRAPH_VERSION (optional, defaults to v25.0), META_LOGIN_CONFIG_ID (optional: a Facebook Login for Business configuration id, used instead of naming permissions), INSTAGRAM_APP_ID and INSTAGRAM_APP_SECRET (the Instagram product, for Instagram Login; without them the Instagram source offers Facebook Login alone), CREDENTIALS_KEY to seal the tokens at rest.

TikTok direct messages will be a sibling of this: its own source, its own webhook route and token, the same conversation contract and the same outbox. Nothing here is Meta-shaped past lib/meta/ and lib/sources/meta.ts.

Disconnecting. On the channel page under Channels, the band at the foot says what disconnecting does: Ghala CRM removes its subscription from the Page (DELETE /{page-id}/subscribed_apps, the Page from config.page_id for an Instagram channel reached through one) or from the account (DELETE /{ig-id}/subscribed_apps on graph.instagram.com, for one connected as itself), forgets the token, and refuses anything that still arrives. Messenger and Instagram on one Page share that subscription, so while the other channel is still connected it is kept and only the disconnected row is forgotten. The messages already captured stay on the timelines. Removing the app from the Page entirely is done in Facebook's own settings; connecting the Page again from Channels revives the channel.


Sarufi

Source of truth: src/platform/services/crm/ in Conversation-Engine-v3.

Sarufi pushes the generic contract itself, per chatbot, over the sarufi source. It sends two events, both once per conversation, so the endpoint is never in the path of every message.

Setup. Create the connection here first, then configure the chatbot with the printed secret.

bun run connect -- --workspace "Faida Microfinance" --source sarufi \
  --account <chatbot account id> --secret whsec_... --country 255

--account is whatever the chatbot sends as account_id; it is how a delivery finds its workspace. The connection's signing secret is required, like every other source's: an unsigned delivery is refused with 403.

What each event becomes.

EventTimeline rowNotes
conversation.messagesone message per thing saidThe conversation itself: what the customer wrote and what the bot wrote back
lead.capturednote, inboundThe lead's own id is an external_id claim in the sarufi namespace
conversation.escalatedescalation, internalbot.handoff: true, so the pipeline opens a case

A turn that carries a bot block without handoff is one the bot took. It lands on the thread and the customer's timeline, but it does not put the thread in front of a person: the hand-off is what asks for one. This holds for any source posting the generic contract, not only Sarufi.

The conversation itself

A lead and a handoff without the turns behind them hand an agent a case whose whole content is "the bot gave up". conversation.messages is what makes the other two worth having.

A turn is Conversation-Engine-v3's own ConversationMessage: one row carrying the customer's message and the reply to it. So one turn becomes more than one row here — an inbound row for what she said, and one outbound row per message the bot said back, because that is how she received them. The engine already splits a turn this way when it mirrors one to a tenant's database (external_storage/db/pipeline.py), and the ids follow those -in / -out suffixes so both halves of the integration key a turn the same way.

{
  "account_id": "<chatbot account id>",
  "event": "conversation.messages",
  "occurred_at": "2026-08-23T09:41:00Z",
  "customer": { "name": "Neema Mushi", "phone": "+255699920009", "verified": true },
  "conversation": { "id": "01JCONV...", "channel": "whatsapp" },
  "turns": [
    {
      "id": "01JTURN1",
      "message": { "text": "Habari, nataka mkopo wa biashara" },
      "response": { "messages": [{ "type": "TEXT", "text": "Karibu! Ni kiasi gani?" }] },
      "message_type": "TEXT",
      "responder": "BOT",
      "responder_name": null,
      "received_at": "2026-08-23T09:38:00Z",
      "responded_at": "2026-08-23T09:38:04Z",
      "sensitive_redacted": false
    }
  ]
}

Interactions become 01JTURN1-in and 01JTURN1-out-0, both on thread 01JCONV.... A few things this settles:

  • Rows carry the turn's own times, not the delivery's. A batch posted at handoff must not date the whole conversation to the moment it was posted, or the 24-hour window is measured from the wrong end.
  • responder distinguishes a human from the bot. Someone answering through Sarufi's own inbox is not the bot, and a thread that calls them the same thing is lying about who spoke.
  • sensitive_redacted is shown, not swallowed. The engine redacts before a turn leaves it; a blank message nobody can explain is worse than one that says why it is blank.
  • A page, not a transcript. 200 turns is the most one delivery may carry — either the turn as it happens, or the conversation so far at handoff. A backfill of everything belongs on a cursor, not on this endpoint.

One thread for all three. Messages are threaded on conversation.id — the engine's conversations row, durable and keyed on chatbot + user + channel. Leads and handoffs used to be threaded on the runtime session_id instead, a Redis id the orchestrator mints per session and clears on a terminal state, so a returning customer's outcomes landed on a new thread each time and none of them could have matched the turns. The engine now resolves the Conversation row for those two as well (thread_for).

It falls back to the session id in one case: a handoff on the very first turn, which can beat the persistence task that creates the row. That escalation opens its case on a thread of its own. Rare, and the alternative — writing a conversation row from the forwarder — risks duplicating a web visitor's thread, which is worse.

A web visitor is not a stranger. A web thread is keyed by a minted web_<ULID>, which names nobody, so on its own every conversation would arrive as a new person. Sarufi sends what the widget collected at connect instead — the name they gave, an email, a phone they typed, and the id another product knows them by, namespaced to whoever minted it so a Snippe id cannot collide with one of ours. None of it is proven, so a claim that conflicts with an existing record goes to the review queue rather than merging on its own.

The thread is the session. Both events carry the Sarufi session id as interaction.thread, so a lead and a later handoff from the same conversation land on one conversation here, and the case opens against it. An interaction with no thread belongs to no conversation, and a case cannot be opened for it.

Verified means the phone came from the network. The verified flag on the customer block says the number was asserted by WhatsApp, SMS or USSD rather than typed into a form — Sarufi knows which, and says so, rather than the CRM guessing from the channel.

The CRM then decides per claim: vouch() marks a phone proven only when the sender said so and the channel could assert it, and never marks an email or an external id proven at all. So an email travelling beside a WhatsApp number is recorded as the unproven claim it is instead of demoting the number. A captured lead is never verified, because its phone came off a capture form whatever channel that form was shown on.

Ids are stable, so a replay dedupes. A lead is keyed on its record id and a handoff on its session and timestamp, at both the delivery header and interaction.id, so re-sending either corrects the row rather than duplicating it.

What lib/sources/sarufi.ts refuses. The two known events are checked against the shape above rather than accepted as generic traffic, because their failures are silent ones — a delivery that is recorded and reads as handled when it is not. Each is a 400 naming the field:

RefusedBecause
No interaction.thread / conversation.idThe row belongs to no conversation, so no case can open. Sarufi believes it handed the customer over; nobody is holding them
A turn with no id, or no received_atA replay would double the transcript, and a row with no time cannot be placed on the thread
A turn carrying neither a message nor a responseAn empty row on a timeline is a conversation nobody had
No interaction.idA replay would duplicate the row instead of correcting it
conversation.escalated without bot.handoff: trueThe flag is what opens the case; an escalation without it is a contradiction
channel: voiceSemaCall owns the phone line, and the same call on both paths is one call twice on one timeline
A kind or direction that disagrees with the eventA lead is an inbound note and a handoff an internal escalation; anything else is a mistake worth seeing

An event this adapter does not know falls through to the generic contract rather than being refused, so a chatbot that grows a third one does not start failing for the two that work.

The adapter decides what was proven, not the flag. verified arrives for the whole customer block, and the CRM treats it as a claim about the channel rather than a fact: a phone is proven on WhatsApp, SMS or USSD, where the network asserted it, and nowhere else. An email is never proven. A wrong claim here merges two people's histories, so it is earned on this side too.

Replies

The CRM replies on a chatbot's web and WhatsApp chats through Sarufi's developer API, POST /api/dev/v1/chatbots/{chatbot_id}/messages. The chatbot is the connection's account id, so it must be the chatbot's id from Sarufi. Replies need two more things on the connection, both optional and both sealed:

  • API key: a Sarufi workspace API key, for the workspace that owns the chatbot. Sent as Authorization: Bearer.
  • Chatbot webhook secret: the webhook_secret in the chatbot's settings. Each send is signed with it in X-Sarufi-Signature: t=<unix>,v1=<hex>, HMAC-SHA256 over "<t>." + body. A Sarufi under another brand names the header after its own prefix; set SARUFI_HEADER_PREFIX to match.

Saving the keys asks Sarufi whether the key reaches the chatbot, and the drawer says if it does not. The secret is only proven by a send.

Web. A reply goes to the conversation id the thread is keyed on. When Sarufi sends it straight away, the reply reads "Sent". When the visitor is away, Sarufi keeps it and shows it when they come back, for up to 7 days: the reply reads "Waiting for the visitor". Sarufi sends no receipt, so the outbound cron reads the chat's visitor_seen_at (GET /api/dev/v1/chatbots/{chatbot_id}/conversations/{conversation_id}, UTC with no zone) every 5 minutes, and a reply turns to "Seen" once that time is after it. Sarufi moves it when the widget says the visitor has the chat open in front of them, and when the visitor writes or sends a file. A waiting reply turns to not delivered after 7 days without a visit. A sent reply was delivered, so after 7 days it is no longer asked about and stays "Sent". The cron asks about 10 chats per connection a minute at most, well inside the 60 reads a minute Sarufi allows a key, and never for a paused channel.

When Sarufi refuses the API key on that read (401 or 403), the channel says "API key refused" in its drawer and on Channels, since the same key sends replies. It clears when a read succeeds again or a new key is saved.

The person's name. Every send carries the replying person's name as sender_name, trimmed and cut at 60 characters, and left out when they have none:

{"channel":"WEB","to":"<conversation_id>","message":{"type":"text","body":"Habari"},"sender_name":"Neema Mushi"}

It is part of the signed body. Sarufi files the reply as that person's, and the widget shows it under their name instead of the bot's; without it, Sarufi uses the API key's name. An older Sarufi ignores the field.

WhatsApp. A reply goes to the number that wrote on the thread (the customer's number on record if none did), in the 24 hours after their last message. There are no templates through Sarufi, so after that nothing can be sent until they write again.

What Sarufi's answers mean for the reply.

Sarufi saysThe reply
201, sent or queuedSent, and put on the thread by the CRM: Sarufi does not send it back
401, 403Held, not counted as an attempt, until the key or secret is fixed
404Not sent: the chat no longer exists on Sarufi
410Not sent: the visitor has been away more than 7 days
422 window_closedNot sent: the WhatsApp window has closed
429Retried with backoff
5xx, no answer in 15 seconds, or a dropped connectionNot confirmed, and not retried: Sarufi has no idempotency on this route, so a retry could deliver twice. "Send again" is left to a person

Never twice. Before the CRM calls Sarufi, the reply is marked as not confirmed; Sarufi's answer turns it to sent. Whatever is lost after the call, a crash, a timeout or a database error, leaves it unconfirmed and out of the retry loop.

A thread whose channel Sarufi did not send (filed as chat) cannot be replied to: Sarufi only takes a web send on a web conversation.

A visitor nobody identified. A web chat can be answered with no customer behind it: the visitor wrote first, and the reply goes back into that same chat, to nobody's phone or inbox. With no customer there are no preferences to read, so the outbound row records the verdict as "a reply to the visitor who wrote, in the same chat". A WhatsApp chat still needs a customer.

Which chatbot. A thread, its side panel and its inbox row name the chatbot it came through, from the connection's label. Sarufi's webhook carries only an account id, so a connection with an API key and no label takes the chatbot's name from GET /api/dev/v1/chatbots/{chatbot_id}: when the keys are saved, and once from the outbound cron for connections that had keys already. A label a person typed, or set in the channel's drawer, is never replaced.

Who answers. A reply from the CRM takes the chat from the bot for 15 minutes, and the thread's "Answering" row can pause the bot or hand the chat back. This needs a Sarufi engine with the developer API's hold and release (engine PR #121); a held web chat stays held when the visitor closes the widget, and the visitor sees the reply under the sender's name (engine PR #122).

Known limits. Sarufi sends no event when a visitor sees a reply, so the CRM keeps asking. SMS and USSD chats take notes only.


Support portal

The other way in: not a channel a customer already uses, but a page we host where they ask for help and follow what they asked, and the same page framed inside your own product. Turned on under Integrations → Support portal, which prints everything below for your workspace.

What a request becomes here is exactly what a WhatsApp escalation becomes: a conversation on the web channel from the portal source, with a case on it opened as source = 'portal', on the queue at once. A follow-up is a message on that thread. A reply from the composer is what the customer sees on the page, and, when their address is proven, an email saying to come and read it. Notes and case comments never leave the workspace.

The page

https://<your-domain>/help/<portal key>. A visitor signs in with a six-digit code sent to their email (turn this off if only your product should sign people in). That email becomes a verified identifier, so the requests they make attach to the right customer record and to the history your other channels already hold for them.

The embed

<script src="https://<your-domain>/portal.js" data-portal="pk_…"></script>

A launcher appears in the corner of your page and opens the Help page in a frame. Your page never sees the customer's requests and we never run in your document. Attributes: data-launcher="false" to bring your own button (then call GhalaPortal.open()), data-label for the launcher's text, data-theme="light" | "dark" | "auto" for the panel's colours (light unless said; auto follows the visitor's system). The panel is a card at the bottom right of a desktop page and the whole screen on a phone, where the page's own close button in the panel's header closes it. The hosted page takes the same choice as ?theme=.

List the sites that may embed it under the portal's settings; a page on an origin not listed can still open the frame, but its scripts get nothing from the API.

Trying it before shipping it

The portal's settings carry a stand-in for your product: a page served by the CRM at /crm/<workspace>/portal/preview with the snippet above on it and nothing else that matters. Its simulator bar picks who the page pretends is logged in (a customer your product knows, with a name, an email, a phone and an id, or nobody), and the CRM signs that identity with the portal's own secret the way your backend will, so the widget can be used exactly as a customer would use it, and what they send lands on the queue as a real case. "Download as HTML" hands the same page back without the simulator, to host on a site of your own for a wider trial; list that site under the portal's origins first. The identity in a downloaded file is good for an hour.

Telling us who is logged in

Your product knows who is signed in. Say so, signed, and they see their own requests without signing in again:

identity = base64url(json) + "." + hex(HMAC-SHA256(secret, base64url(json)))

json = { "sub": "<their id in your system>",   // required
         "name": "Neema Mushi",                 // optional
         "email": "neema@example.com",          // optional, unverified
         "phone": "+255699920009",              // optional, unverified
         "exp": 1788700000 }                    // required, unix seconds, ≤ 24h away
// Node, in your backend, once per page load
const claims = { sub: user.id, name: user.name, email: user.email,
                 exp: Math.floor(Date.now() / 1000) + 3600 }
const payload = Buffer.from(JSON.stringify(claims)).toString("base64url")
const signature = crypto.createHmac("sha256", PORTAL_SECRET).update(payload).digest("hex")
res.render("dashboard", { portalIdentity: payload + "." + signature })
<script>GhalaPortal.identify("<%= portalIdentity %>")</script>
# Python
import base64, hmac, hashlib, json, time
claims = {"sub": user.id, "name": user.name, "exp": int(time.time()) + 3600}
payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).rstrip(b"=").decode()
signature = hmac.new(PORTAL_SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()
identity = f"{payload}.{signature}"

The secret is the one printed when the portal was turned on, or rotated since; it signs identities and nothing else, and it belongs in your backend only. sub becomes a verified external_id identifier in the namespace portal:<key>, which is how a returning customer is the same customer. The email, phone and name are the host's word, not proof: they never name or merge a customer, but they are kept on the customer's card as contact, so the team can reach whoever wrote. They fill empty fields only. An identity signed with a secret that has since been rotated is refused.

Your own UI over the API

Everything the page does is the API below, JSON, under /api/portal. From your backend, no CORS is involved. From your pages, the origin must be on the portal's list and every URL must carry ?key=<portal key>, which is what the browser's preflight is answered from. Every answer carries correlation_id.

CallBodyAnswer
POST /session{ key, identity }{ token, customer: { name, known } }
POST /email/start{ key, email }{ ok: true } — always, whether or not the address is known
POST /email/verify{ key, email, code }{ token }
GET /config?key={ name, intro, email_sign_in, categories }
GET /mebearer{ name, email, identity, known }
GET /ticketsbearer{ tickets: [{ id, reference, title, status, word, open, created_at, last_message, … }] }
POST /ticketsbearer, { subject, body, category_id? }201 { id, reference }
GET /tickets/{id}bearer{ ticket: { …, messages: [{ direction, body, author, at }] } }
POST /tickets/{id}/messagesbearer, { body }{ ok: true }
POST /tickets/{id}/closebearer{ ok: true } — the customer confirming a resolved request is fixed; 409 unless it is resolved
GET /live[?ticket={id}][&since=]bearera held response, one JSON line at a time: see Live below
POST /signoutbearer{ ok: true } — the bearer stops working

token is a bearer for one customer on one portal; send it as Authorization: Bearer ps_…. It expires after thirty days without use and ninety days regardless; rotating the secret ends every host-signed session. status is the case's own; word is the customer's reading of it (Received, Being handled, Waiting for you, Waiting on us, Resolved, Closed). Beside them a ticket carries what the page needs to say what happened rather than a status word: handler (first name of whoever owns it), resolved_at and resolved_by (first name, or null when the team resolved it), closed_at and closed_by (you when the customer confirmed the fix, team, or auto when a resolved request closed after the grace period; closed_by_name carries the closer's first name when the team did). A message on a resolved or closed request is read like a chat message: an issue reopens it, a thanks leaves it closed, and a message nobody is sure of goes to Triage. A cancelled one is finished, a message on it is a 409, and the page offers a new request instead. A request that is not the bearer's is a 404, not a 403: the API does not say whether it exists.

Live

The page does not poll. GET /live holds the response open for most of a minute and writes a line whenever something moves: with ?ticket=, the whole request ({"type":"ticket","ticket":{…},"stamp":"…"}) as its status, its messages or its thread change; without, the list ({"type":"tickets","tickets":[…],"stamp":"…"}). The first line is what is there now, unless since already names it; {"type":"ping","stamp":"…"} keeps the line warm every fifteen seconds while nothing moves (take the stamp from pings too); {"type":"end"} closes it before the platform would, and the client opens the next one at once, sending the last stamp back as since so an unchanged snapshot is not sent twice; {"type":"error"} means the server could not answer and the client should wait and reconnect. Silence for more than about thirty seconds means the line is dead: cut it and open another. A reply from the composer is on the customer's page a second or two after it is sent; a customer's follow-up is in the agent's thread the same way. It is newline-delimited JSON over a plain fetch, not EventSource, because the bearer travels in a header; from your own pages the origin must be listed, as for every other call. Each session may open about a dozen lines a minute; a 429 says to wait.

What the customer is told

A status is the queue's word for its own state. The page says instead who did what and when, in the customer's words, and asks the one thing the state needs from them. A resolved request reads "Amina marked this as resolved on 5 Sept, 14:02. Did this fix it?" with Yes, it is fixed, which closes the case as the customer's confirmation (the case timeline says so, rather than that a week passed), and No, it is not, which asks what is still wrong. A closed request says whether the customer confirmed it, the team closed it, or the resolution simply held, and that the team sees what they write. A message after the request was resolved or closed is acknowledged: "We have your message. The team will read it and reply here if anything is needed."

Refusals: 400 for a malformed body, 401 for a bad identity, code or bearer, 404 for no such portal or request, 429 for too many calls from one address.


Records: what other systems know

A record is one fact another system holds about a customer, of a kind, with that system's own id: a booking, an order, a payment, a flight, an account, a loan. Posted on the generic contract, beside or instead of an interaction, it lands beside the customer (never on the timeline: nobody said it), shows on their page under Records, is summed into one line on the thread rail and the case page ("3 bookings, next on 12 Sept"), and is read by the AI when it drafts or briefs. The same record delivered twice updates the row: a booking later cancelled is the same booking.

{
  "account_id": "biz_123",
  "event": "record.upserted",
  "customer": { "external_id": "PAX-88214", "external_id_namespace": "booking",
                "phone": "+255699920009", "verified": true, "name": "Neema Mushi" },
  "record": {
    "kind": "booking",
    "external_id": "PNR-7QX2LK",
    "title": "DAR → NBO, 12 Sept",
    "status": "confirmed",
    "occurred_at": "2026-09-12T06:40:00Z",
    "amount": "450000.00",
    "currency": "TZS",
    "attributes": { "flight": "PW431", "seat": "14C" }
  }
}
FieldRule
kindA short slug the sender chooses: booking, order, payment, flight, subscription, account, invoice, visit, loan, policy, or your own. Shown as sent; the CRM never has to know what a PNR is to show one
external_idRequired, at most 200 characters. With namespace it is the record's identity
namespaceWhich system the id belongs to. Defaults to the source (api). Set it when two systems could mint the same id
titleRequired, at most 200 characters: the record as a person would say it
statusOptional, at most 60 characters, as the system says it
occurred_atWhen the thing is or was: a departure, a payment date. Defaults to the delivery's occurred_at. What the summary line sorts by
amountA decimal string with at most two places, never a JSON number, so nothing re-rounds money. Needs currency
currencyA three-letter code
attributesAnything else, kept as sent and shown as key: value

Several at once. records: [ … ], up to 200 per delivery, for a nightly sync. Each is for the envelope's customer unless it carries a customer block of its own, so one delivery can carry many customers' records. The delivery header still dedupes the delivery; the records inside upsert.

Whose it is. The same identity rules as an interaction, with one difference: a verified identifier attaches confidently or creates the customer; an unverified one that matches nobody creates too; an unverified one that matches exactly one customer attaches to them, since a record is the sender's own statement about that identifier and the second booking must land beside the first; two different people matching is a question a record cannot answer. Such a record is kept with no customer rather than dropped, and counted as unattributed in the answer:

{ "ok": true, "deduped": false, "interactions": 0, "records": { "applied": 1, "unattributed": 0 }, "correlation_id": "01J…" }

From a spreadsheet. Settings → Data & identity → Records from other systems takes a CSV with the same fields as columns (kind, external_id, title; one of phone, email, external_customer_id, the last being their id in that system, never in the CRM; then status, occurred_at, amount, currency, name, namespace), for the system that has no engineer. Any other column is kept as an attribute.


The generic contract

For SemaCall, Sarufi, and any internal service.

POST /api/ingest/{semacall|sarufi|api}
X-Ghala-CRM-Signature: sha256=<hex(HMAC-SHA256(secret, "{timestamp}.{raw_body}"))>
X-Ghala-CRM-Timestamp: <unix seconds>
X-Ghala-CRM-Delivery:  <your id, stable across retries>
Content-Type: application/json

{
  "account_id": "biz_123",
  "event": "call.completed",
  "occurred_at": "2026-08-18T09:12:00Z",
  "customer": {
    "name": "Neema Mushi",
    "phone": "+255699920009",
    "email": null,
    "external_id": "BORROWER-88214",
    "external_id_namespace": "core-banking",
    "verified": true
  },
  "interaction": {
    "channel": "voice",
    "direction": "inbound",
    "kind": "call",
    "id": "call_abc",
    "thread": "session_abc",
    "body": "transcript or summary",
    "attributes": { "duration_seconds": 266, "status": "Success" }
  },
  "bot": {
    "summary": "Customer asked about a repayment that has not landed",
    "classification": "pesa haijaingia",
    "confidence": 0.92,
    "suggested_action": "Check the core system for the transaction",
    "handoff": true
  }
}

channel is one of whatsapp voice chat ussd sms email web system other, direction one of inbound outbound internal, and kind one of message call classification escalation status order note. An unknown value is a 400 rather than being coerced to other, because a timeline that is quietly wrong is worse than an error the integrator sees immediately.

bot is optional AI context (Sarufi's block): what the bot understood and whether it gave up. It rides on the interaction's attributes under the bot key, and handoff: true is the escalation signal — the CRM opens a case for the conversation unless one is already active, exactly like Ghala's conversation.escalated.

verified means your system proved the identifier, not that someone typed it. It is the difference between attaching confidently and sending a record to review, so do not set it by default.

X-Ghala-CRM-Delivery is required and is the dedupe key. Retry freely.

A delivery may also carry a consent decision — appendix A's contact.preference.changed — beside or instead of an interaction:

{
  "account_id": "biz_123",
  "event": "contact.preference.changed",
  "customer": { "phone": "+255699920009", "verified": true },
  "preference": {
    "purpose": "marketing",
    "channel": "whatsapp",
    "status": "opted_in",
    "source": "ussd-menu",
    "jurisdiction": "TZ",
    "captured_at": "2026-08-18T09:12:00Z",
    "expires_at": "2027-08-18T09:12:00Z"
  }
}

status is opted_in or opted_out and nothing else; purpose and channel are required; a preference with no customer identifier to apply to is a 400. Each decision lands in the audit trail as consent.changed.

What the gate does with it, on every outbound send: a recorded opted_out refuses; an expired opted_in refuses everything except service; and with no record, service contact — replying to the customer about their own issue, including the closing message sent when a case is resolved — is allowed, while every other purpose (marketing, campaigns) is refused until an opt-in exists. Absence of consent is not consent. A refused send is still recorded, as message.blocked, so the attempt is evidence too.

Responses

Every response carries correlation_id. On 200:

{
  "ok": true,
  "deduped": false,
  "interactions": 1,
  "resolutions": ["attach"],
  "intake": { "decisions": [], "queued": 0, "tickets": 0, "jobs": 1 },
  "correlation_id": "01J..."
}

resolutions is the identity decision per interaction (attach, create, review, unattributed — see below). intake is what triage did with each interaction: await-triage (put the thread in front of a person), open-ticket (a source that had already triaged it, so a case opened), reopen (a customer raising an issue on finished work), or leave (our own reply, a thread already being handled, or a thanks on finished work). queued and tickets count the first two. Always present.

Changed with the agent queue. A customer's message that the AI may read is no longer decided before the answer. It is queued and decided moments later (AI.md), so it is not in decisions, queued or tickets. jobs (new) counts the threads with a message queued this way. A message decided by rules alone (our own reply, a note, a workspace with AI off) is in decisions as before. Read the case or the thread later for what became of a queued message; do not wait on this answer for it.

StatusMeaningRetry?
200Accepted. {"deduped": true} means we already had itNo
400Malformed body, unknown enum, or missing delivery headerNo, fix it
401Signature missing, wrong, or timestamp too oldNo, fix it
403Connection paused or revoked, or a connection with no signing secretNo, fix the connection
404No connection registered for that account_idNo, register it
500Our fault. The raw delivery is already recorded, so a retry is cheap and dedupe makes it safeYes

Webhooks out

The other direction. What the CRM does with a conversation — opens a case, moves it, hands it over, resolves it, breaches its clock, merges two customer records — is written to an outbox in the same transaction as the change, so an event is never emitted for a write that did not happen and never missed for one that did. A drain (/api/cron/outbox, every minute) delivers each event to every active endpoint the workspace has registered.

Registration. An endpoint is a public https:// URL and a secret, per workspace. Today the Neurotech team registers them for a pilot workspace; a settings screen for it is on the roadmap. The secret is sealed at rest with the same key as channel credentials.

Delivery.

POST <your endpoint>
Content-Type:          application/json
X-Ghala-CRM-Event:     case.status.changed
X-Ghala-CRM-Delivery:  <event id, stable across retries>
X-Ghala-CRM-Timestamp: <unix seconds>
X-Ghala-CRM-Signature: sha256=<hex(HMAC-SHA256(secret, "{timestamp}.{raw_body}"))>

{
  "event": "case.status.changed",
  "occurred_at": "2026-09-03T21:14:22.000Z",
  "correlation_id": "01J...",
  "data": { "case_id": "…", "reference": "CASE-42", "from": "OPEN", "to": "RESOLVED" }
}

Verify exactly as we verify you: HMAC over "{timestamp}.{raw body}" with the endpoint's secret, compared in constant time, timestamp within a few minutes. One routine covers both directions. Answer 2xx to acknowledge. A 3xx is a failure, not a hop — the signed payload is never re-sent to a host that did not pass the public-URL check — so point the subscription at the final URL.

Semantics. At-least-once, ordered per aggregate by the outbox's own sequence, no exactly-once claim: dedupe on X-Ghala-CRM-Delivery like we dedupe yours. A failed delivery backs off exponentially and is retried up to 8 times, then dead-letters. An endpoint that fails 20 deliveries in a row is paused rather than hammered, and stays paused until somebody looks; a single success resets the count. Each attempt has a 10-second timeout. While an endpoint is paused the drain does not deliver to it, so a workspace whose only endpoint is paused has its events marked delivered without reaching anyone; resume the endpoint and only events from then on go to it.

Events. data always carries the aggregate's id and, for a case, its human reference.

EventWhendata
case.createdA case is opened, by a person or by an escalationcase_id, reference, title, source (manual or escalation)
case.status.changedEvery move of the state machine, including the automatic close after a resolution holdscase_id, reference, from, to (NEW OPEN PENDING_CUSTOMER PENDING_INTERNAL RESOLVED CLOSED REOPENED CANCELLED); a duplicate merged into another case also carries reason: "merged", merged_into and merged_into_reference, so it can be told from a cancellation, and a person splitting it back carries split_from
case.owner.changedA hand-offcase_id, reference, from, to (user ids; from may be null)
case.comment.addedSomebody wrote on the case's discussioncase_id, reference, comment_id, author_user_id, mentions (user ids named with @)
case.sla.breachedThe resolution clock ran out on an open casecase_id, reference, due_at
customer.mergedTwo customer records were mergedwinner_customer_id, loser_customer_id, merge_id
customer.unmergedA merge was undonewinner_customer_id, loser_customer_id, merge_id

The comment's text is not in the event, on purpose: the discussion is the customer's data and stays in the workspace. Fetch it through the product with the case id if your integration needs it.


Identity resolution

One person, one customer, however the deliveries land. Two deliveries for one new number can arrive at the same moment (two ticks of a pull sync draining a backlog, two WhatsApp messages a second apart). Each finds nobody and each makes a customer; only one can take the identifier. The second is dropped in favour of the holder before anything is attached to it, and the delivery lands on the customer the number names. A pull sync also holds a lease on its connection for the tick, so an overrunning tick is not joined by the next. Duplicates made before this held are folded into the customer their number belongs to (Settings → Data & identity → One person, made twice), automatically after each call sync and on demand, through the ordinary merge, so each is on the trail and can be undone.

A phone number is a signal, not the person. Shared handsets, SIM swaps and one person running three accounts are normal here, so a match has to be earned.

Each identifier carries a weight:

IdentifierVerifiedUnverified
external_id10060
email7040
phone5025
handle2020

Scores are summed per matching customer; 50 is the threshold to attach without asking. Four outcomes:

  • attach to the matched customer
  • create a new customer when nothing matched but we hold an identifier worth recognising them by later
  • review when identifiers on one interaction point at different customers, or when only a weak one matched. The interaction is still stored, attached to nobody, and a row goes in identity_reviews
  • unattributed when there is nothing to identify them by at all

The interaction is always recorded. An unattributed row is recoverable; a wrong merge is a data-protection incident.

What a person can do about it

Reviews land under Needs attention in the product, grouped per conversation, as a question — "Is this Asha?", "Who was this sent to?" — with attach, merge and "not them" as the answers. A merge keeps the losing record and can be undone from the surviving customer's page; both directions are audited and both emit customer.merged / customer.unmerged (see Webhooks out).

The known limit

An identifier maps to exactly one customer per workspace, enforced by a unique constraint. So if two people genuinely share a handset, the second one to call is attributed to the first, and nothing flags it. The review queue does not fire here because from the data's point of view there is no ambiguity. The way out today is an agent noticing and splitting the record by hand; a "this is not that person" action on a message is not built yet. It is written down here rather than left to be discovered.


Operating it

Connections are created in the product under Integrations → Channels: pick the source, give it the external account id the source will send, and the signing secret is shown there (revealable later by an administrator, and required before the source posts). The CLI does the same from a terminal:

bun run db:migrate     # apply db/migrations/*.sql, safe to re-run
bun run connect -- --workspace "..." --source ghala --account ... --secret ...

--country sets the workspace's default country code, used to expand local numbers: with 255, 0699920009 and +255699920009 resolve to one person. Without it a leading-zero number is left as-is rather than guessed at, because guessing merges two people in different countries.

Pausing and disconnecting. Every channel page has a Pause in its footer and a Disconnect band at its foot, and they are not the same thing. Pause flips the row to paused: deliveries answer 403 and pull syncs skip it, while the credentials and the history stay, so Resume (on the page, and beside a paused stream on the Channels list) is one click. Disconnect gives the provider's access back where there is any to give (a Page's app subscription, a Gmail grant), forgets every credential and secret the row held, and marks it revoked; the source is then offered to connect again, and connecting the same account revives the row. A revoked row no longer claims the account: only live rows are unique on (source, account), so another workspace can connect it and gets a row of its own. Either way the interactions already captured stay on the timelines: a source that stops writing did not un-write. Both are audited (connection.disconnected and connection.reconnected for the pair, connection.revoked for the end, with whether the provider was told).

Replay. Every delivery is stored raw in inbound_events before anything is parsed, with its status and any error. When an adapter has a bug, fix it and re-process the payloads: nothing was lost to the parser.