api-events. Twilio Conversations remains the live transport and SDK history source; Postgres holds chat metadata, membership, and a webhook-fed message mirror used by authorized REST and MCP reads. In-app members join with a Conversations AccessToken (user_<id>); phone-only crew are bridged over SMS through a dedicated proxy-number pool.
This page does not provision Twilio, A2P, or cloud resources. Configure those in the Twilio Console and secret stores, then point api-events at the existing SIDs.
Product behavior for end users: Chat. Env inventory: Integrations and api-events operations. Event-chat e2e: e2e/README.md.
What is shipped
REST:
GET/POST /api/v1/events/{event_id}/chats, PATCH /api/v1/chats/{chat_id}, POST …/archive, POST …/join, POST …/leave, POST …/members, DELETE …/members/{org_member_id}, GET …/messages, GET …/messages/{message_id}, GET /api/v1/chat/token. Listing is read-only (no Twilio, no membership insert). Managers join via POST …/join before minting a token. In-app members leave themselves via POST …/leave. Revocation removes the stored participant and checks the claimed user’s Twilio identity as well, so an interrupted SMS-to-app migration cannot leave an unrecorded participant behind. A failed identity lookup keeps the local membership available for retry. Webhook: POST /webhooks/twilio/conversations.
Dedicated Conversations Service
Create a Conversations Service used only by Soundcheck event chats (IS…). Put that SID in TWILIO_CONVERSATIONS_SERVICE_SID.
Do not:
- Reuse the default / shared Conversations Service that other products already write to.
- Point this SID at a Messaging Service. Conversations and Programmable Messaging are different products.
- Set
TWILIO_CONVERSATIONS_ENDPOINTin a deployed environment. That override is emulator-only (http://localhost:4013/conversationsfortools/twilio-emulator).
/v1/Services/{TWILIO_CONVERSATIONS_SERVICE_SID}/…. AccessToken ChatGrants must use the same SID or the SDK cannot open the conversation.
Canonical pre-event and post-event webhooks
Configure service-level webhooks on that Conversations Service (Twilio Console → Conversations → Services → the dedicatedIS… → Webhooks). Do not configure these on individual conversations, on phone numbers, or on a Messaging Service.
api-events verifies X-Twilio-Signature with the account auth token against TWILIO_CONVERSATIONS_WEBHOOK_URL (not TWILIO_WEBHOOK_URL, which is the invitation SMS route and will always fail signature checks here). Set TWILIO_CONVERSATIONS_WEBHOOK_URL to the exact public URL Twilio POSTs, including scheme and path. A mismatch is 403 / INVALID_SIGNATURE.
The handler prefixes SMS bodies on EventType=onMessageAdd with Twilio’s JSON {"body":…} override. Post-event onMessageAdded, onMessageUpdated, and onMessageRemoved persistence and topic publication are awaited before HTTP 200; a failure returns non-2xx so Twilio retries. Creates also bump last_message_at and publish EventChatMessageTopic with a deterministic event ID derived from message ID, action, and citation version. Created events always use citation version 1 so a late onMessageAdded replay after edits keeps the same ProcessedEvent id; updated and deleted ids still include the current citation version. A remove that arrives before add writes a DELETED tombstone keyed by MessageSid, so the delayed add cannot resurrect it. eventChatMessageProcessor consumes created messages with a ProcessedEvent inbox, suppresses CheckAI and the author, honors the event_chat push preference, and batches Expo delivery. Update and remove events are terminally consumed without push. Other event types are acknowledged 200 and ignored.
If an SMS author posts a STOP-family control word (STOP, STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT, including the Name: STOP prefix rewrite), the pre-action hook removes that Twilio participant and the local membership, then returns HTTP 403 so the control message is rejected. If the pre-action hook times out and Twilio accepts the message, the post-action hook attempts membership revocation first, then provider-message deletion. A delete failure does not skip opt-out; either failure returns non-2xx so Twilio retries. Retries stay safe once membership is already gone (already_gone). Neither path persists or pushes the control message. Carrier-level suppression can still swallow some STOP texts before they reach this webhook — treat this as a defensive complement, not a guarantee that every opt-out is visible to api-events.
If an SMS author sends exactly HELP (trimmed, any case), the pre-action hook queues a help reply and returns HTTP 403, so the group never sees the message. The reply goes only to that phone, from its bound proxy number, through Programmable Messaging:
MessageSid, then deletes the message from the conversation, and skips persist and push. Either failure returns non-2xx so Twilio retries; the MessageSid key keeps retries to one reply. Neither path sends the SMS before answering Twilio: the queued reply is attempted only after the webhook response is written, and the minute maintenance job sends it if that attempt does not run or fails.
Pre-action events carry no MessageSid, so one text can reach both paths under different keys: the pre-action hook queued a reply but answered Twilio too late, or the two ran concurrently. Both paths take a per-member transaction advisory lock (event-chat-sms-help:{member}) around check-and-insert, and treat a pre-action row and an accepted row as the same text when the pre-action row’s created_at is within SMS_HELP_SAME_MESSAGE_WINDOW_SECONDS (15 s) of the accepted message’s own DateCreated. Whichever path commits second queues nothing. The window covers Twilio’s pre-action timeout plus clock skew between Twilio and the database; separate HELP texts further apart each get a reply. Two HELP texts from one member within 15 s of each other, where the first was rejected and the second accepted, get one reply. HELP handling does not depend on the event-chats flag or on the chat being active.
Twilio may also answer HELP itself (default keyword handling or Advanced Opt-Out on the Messaging Service). Turn that off or set it to the same text on the chat-only Messaging Service, or the sender gets two replies.
SMS notices: welcome and HELP
Carriers require the first text a recipient gets on an A2P campaign to identify the sender and program and to give opt-out instructions. When a member is added on the SMS channel,api-events sends that phone one welcome from its bound proxy number:
^ { } [ ] ~ | \ €), the backtick, curly quotes, and dashes are mapped to basic equivalents; accents outside GSM-7 are stripped; anything else is dropped. Each name is then truncated to 40 septets. The fixed text is 103 septets, so the welcome is at most 223 septets: always GSM-7, one segment for typical names, never more than two (306). A blank organization, chat, or event name falls back to generic wording. The HELP reply and the disclosure suffix below are fixed GSM-7 text.
Welcomes and HELP replies go through Programmable Messaging (sendSMS with an explicit From), not a Conversations message, so in-app members never see them. They are only ever sent from a number in TWILIO_GROUPCHAT_NUMBERS that is not TWILIO_FROM_NUMBER; any other stored proxy abandons the notice instead of falling back to the invitation sender.
Welcome before the first group message
- Each SMS member’s welcome is attempted as soon as that member’s add releases the chat locks, before the next member in the same request is added.
- While an SMS member is joining or their welcome is unsent, the pre-action
onMessageAddhook appends(Soundcheck gig chat. Reply STOP to leave, HELP for help)to every message posted in that chat, whatever the author. A media-only message with no text gets the disclosure alone, without the leading space. It uses the same{"body":…}override as the SMS author prefix. So a group message that beats the welcome still carries the program name and STOP/HELP instructions. - “Joining” is an
event_chat_sms_join_intentsrow keyed by chat and org member. It is committed before the Twilio participant is bound, so it also covers the window between the bind and the membership commit.- The membership commit (or the orphan-recovery commit) deletes it in the same transaction that queues the welcome, and the welcome effect takes over.
- A definitive bind failure, an exhausted pool, or an activation failure whose Twilio participant rollback succeeded deletes it. If the intent can’t be written, the member add fails before any Twilio call.
- An ambiguous bind, or an add that ends in queued removal, leaves it in place: the participant may still exist.
- Backstop: an intent older than
SMS_JOIN_INTENT_TTL_MINUTES(10) no longer counts, and the minute maintenance job deletes it.
- The check is one
EXISTS … OR EXISTS …query per inbound message, keyed by conversation SID through the unique index, and it runs in parallel with the prefix lookup under the same 100 ms budget. It fails closed: if the lookup errors or times out, the suffix is appended anyway. Dead-lettered or settled welcomes stop the suffix.
Durability
- Both kinds are
event_chat_sms_noticerows ineffect_outbox, one per notice. The unique idempotency key is the notice key:welcome:{membership id}for welcomes,help:{MessageSid}for accepted HELPs, and a fresh key for pre-action HELPs. The payload (dedupeKey,kind,memberId, plusmessageSidandmessageCreatedAtIsofor an accepted HELP) is the source of truth; no other table holds notice state. - The member insert and its welcome effect commit in one transaction. So does the orphan-recovery transaction that re-activates an SMS membership. A failed enqueue rolls the membership and Twilio participant back like any other activation failure. The recovery path that only retains the row for queued removal writes no welcome.
- The request makes one bounded eager attempt per notice. It shares the effect’s two-minute claim with the scheduled runner.
- The effect sweep and the participant-sync maintenance job (every minute) claim due notices through the effect worker. While Conversations is not configured, maintenance runs only HELP effects; a welcome that is claimed anyway backs off by its own age (a minute at first, doubling in effect up to an hour) without spending an attempt. A welcome for an org with the
event-chatsflag off is deferred an hour and retried, and one whose flag lookup is unavailable is deferred five minutes. - Sending and recording are not atomic, and Programmable Messaging has no idempotency key or custom metadata to look up. So immediately before its first provider send, fenced on the claim token it holds, a notice records the exact text it is about to send (
sentBody) and when that attempt started (firstAttemptedAtIso) in its payload. Later attempts keep both values and resendsentBodyunchanged, even if the chat or event was renamed in between. If the claim was lost before the record write, nothing is sent (outcome=lease_lost). - A null
firstAttemptedAtIsoproves no provider call happened, because the record write always precedes the send, so that notice is sent without any lookup. - When
firstAttemptedAtIsois set, the notice looks up the earlier send first. The lookup pages the Messages list from that pool number to that phone, newest first (50 per page, at most 5 pages), until it reaches a message sent before the window start. A message created inside the window, with a status other thanfailed/undelivered/canceledand a body exactly equal tosentBody, counts as the earlier send: the effect settles without sending (outcome=reconciled). The window starts 2 s beforefirstAttemptedAtIso, which covers Twilio’s whole-seconddate_createdand clock drift between Postgres and Twilio. Another chat’s welcome on a reused pool number was sent before this notice’s first attempt, so it never matches. - HELP replies are serialized per member: a HELP effect waits (retries in 30 s without spending an attempt) while an earlier-queued HELP effect for the same member, ordered by
created_atthenid, is stillPENDING. Every settled send stores the provider message SID in the payload (providerSid), in a claim-fenced write: the SID the send returned, or the SID a reconciliation matched. The lookup skips any message whose SID is stored on another notice for the same member, so a later HELP can only reconcile to its own earlier send. Remaining edge: an earlier HELP dead-lettered after an ambiguous last send has no SID, so a later HELP for that member can match that reply, and the member gets one reply for two requests. A lookup that keeps failing retries every five minutes without spending an attempt and holds later HELPs for that member (Sentrysms_notice_lookupeach time). - If the lookup fails or hits the page cap without reaching the window’s start, the notice is deferred five minutes (
outcome=lookup_deferred) without counting a send attempt, so lookup failures alone never dead-letter it. - After a send Twilio accepted, the
providerSidwrite is retried up to three times. If it still fails, or the claim was lost, the outcome issent_unrecordedand the effect retries in a minute without being settled. The next claim reconciles it through the lookup above and records the SID, so a later HELP can never match an uncredited reply. These retries count against the effect’s attempt limit and dead-letter at ten. - A failed send backs off exponentially (1 minute doubling to 1 hour). After six failed attempts, or when the stored proxy is not a pool number, the effect goes
DEAD;requeueDeadEffectsre-queues it by hand. A recipient on the undeliverable list settlesDONE(outcome=undeliverable). - Settled effects are kept for the standard 28 days and then deleted by the effect cleanup sweep. Removing a member does not delete its effects; the next claim finds no deliverable target and settles without sending (
outcome=skipped), and a re-add gets a fresh membership id and so a fresh welcome. A replayed add of an existing member queues nothing new. - A welcome is not sent for an archived chat or a member that is no longer SMS. The worker reads the membership and chat again after the organization and event name lookups and immediately before sending. It does not hold the archive lock across the Twilio call; that lock serializes manager archive requests, which would otherwise fail as busy for the length of the provider call. An archive that lands after that final read can still let one welcome out.
- A welcome failure never fails the member add.
- Programmable Messaging has no idempotency key. If the process dies after Twilio accepts the send but before the effect settles, the next claim looks the send up by its recorded text and window, and sends again only when it is not found. Duplicates are limited to a send whose attempt record was lost.
- Members added before this queue existed get no retroactive welcome.
Proxy pool (2–4 numbers)
TWILIO_GROUPCHAT_NUMBERS is a comma-separated E.164 list used as MessagingBinding.ProxyAddress for SMS participants.
When every pool number is already bound to that phone, add-member returns a per-member failure with reason
sms group-chat number pool exhausted (Twilio 50416 is walked internally; exhaustion is not a 500).
Buy and register the pool numbers in Twilio before enabling the feature. This runbook does not purchase numbers.
Auto-creation disabled
On the dedicated Conversations Service, disable conversation auto-creation (Address Configuration / Autocreation for the pool numbers). Soundcheck creates conversations only throughcreateConversation when a manager creates an event chat. If auto-creation is on, an inbound SMS to a pool number can spawn an orphan conversation that:
- has no Postgres
event_chatsrow - never receives the
checkaiparticipant - never fans out push
- burns a proxy binding
Messaging Service for the pool
US 10DLC numbers are registered to a campaign through a Messaging Service, so the pool numbers do belong to a Messaging Service. Use a dedicated, chat-only Messaging Service tied to the A2P “Proxy” campaign. Never use the Messaging Service, sender number, or campaign that sends invitations, reminders, or other Programmable Messaging traffic.
Set the chat-only Service’s incoming-message handling to route to Conversations. Do not point its “A message comes in” webhook at an API route, and keep conversation auto-creation off (see Auto-creation disabled). A Service that handles inbound SMS itself takes replies away from Conversations, so they never become
onMessageAdded on the conversation.
Conversations binds MessagingBinding.Address + ProxyAddress directly. The welcome and HELP replies use Programmable Messaging with an explicit From of the member’s pool number. They do not pass a Messaging Service SID and never use the invitation sender.
A2P 10DLC
US carriers filter unregistered application-to-person SMS.- Local 10DLC pool numbers need a registered brand and a campaign whose use case matches group-chat / customer-care traffic to crew phones. Approval is a Twilio / TCR process — do it in the Console; do not invent a campaign from this repo.
- Register the pool in the chat-only Messaging Service on its own “Proxy” campaign. The first text each phone gets is the welcome, which carries the sender, program, and STOP/HELP disclosure that campaign review expects.
- Do not hang the chat pool off the invitation sender’s campaign or (if used) toll-free verification. Invitation traffic and group-chat traffic are different use cases and different numbers.
- Toll-free invitation senders (see
handoffs/eng-418/) do not substitute for 10DLC on local proxy numbers. - Until the campaign is
APPROVED, treat SMS bridge as blocked even if in-app chat works. Dogfood only after at least one US handset sends and receives a group-chat SMS through a pool number.
STOP / carrier opt-out is primarily handled by Twilio and the carrier on the proxy number. When Conversations delivers a STOP-family keyword, api-events rejects it in the pre-action webhook and removes the SMS member (Twilio first, then Postgres). The post-action fallback attempts member cleanup first and provider-message deletion independently and skips persist/push; either failure is retried, including after membership is already gone. Carrier-level suppression may prevent the webhook from seeing every STOP.
API keys and secrets
Conversations REST and AccessToken JWTs use an API Key pair, not the account auth token.
The auth token cannot sign Conversations JWTs. Missing
SK / secret / IS throws twilio conversations client is not configured on create/token.
Store SIDs and secrets in the environment secret store (Encore secrets / GCP Secret Manager — see infra-gcp/modules/events). Do not commit values. Rotate the API Key by creating a new SK…, deploying it, then deleting the old key.
Feature rollout
Order matters. Do not load the Conversations secrets until the Twilio side is live: once they are valid, the server treats event chats as configured, and theevent-chats flag decides which orgs see them.
- Dedicated
IS…service exists; auto-creation off. - Canonical pre/post webhooks point at
/webhooks/twilio/conversations. - 2–4 pool numbers purchased, in a dedicated chat-only Messaging Service (not the invitation Service), not equal to
TWILIO_FROM_NUMBER. - A2P 10DLC brand/campaign approved for those numbers (US).
- Secrets populated (
SK/ secret /IS/ pool / conversations webhook URL). ConfirmTWILIO_CONVERSATIONS_ENDPOINTis unset in the deploy. - Roll a revision so it picks up the secrets. Hit
POST /webhooks/twilio/conversationswith a bad signature and expect403; for an org with the flag off, a valid signed callback still persists provider state but terminally suppresses notification delivery. - Roll PostHog
event-chatsto dogfood orgs, then wider. Web, mobile and api-events read the same flag, so there is no second server toggle to keep in step.
Monitoring
Pre-event
onMessageAdd stays fast and synchronous for prefixing only. Post-event persistence and topic publication run on the request thread so Twilio can retry failures. Push fan-out remains on EventChatMessageTopic → eventChatMessageProcessor, not in the webhook handler.
Rollback
- Turn PostHog
event-chatsoff (for the affected orgs, or everyone). Routes disappear for clients; Twilio keeps POSTing signed callbacks, which continue persistence and cleanup while push delivery is suppressed. Within about six minutes the flag sweep removes affected users’ in-app participants, so an already-minted token stops reaching those conversations. - Leave the Conversations Service, webhooks, provider credentials, scheduler, reconciliation workers, and pool numbers in place. Deleting the service destroys message history and orphan-binds phones; queued cleanup and reconciliation still protect existing conversations while the flag is off.
- If SMS must stop, managers archive chats before disabling because authenticated chat routes are then unavailable. Already-queued and event-lifecycle archive recovery continue while disabled; their durable close jobs retry provider uncertainty asynchronously.
- Do not point pool numbers at
/webhooks/twilioas a “safe” fallback — that mixes invitation reply codes with group SMS.
event_chats rows and CH… SIDs remain valid if the same IS… is still configured. Pushes already accepted by Expo cannot be recalled.
Reconciliation
An ambiguous close failure reads back Twilio state: closed or missing is success, while any other or unavailable state retains the archive marker and durable close job and returns a retryable failure. Even an active read cannot exclude the original timed-out request completing later. Twilio error 50377 also confirms the final closed state. Successful close requests make no extra read; each close attempt uses the existing bounded GET retry budget; a later queued retry can reassert the close POST.
The archive marker and close intent commit together before provider HTTP. The archive-close worker runs every minute, claims up to five jobs for a five-minute lease, and rechecks each pending intent under the same per-chat session lock as manual archive. Successful closure acknowledges the job; provider or acknowledgement failure retains it for retry, even when the chat feature is disabled. Definitive provider failures also retain the archive marker and close job for retry. Already-archived retries ensure intent exists before HTTP, and the migration seeds existing archived chats. Deleting the chat cascades its close job; the separate deletion queue then owns provider cleanup. Recovery failures use
event_chats.twilio_lifecycle with source=archive_recovery and Sentry operation deferred_conversation_close.
Participant removal records durable intent in event_chat_member_revocations before provider revocation. The saved transport identities survive local membership deletion so queued retries can finish cleanup. Revocation, restoration, re-addition, and participant reconciliation share a per-member session lock; roster removals acquire every affected participant key in sorted order on one lock-pool connection and retain them through preparation, the short roster transaction, and compensation. A retained membership row does not cancel removal intent. Prepare and org-leave set retain_local_snapshot so the reconcile worker never deletes that row — it converges provider absence and leaves row and marker for the business removal, which deletes the exact snapshot and enqueues a fresh generation in one transaction; a later pass retires the marker once the row is gone. Direct remove, leave, and STOP record the exact snapshot identity with retain false; if provider revocation succeeded but the local snapshot delete did not, reconciliation CAS-deletes that leftover and ACKs. Explicit restoration first verifies that the retained row is still the exact prepared snapshot, then clears the marker and bumps the generation in one transaction before provider work, so a failed eager restore leaves queued PRESENT recovery that restores the persisted membership channel. SMS recovery also removes a stray CHAT identity left by an interrupted explicit join. A membership update newer than the revocation also supersedes it. Archived chats still reconcile effective revocations using the saved transport identities; only effective PRESENT work is skipped. Each participant-sync enqueue advances a generation, so an older worker cannot acknowledge newer work. Prompt delivery accelerates processing, and the minute-based worker retries durable pending jobs after lease expiry.
The deletion queue retries committed cleanup automatically. If initial conversation creation succeeds but neither the chat row nor a deletion job can be persisted, the request attempts direct provider cleanup and reports any cleanup failure. There is no general nightly scan for other divergence. After an incident, list conversations on the dedicated IS… and compare CH… SIDs to event_chats.twilio_conversation_sid. Orphans (Twilio only) should be closed or deleted by hand. DB rows whose conversation is gone should be archived.
An advisory-lock connection failure aborts the protected work’s signal and makes its ownership checks fail. Provider calls receive that signal, and mutation boundaries assert ownership before continuing; the failed lock connection is destroyed instead of returning to the pool. The wrapper propagates failures before, during, and after protected work without terminating unrelated requests. Already-issued provider calls remain subject to reconciliation. The separate four-connection lock pool and lock ordering are unchanged.
CI and emulator limits
Theencore-e2e job in .github/workflows/api-events-deploy.yml starts
tools/twilio-emulator on :4013, boots api-events with
the Conversations test credentials pointed at that emulator and
FEATURE_FLAG_OVERRIDES including event-chats, and runs
e2e/tests/event-chats/crud.test.ts with E2E_EVENT_CHATS=1. Locally the
CRUD file is still skipped unless that flag is set. See e2e/README.md for
the exact env (seeded AC000…0 / SK000…0 / IS000…0, and
TWILIO_GROUPCHAT_NUMBERS).
e2e/tests/event-chats/routes-probe.test.ts always runs: unauthenticated
probes get 401 before the kill switch, which registers route-coverage hits
in both flag states.
Known emulator limits:
- Not production Twilio (no A2P, no carrier
STOP, no real SMS, no AccessToken validation). - Conversations webhooks are not fired by the emulator; e2e posts signed
onMessageAdd/onMessageAdded/onMessageUpdated/onMessageRemovedbodies to/webhooks/twilio/conversations. DELETE …/Participants/{sid}is implemented so member remove anduser.deletedrevoke can be asserted.- Signature checks still use the account auth token over the exact webhook URL
(
E2E_CONVERSATIONS_WEBHOOK_URLor${E2E_API_URL}/webhooks/twilio/conversations).
TWILIO_CONVERSATIONS_ENDPOINT unset.
Dogfood checklist
Do this against the target Twilio account after secrets are set. Do not buy numbers or register A2P from the agent.- Dedicated
IS…; auto-creation disabled. - Pre-event and post-event webhooks both
POST{API}/webhooks/twilio/conversations. - Pool is 2–4 E.164s, disjoint from
TWILIO_FROM_NUMBER, in a chat-only Messaging Service (never the invitation Service) whose inbound handling routes to Conversations. - US 10DLC Proxy campaign approved for that chat-only Messaging Service (or dogfood is non-US / in-app only and that limit is written down).
-
TWILIO_API_KEY_SID/SECRETmint a token (GET /api/v1/chat/tokenas a signed-in user with the flag on). - Conversations secrets loaded (not
PLACEHOLDER); PostHogevent-chatson for the dogfood org. - Manager creates a named event chat with at least one in-app member and one SMS member.
- Adding the SMS member sends one welcome text from that member’s pool number (not the invitation sender) before any group message. In-app members do not see it, and repeating the add does not send a second one.
- A message posted while a welcome is still pending carries the
(Soundcheck gig chat. Reply STOP to leave, HELP for help)suffix; once the welcome is sent, new messages do not. - After a manager posts an introduction, the SMS arrives from the same pool number.
- Texting
HELPfrom the handset returns exactly one help reply from the pool number (check that Twilio’s own HELP auto-reply is off or matches), and the web/mobile thread does not showHELP. - In-app message appears on the handset; handset reply appears in the web/mobile thread and pushes other in-app members (
event_chat_message). - Second concurrent chat for the same SMS phone uses a different pool number; a third overlapping chat (if pool size is 2) fails that member with pool exhausted.
- Rename updates the UI; Twilio friendly name is best-effort.
- Archive stops SMS; further posts do not fan out.
- Archive/cancel the event; remaining chats close.
- Forged conversations signature →
403/INVALID_SIGNATURE. - Turn
event-chatsoff for the dogfood org: UI/API hide (404); valid signed callbacks still persist, but no new push is sent. - Sentry
twilio_conversationsclean for the dogfood window.
Related
- Integrations — Twilio SMS + chat env block
- api-events operations — secret inventory
- Environment variables
- API testing — Vitest + e2e gates
- Chat (product)
Stable message pagination
For stored history, passbefore as the last message’s id from the previous page to list_event_chat_messages or GET /api/v1/chats/{chatId}/messages. This anchors the next page to immutable creation time and id, so newer arrivals do not repeat or skip older messages. Do not combine before with the legacy offset parameter.
Organization deletion queues Twilio conversation SIDs in the deletion transaction. The internal cleanup worker runs every minute and retries failed deletions after a five-minute lease. A database rollback therefore preserves provider conversations and history; a provider outage leaves durable work for later retry.