# CommSync Webhooks — Agent Reference # Fetch this document to understand how to receive real-time CommSync events. ## What this is and why it exists CommSync webhooks are push-based event delivery. When a customer sends your user an SMS or email, CommSync POSTs a signed JSON payload to a URL you register. This is the chatbot trigger. Webhooks exist to replace MCP polling. Cron-ing list_threads every few minutes to ask "anything new?" is expensive: every poll burns tokens for nothing 99% of the time. With webhooks, your agent sleeps until a real event fires — and the payload itself usually contains enough context (thread, person, labels, last N messages in verbose mode) to decide whether to reply without round-tripping back to MCP. Use both surfaces together: - WEBHOOKS for "wake me when something happens" (this document). - MCP for "do something now" — send the reply, look up a contact, label a thread. See https://commsync-server-63391657323.us-central1.run.app/docs/mcp.txt for the MCP tool catalog. ## Connection Endpoint: a URL on YOUR side that CommSync will POST to. Method: HTTP POST Body: application/json Header: X-CommSync-Signature: t=,v1= Required response: HTTP 2xx within 10 seconds. Non-2xx or timeout → retry. ## Bootstrap (three steps from zero to receiving events) 1. Mint a CommSync API key (format csk_<48-hex>) in Settings → API Keys. 2. Register a webhook endpoint via MCP (recommended — no UI needed): POST https://commsync-server-63391657323.us-central1.run.app/api/mcp Authorization: Bearer csk_ Content-Type: application/json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "register_webhook", "arguments": { "url": "https://your-agent.example.com/commsync", "events": ["message.received"], "mode": "verbose", "messageHistoryCount": 20 } } } Response data contains { "endpoint": { ... }, "secret": "<32-byte hex>" }. The secret is shown ONCE. Store it (env var / secrets manager). Use it to verify every incoming signature. Lose it → rotate_webhook_secret. 3. Start receiving. Every event that matches your subscription will hit your URL until you call update_webhook { status: "disabled" } or delete_webhook. ## Envelope Every POST body looks like this: { "id": "evt_<32 hex chars>", // unique per delivery — your dedup key "type": "message.received", "createdAt": "2026-05-21T09:30:00.000Z", "endpointId": "", "mode": "concise" | "verbose", "data": { ...event-specific... } } ## Event catalog (10 events) ### message.received Fires when: inbound SMS or email finished ingestion and was persisted. data (concise): thread: { id, subject, lastMessageAt, messageCount } person: { id, displayName } | null labels: [ "Sales", "VIP", ... ] // user-defined label names message: id, threadId, channel ("SMS"|"EMAIL"), direction ("INBOUND"|"OUTBOUND"), status, body, createdAt, sentAt, fromIdentity: { value, kind ("PHONE"|"EMAIL") }, toIdentity: { value, kind } data (verbose adds): thread.recentMessages: [ serializedMessage, ... ] // up to messageHistoryCount person.identities: [ { id, kind, value, displayName }, ... ] attachments: [ { id, filename, mimeType, sizeBytes, url } ] // url is a signed download URL valid for 1 hour emailHeaders: // only for EMAIL channel { messageId, inReplyTo, references, listUnsubscribe, authentication: { spf, dkim, dmarc } } labelConfidence: { "": <0..1>, ... } Sample: https://commsync-server-63391657323.us-central1.run.app/docs/webhooks/samples/message.received.concise.json https://commsync-server-63391657323.us-central1.run.app/docs/webhooks/samples/message.received.verbose.json ### message.sent Fires when: an outbound message was successfully handed to the carrier (SMS) or the SMTP server (email) — i.e. the moment it leaves QUEUED. Resulting status is SENT for email and Twilio, but DELIVERED for Skyetel/JustCall (no delivery callback — optimistically marked delivered on handoff). data: identical shape to message.received (message.direction is "OUTBOUND"). message.received + message.sent together give you both halves of a conversation without subscribing to message.status_changed, which also carries every downstream delivery-status flip. Sample: https://commsync-server-63391657323.us-central1.run.app/docs/webhooks/samples/message.sent.concise.json ### message.status_changed Fires when: a Message.status flip is recorded. (QUEUED→SENT, SENT→DELIVERED, SENT→FAILED, …) data (concise): same as message.received PLUS: previousStatus: "" | null currentStatus: "" data (verbose): same verbose additions as message.received. Sample: https://commsync-server-63391657323.us-central1.run.app/docs/webhooks/samples/message.status_changed.concise.json ### thread.created Fires when: a new Thread row is inserted (first message from a previously unknown conversation partner). data (concise): thread: { id, subject, lastMessageAt, messageCount } person: { id, displayName } | null // null id with non-null displayName = orphan identity labels: [] // usually empty at thread birth message: serializedMessage // the message that created the thread ORDER IS NOT GUARANTEED: CommSync emits thread.created before message.received for the same message, but the two are dispatched and retried independently and can arrive in either order. Do not assume you have already seen the thread when message.received lands — both payloads carry the full thread object. Sample: https://commsync-server-63391657323.us-central1.run.app/docs/webhooks/samples/thread.created.concise.json ### email.opened Fires when: a tracked outbound email records a distinct open — the recipient's mail client fetched the read-receipt image. Only emails sent with read receipts on carry the image (Message.trackOpens, resolved at send time from the composer toggle and the sender's settings). Untracked emails never fire this event. data (concise): same as message.received (message.direction is "OUTBOUND") PLUS: open: { firstOpenedAt, // ISO timestamp of the first recorded open lastOpenedAt, // ISO timestamp of this open openCount } // distinct opens so far; 1 = the first open data (verbose): same verbose additions as message.received. open is a snapshot taken at the open that produced the event. Filter on open.openCount === 1 if you only care about the first open. Dedup and filtering happen BEFORE the event exists: refetches within 60 seconds count as the same view (no event), opens in the first seconds after the send are treated as relay-side scanners (no event), and known security-gateway user agents are dropped (no event). An absent event is NOT proof the email went unread — many mail clients block remote images. Sample: https://commsync-server-63391657323.us-central1.run.app/docs/webhooks/samples/email.opened.concise.json ### attachment.extracted Fires when: an attachment's text layer finished extraction (PDF text, image OCR, office doc, or .ics calendar parse) and is now searchable. IMPORTANT: message.received fires BEFORE the attachment bytes are fetched or OCR'd. If your integration reads documents, act on this event, not on message.received. data (concise): the message.received envelope PLUS: attachment: { id, filename, mimeType, sizeBytes, messageId, textLength, // characters extracted hasText, // textLength > 0 extractedAt } calendarEvents: [ { id, uid, method, status, summary, startsAt, endsAt, organizerEmail, attendeeEmails } ] // non-empty only for .ics / text/calendar attachments The extracted text itself is NOT included (it can be megabytes). Fetch it with the read_attachment MCP tool using attachment.id. Attempt-once: an attachment that fails extraction is stamped and never retried, so this event fires at most once per attachment. ### contact.merged Fires when: two or more contacts are merged into one. data (concise): contact: { id, displayName, identities: [ { id, kind, value, displayName } ] } mergedContactIds: [ "", ... ] // these ids NO LONGER EXIST If you store CommSync contact ids, remap mergedContactIds onto contact.id here. This is the only notification you get — the merged-away rows are deleted. Not channel-scopable: a contact isn't tied to a line, so endpoints scoped to specific channels do not receive this event (scoped endpoints fail closed). ### email_account.health_changed Fires when: a mailbox stops syncing (status "unhealthy") or starts working again (status "recovered"). data (concise): emailAccount: { id, emailAddress, isActive, lastSyncedAt } health: { status: "unhealthy" | "recovered", errorKind: "AUTH" | "RATE_LIMIT" | "NETWORK" | ... | null, error: "" | null, since: "" | null } Edge-triggered: fires ONCE on the transition in each direction, not on every failed retry. A mailbox that stays broken produces exactly one event. An expired-credential mailbox fails silently — nothing looks wrong from the inbox — so this is the event to alert on. errorKind "AUTH" means a human has to re-authenticate the account. Channel-scopable: the mailbox IS the channel, so a scoped endpoint receives this only for mailboxes within its own scope. ### agent_turn.awaiting_approval Fires when: a DRAFT-mode CommSync Agent drafted a reply that needs human approval before it can be sent. data (concise): the thread envelope PLUS: agentTurn: { id, status, threadId, messageId, createdAt, agent: { id, name, mode } } No "message" block — nothing has been sent yet. The draft body lives on the AgentTurn; read it via the agents API. In verbose mode this event gets thread.recentMessages[] and person.identities[] but NOT attachments[] or emailHeaders, which derive from a message anchor it does not carry. Approve or reject via the agents API. Without this event the approval queue is poll-only. ### agent_turn.sent Fires when: an agent sent a reply to a contact (AUTO/SUPERVISED modes, or an approved DRAFT turn). data (concise): same shape as agent_turn.awaiting_approval; agentTurn.messageId points at the Message the agent sent. This is the oversight trail — every message an AI sent under your org's name. ## Signature verification Header format: X-CommSync-Signature: t=,v1= Algorithm: HMAC-SHA256(secret, ".") Tolerance: reject if |now − t| > 300 seconds (clock skew defense) Compare with: timing-safe equal (constant-time) CRITICAL: verify against the RAW request body, BEFORE any JSON parsing or middleware that mutates the bytes. Whitespace changes will break the HMAC. ### Node / TypeScript import { createHmac, timingSafeEqual } from 'crypto'; export function verify(secret, rawBody, header, toleranceSec = 300) { const parts = Object.fromEntries(header.split(',').map(p => p.split('='))); const t = Number(parts.t); if (!Number.isFinite(t)) return false; if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; const expected = createHmac('sha256', secret) .update(`${t}.${rawBody}`).digest('hex'); const a = Buffer.from(expected, 'hex'); const b = Buffer.from(parts.v1, 'hex'); return a.length === b.length && timingSafeEqual(a, b); } ### Python import hmac, hashlib, time def verify(secret: str, raw_body: bytes, header: str, tolerance_sec: int = 300) -> bool: parts = dict(p.split("=", 1) for p in header.split(",")) try: t = int(parts["t"]) except (KeyError, ValueError): return False if abs(time.time() - t) > tolerance_sec: return False signed = f"{t}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts.get("v1", "")) ### Go func Verify(secret, rawBody, header string, toleranceSec int64) bool { parts := map[string]string{} for _, p := range strings.Split(header, ",") { kv := strings.SplitN(p, "=", 2) if len(kv) == 2 { parts[kv[0]] = kv[1] } } t, err := strconv.ParseInt(parts["t"], 10, 64) if err != nil { return false } now := time.Now().Unix() diff := now - t; if diff < 0 { diff = -diff } if diff > toleranceSec { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(fmt.Sprintf("%d.%s", t, rawBody))) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(parts["v1"])) } ### Secret rotation grace window When you call rotate_webhook_secret, the previous secret stays valid for 24h. During rollover: try the NEW secret first; if it fails, retry verification with the PREVIOUS secret. Drop the old one once you've redeployed. ## Delivery semantics Timeout per attempt: 10 seconds Success criterion: HTTP 2xx Retry schedule: 1 min → 5 min → 30 min → 2 h → 12 h (5 attempts max) Auto-disable: 100 consecutive failures OR 24h continuous failure → endpoint flips to status="disabled" → re-enable via update_webhook { status: "active" } → that also resets the consecutive-failure counter Idempotency key: event.id (format: evt_<32 hex chars>) Same id is never delivered twice for the same delivery row. Retries reuse the id. resend_webhook_delivery creates a NEW row with a fresh id. Response body: truncated to 2 KB in the delivery log; we still consider a 2xx status a success regardless of body. ## Tier caps STARTER: 1 endpoint per user PRO: 3 endpoints per user POWER: 10 endpoints per user Per-org override available via admin-grant. ## Channel scoping By default an endpoint receives events from ALL channels — every connected line (email account / phone number), including lines connected later. You can restrict any endpoint to specific lines via the channelScope field on register_webhook / update_webhook: "channelScope": { "allChannels": false, "channels": [ { "channelType": "EMAIL_ACCOUNT", "channelId": "" }, { "channelType": "PHONE_NUMBER", "channelId": "" } ] } Semantics: - allChannels: true (default when omitted) → all lines, auto-includes future lines. - allChannels: false → fixed allowlist; events whose thread lives on a non-listed channel are filtered out BEFORE delivery (never reach you). Must list at least 1 channel. Lines connected later are NOT auto-added. - Deleted lines fail closed: their scope entries simply stop matching. - Channel ids: list_email_accounts / list_phone_numbers via MCP. - Pass channelScope on update_webhook to replace the scope wholesale. ## Security & guardrails - HTTPS required in production. http:// is accepted only in dev. - SSRF blocked: private (RFC-1918), loopback, link-local, unique-local, multicast, and broadcast hosts are rejected at registration AND at send time (DNS rebinding defense). - Events are scoped to userId. You only receive your own user's events. - Best practice: store secrets in a secrets manager; redact event.id and request bodies if you log them (payloads contain customer message text). ## MCP tools for managing webhooks (call via https://commsync-server-63391657323.us-central1.run.app/api/mcp) list_webhooks Read No params. get_webhook Read endpointId register_webhook Write url, events[], mode?, messageHistoryCount?, channelScope? Returns { endpoint, secret } — secret shown ONCE. update_webhook Write endpointId, url?, events?, mode?, messageHistoryCount?, status?, channelScope? rotate_webhook_secret Write endpointId Old secret valid 24h; returns new secret ONCE. delete_webhook Destr endpointId — irreversible. list_webhook_deliveries Read endpointId, cursor? 25 rows/page, newest first. resend_webhook_delivery Write deliveryId — new evt_* id, fresh delivery row. ## Common workflows 1. Chatbot reply loop (the canonical use case): register_webhook(url, ["message.received"], mode: "verbose") ↓ POST arrives, verify signature ↓ inspect data.message + data.thread.recentMessages ↓ decide reply (LLM call) ↓ MCP send_sms({ threadId, identityId, smsBody }) done — no polling, no token waste. 2. Delivery-monitoring bot: register_webhook(url, ["message.status_changed"]) ↓ branch on data.currentStatus - "FAILED" → page on-call, log to incident system - "DELIVERED" → close the loop in your outbound-message tracker 3. Label-driven router: register_webhook(url, ["message.received"], mode: "verbose") ↓ inspect data.labels and/or data.labelConfidence ↓ route to the right downstream system (Sales / Support / Spam-bin) 4. CRM-sync bot: register_webhook(url, ["thread.created"]) ↓ create matching CRM record from data.person + data.labels ↓ link CRM id back via MCP update_contact { notes: "crm:..." } ## Pointers Machine spec (JSON): https://commsync-server-63391657323.us-central1.run.app/docs/webhooks.json Payload fixtures: https://commsync-server-63391657323.us-central1.run.app/docs/webhooks/samples/..json e.g. /docs/webhooks/samples/message.received.verbose.json MCP tool catalog: https://commsync-server-63391657323.us-central1.run.app/docs/mcp.txt Human page: https://commsync-server-63391657323.us-central1.run.app/docs/webhooks