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
| Source | Status | How it reaches us |
|---|---|---|
| Ghala | Specified and implemented | Native event subscriptions, HMAC-signed. Ghala already ships this |
| Gmail | Specified and implemented | OAuth + cron-driven cursor sync against Gmail's real API |
| SemaCall | Specified and implemented | Pulled from SemaCall's real application API on a cursor (below); the generic contract remains its push fallback |
| Messenger and Instagram | Specified and implemented | Facebook Login for the Pages and the Instagram accounts linked to them, Meta's webhook signed with the app secret. See Messenger and Instagram below |
| Sarufi | Specified and implemented | Native 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 portal | Specified and implemented | A 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.
| Event | Timeline row | Notes |
|---|---|---|
message.received | message, inbound | Contact phone and id become verified identifiers |
message.sent | message, outbound | attributes.actor distinguishes AI from human |
conversation.escalated | escalation | attributes.needsCase is the hook phase 2 turns into a case |
order.paid | order | Totals kept as strings; a JSON float would re-round money |
message.status | none | Recorded for replay, not a timeline row |
template.status | none | Delivery plumbing, not something the customer did |
account.error | none | Same |
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.
| Concern | How |
|---|---|
| Cursor | Gmail 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 |
| Threading | Gmail'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 |
| Replies | Queued 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 |
| Identity | The 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 |
| Direction | The SENT label; the counterparty is From on inbound, the recipient on outbound |
| Content | Sanitized text body, bounded at 20k chars (text/plain preferred, HTML flattened); attachments as metadata only until object storage lands (ADR-014) |
| Dedupe | gmail:<message id> as the delivery id, so replays and window overlaps store nothing twice |
| Failure | Sync 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.
| Concern | How |
|---|---|
| Thread | Meta 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 |
| Identity | The 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 |
| Content | Text 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 |
| Echoes | Our own sends come back as message_echoes and are stored outbound, so the thread shows what the customer was told |
| Windows | Inside 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 |
| Replies | Queued 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.
| Event | Timeline row | Notes |
|---|---|---|
conversation.messages | one message per thing said | The conversation itself: what the customer wrote and what the bot wrote back |
lead.captured | note, inbound | The lead's own id is an external_id claim in the sarufi namespace |
conversation.escalated | escalation, internal | bot.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.
responderdistinguishes 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_redactedis 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:
| Refused | Because |
|---|---|
No interaction.thread / conversation.id | The 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_at | A 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 response | An empty row on a timeline is a conversation nobody had |
No interaction.id | A replay would duplicate the row instead of correcting it |
conversation.escalated without bot.handoff: true | The flag is what opens the case; an escalation without it is a contradiction |
channel: voice | SemaCall 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 event | A 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_secretin the chatbot's settings. Each send is signed with it inX-Sarufi-Signature: t=<unix>,v1=<hex>, HMAC-SHA256 over"<t>." + body. A Sarufi under another brand names the header after its own prefix; setSARUFI_HEADER_PREFIXto 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 says | The reply |
|---|---|
201, sent or queued | Sent, and put on the thread by the CRM: Sarufi does not send it back |
| 401, 403 | Held, not counted as an attempt, until the key or secret is fixed |
| 404 | Not sent: the chat no longer exists on Sarufi |
| 410 | Not sent: the visitor has been away more than 7 days |
422 window_closed | Not sent: the WhatsApp window has closed |
| 429 | Retried with backoff |
| 5xx, no answer in 15 seconds, or a dropped connection | Not 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.
| Call | Body | Answer |
|---|---|---|
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 /me | bearer | { name, email, identity, known } |
GET /tickets | bearer | { tickets: [{ id, reference, title, status, word, open, created_at, last_message, … }] } |
POST /tickets | bearer, { subject, body, category_id? } | 201 { id, reference } |
GET /tickets/{id} | bearer | { ticket: { …, messages: [{ direction, body, author, at }] } } |
POST /tickets/{id}/messages | bearer, { body } | { ok: true } |
POST /tickets/{id}/close | bearer | { ok: true } — the customer confirming a resolved request is fixed; 409 unless it is resolved |
GET /live[?ticket={id}][&since=] | bearer | a held response, one JSON line at a time: see Live below |
POST /signout | bearer | { 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" }
}
}
| Field | Rule |
|---|---|
kind | A 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_id | Required, at most 200 characters. With namespace it is the record's identity |
namespace | Which system the id belongs to. Defaults to the source (api). Set it when two systems could mint the same id |
title | Required, at most 200 characters: the record as a person would say it |
status | Optional, at most 60 characters, as the system says it |
occurred_at | When the thing is or was: a departure, a payment date. Defaults to the delivery's occurred_at. What the summary line sorts by |
amount | A decimal string with at most two places, never a JSON number, so nothing re-rounds money. Needs currency |
currency | A three-letter code |
attributes | Anything 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.
Consent, on the same contract
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.
| Status | Meaning | Retry? |
|---|---|---|
| 200 | Accepted. {"deduped": true} means we already had it | No |
| 400 | Malformed body, unknown enum, or missing delivery header | No, fix it |
| 401 | Signature missing, wrong, or timestamp too old | No, fix it |
| 403 | Connection paused or revoked, or a connection with no signing secret | No, fix the connection |
| 404 | No connection registered for that account_id | No, register it |
| 500 | Our fault. The raw delivery is already recorded, so a retry is cheap and dedupe makes it safe | Yes |
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.
| Event | When | data |
|---|---|---|
case.created | A case is opened, by a person or by an escalation | case_id, reference, title, source (manual or escalation) |
case.status.changed | Every move of the state machine, including the automatic close after a resolution holds | case_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.changed | A hand-off | case_id, reference, from, to (user ids; from may be null) |
case.comment.added | Somebody wrote on the case's discussion | case_id, reference, comment_id, author_user_id, mentions (user ids named with @) |
case.sla.breached | The resolution clock ran out on an open case | case_id, reference, due_at |
customer.merged | Two customer records were merged | winner_customer_id, loser_customer_id, merge_id |
customer.unmerged | A merge was undone | winner_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:
| Identifier | Verified | Unverified |
|---|---|---|
external_id | 100 | 60 |
email | 70 | 40 |
phone | 50 | 25 |
handle | 20 | 20 |
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.