Lifecycle Hooks Reference
Dieser Inhalt ist noch nicht in deiner Sprache verfügbar.
This is the contract of the requests SynapsAI sends to a lifecycle hook. Payload version: 2026-10.
Request
Section titled “Request”POST {your endpoint}Content-Type: application/json (application/x-www-form-urlencoded for a Form body)User-Agent: SynapsAI-Webhooks/2026-10X-Synapsai-Event: conversation.endedX-Synapsai-Webhook-Id: evt_0123456789abcdef0123456789abcdefX-Synapsai-Timestamp: 1761220800X-Synapsai-Signature: sha256=<hex>| Header | Meaning |
|---|---|
X-Synapsai-Event | conversation.started, conversation.idle, conversation.ended, handoff.requested, operator.joined, operator.left, handoff.ended, or hook.ping (quarantine check) |
X-Synapsai-Webhook-Id | The event id. Identical on every retry and redeliver — deduplicate on it. |
X-Synapsai-Timestamp | Unix seconds when this attempt was signed |
X-Synapsai-Signature | sha256= + hex HMAC-SHA256 of {timestamp}.{webhookId}.{raw body}, keyed with the hook’s signing secret |
Your headers are added as configured. The body is at most 256 KB; when the transcript would exceed it, it is cut and transcriptTruncated is true.
Responding
Section titled “Responding”- Answer 2xx within 5 seconds to acknowledge. Do the work asynchronously if it takes longer.
- A
2xxafter more than 5 seconds is delivered but counts as a slow answer; a request still open after 10 seconds is a timeout and is retried. - Any other status, a timeout or a connection error is retried. Redirects (3xx) are not followed and count as a failure.
410 Gonepauses the hook at once (no more deliveries until it is turned back on).- Answer
hook.ping(below) with a2xxas well.
Verify the signature
Section titled “Verify the signature”Always verify before trusting the payload: compute the HMAC over the raw body bytes, compare in constant time, and reject a timestamp more than 5 minutes away from your clock.
Test vector — secret whsec_test_7f3a9c, timestamp 1761220800, id evt_0123456789abcdef, body {"id":"evt_0123456789abcdef","type":"conversation.ended"} → sha256=8e78712ba57db2cc8d77c2e38c6537b496f24b2952e859afb8ddc2043979f032.
Node.js
Section titled “Node.js”import crypto from 'node:crypto';
export function verifySynapsaiSignature({ secret, timestamp, webhookId, rawBody, signature, toleranceSeconds = 300 }) { const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)); if (!Number.isFinite(age) || age > toleranceSeconds) return false; const expected = 'sha256=' + crypto.createHmac('sha256', secret) .update(`${timestamp}.${webhookId}.`).update(rawBody).digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(signature ?? ''); return a.length === b.length && crypto.timingSafeEqual(a, b);}
// Express: keep the raw body — app.post('/synapsai', express.raw({ type: '*/*' }), handler)// verifySynapsaiSignature({ secret, timestamp: req.get('X-Synapsai-Timestamp'),// webhookId: req.get('X-Synapsai-Webhook-Id'), rawBody: req.body, signature: req.get('X-Synapsai-Signature') })Python
Section titled “Python”import hashlibimport hmacimport time
def verify_synapsai_signature(secret: str, timestamp: str, webhook_id: str, raw_body: bytes, signature: str, tolerance_seconds: int = 300) -> bool: try: if abs(int(time.time()) - int(timestamp)) > tolerance_seconds: return False except ValueError: return False message = f"{timestamp}.{webhook_id}.".encode() + raw_body expected = "sha256=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature or "")
# Flask: verify_synapsai_signature(secret, request.headers["X-Synapsai-Timestamp"],# request.headers["X-Synapsai-Webhook-Id"], request.get_data(), request.headers["X-Synapsai-Signature"])import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;import java.nio.charset.StandardCharsets;import java.security.MessageDigest;import java.time.Instant;import java.util.HexFormat;
static boolean verifySynapsaiSignature(String secret, String timestamp, String webhookId, byte[] rawBody, String signature) throws Exception { long age = Math.abs(Instant.now().getEpochSecond() - Long.parseLong(timestamp)); if (age > 300) { return false; } Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); mac.update((timestamp + "." + webhookId + ".").getBytes(StandardCharsets.UTF_8)); String expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal(rawBody)); return signature != null && MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8), signature.getBytes(StandardCharsets.UTF_8));}Retries and redelivery
Section titled “Retries and redelivery”Delivery is at least once. A failed attempt is retried 5 more times — after 30 s, 1, 2, 4 and 8 minutes — then the event is marked failed in the delivery log. Events of one hook are sent one at a time, oldest first; a retried event can arrive after a newer one, so order by createdAt, not by arrival.
Order across event types is not guaranteed. conversation.ended waits for the AI analysis (up to 15 minutes), so it usually arrives after operator.left and handoff.ended for the same conversation — but a retry, or an event recovered after a temporary failure, can arrive later than events created after it. Each event carries its own id (repeats of one event keep it) and createdAt: deduplicate by id, and when the state of a conversation matters, keep the event with the newest createdAt. Within one human-handoff session the order is fixed: operator.left, then operator.joined, then handoff.ended.
- Redeliver (console delivery log) sends a delivered, failed or cancelled event again, with the same
X-Synapsai-Webhook-Idand a fresh signature. Its data is read again at send time. - Quarantine: after 5 bad attempts in a row (non-2xx, timeout, connection or TLS error, redirect, or a
2xxafter more than 5 s) the hook’s waiting and new events are skipped, and the endpoint is probed after 1, 2, 4, 8, 16, 32 and then every 60 minutes (up to 20 % sooner, never later). The first2xxwithin 5 s (tohook.ping, or to a trial event when probing vendor endpoints) releases the hook; Redeliver all skipped sends the remaining skipped events again with their ids. No e-mail is sent. See the guide. - Redelivering skipped events after a URL change: If the hook’s URL was changed while events were skipped in quarantine:
- If the new URL is on the same host (different path or query), Redeliver all skipped delivers them directly to the updated URL (payloads rendered fresh at send time).
- If the new URL points to a different host, the console shows the skipped events as recorded for the previous host. Redelivering them to the new host requires fresh step-up authentication (
auth_time≤ 5 min; HTTP 403STEP_UP_REQUIRED) and writes an audit log entry (SKIPPED_REDELIVERED_TO_NEW_HOST) recording the source and target hosts.
- An event is cancelled — never sent — when the hook was in quarantine (skipped, redeliverable), turned off or deleted, its URL changed after the event was created, the plan went below Growth, the contact was erased, or (for
conversation.idle) the customer wrote again before it was sent. - The delivery log keeps 30 days of rows and never stores request bodies.
hook.ping
Section titled “hook.ping”The quarantine check. Signed like every event, sent with the hook’s headers, in its body type (JSON, or a form with the same fields). It never carries conversation, contact or operator data. When an endpoint returns an HTTP 4xx or slow 2xx answer that rejects the ping’s generic payload schema (e.g. HTTP 400 from vendor APIs like Slack), recovery is not ping-only: SynapsAI automatically performs trial-event recovery using a real signed event with its original X-Synapsai-Webhook-Id (receivers dedupe; retried at most 6 times) to confirm recovery and release the hook.
{ "id": "ping_3f9a0c1b2d4e5f60", "type": "hook.ping", "apiVersion": "2026-10", "createdAt": "2026-10-25T14:00:00Z", "test": true, "hook": { "id": "6718f0c2a1b2c3d4e5f60718" }, "agent": { "key": "loja-bot" } }Envelope
Section titled “Envelope”The standard body (no custom template). Sections appear only when the event carries them and the hook turned them on; intel.status is always present on conversation.ended. When AI analysis was skipped or ineligible, intel.status is "SKIPPED" and intel.reason is "NOT_REQUESTED_OR_INELIGIBLE", and empty lead and intel.summary fields are omitted so receivers do not store blank values.
{ "id": "evt_0123456789abcdef0123456789abcdef", "type": "conversation.ended", "apiVersion": "2026-10", "createdAt": "2026-10-23T14:01:00Z", "account": { "id": "acc_123" }, "agent": { "key": "loja-bot", "name": "Loja" }, "conversation": { "id": "6aa43b340ddc0d7f2a35395b", "channel": "WHATSAPP", "startedAt": "2026-10-23T13:40:00Z", "lastActivityAt": "2026-10-23T13:45:00Z", "closedAt": "2026-10-23T14:00:00Z", "closeReason": "INACTIVITY_TIMEOUT", "userMessageCount": 4, "language": "pt" }, "contact": { "id": "c_42", "name": "Maria", "email": "maria@example.com", "phone": "+5511999999999", "channelUserId": "5511999999999", "tags": ["vip"], "custom": { "customer_code": "C-0001" }, "firstSeenAt": "2026-09-23T10:00:00Z", "lastSeenAt": "2026-10-23T13:45:00Z" }, "intel": { "status": "SETTLED", "outcome": "resolved", "sentiment": "positive", "intent": "product_question", "topics": ["delivery"], "score": 82, "summary": "The customer asked about delivery times." }, "lead": { "score": 70, "interest": "Bulk order", "dealTitle": "Bulk order", "budget": { "amount": 1500, "currency": "USD" }, "timeframe": "this month", "contactDeclined": false }, "transcript": [ { "role": "customer", "text": "Hi, how long does delivery take?", "at": "2026-10-23T13:40:00Z" }, { "role": "agent", "text": "Usually 2 to 3 business days.", "at": "2026-10-23T13:40:05Z" } ], "transcriptTruncated": false}A human-handoff event carries a handoff object and, with Operator on, the operator it is about:
{ "id": "evt_…", "type": "operator.joined", "apiVersion": "2026-10", "conversation": { "id": "6aa43b340ddc0d7f2a35395b", "channel": "WHATSAPP" }, "handoff": { "sessionId": "…", "reason": "USER_REQUEST", "source": "customer", "joinKind": "TRANSFERRED", "waitSeconds": 45 }, "operator": { "id": "…", "name": "Ana", "title": "Specialist", "department": "Sales", "avatarUrl": "https://…" }, "previousOperator": { "id": "…", "name": "Bruno", "title": null, "department": null, "avatarUrl": null }}operator.left carries handoff.leftReason (CLOSED, TRANSFERRED, TAKEN_OVER, EXPIRED, CHAT_CLOSED) and handlingSeconds; handoff.ended carries outcome, botResumed and handlingSeconds. An operator who no longer exists is sent as "id": null, "name": "Support". The operator e-mail is added only with Operator e-mail on. On conversation events, Operator adds conversation.operator while a person holds the chat (null otherwise); on conversation.ended, Human handoff adds handoffSummary.
conversation.idle carries conversation.idleAt and conversation.idleForMinutes instead of closedAt / closeReason. intel.status is SETTLED (analysis included), SKIPPED (too short to analyse), FAILED, TIMEOUT (not finished within 15 minutes) or DISABLED (analysis off for the agent). Transcript roles are customer, agent and operator. A test event adds "test": true and made-up data.
Variables
Section titled “Variables”Every variable a custom body can use. Opt-in is the section that must be turned on; always means it is always present for the events listed.
| Variable | Section | Events | Class | Opt-in | Description |
|---|---|---|---|---|---|
{{event.id}} | event | all | METADATA | always | The delivery id; same on every retry and redeliver |
{{event.type}} | event | all | METADATA | always | generated |
{{event.createdAt}} | event | all | METADATA | always | When the event happened (ISO-8601) |
{{event.apiVersion}} | event | all | METADATA | always | Payload contract version |
{{account.id}} | account | all | METADATA | always | Your SynapsAI account id |
{{agent.key}} | account | all | METADATA | always | The agent’s key |
{{agent.name}} | account | all | METADATA | always | The agent’s name |
{{conversation.id}} | conversation | all | METADATA | always | The chat id |
{{conversation.channel}} | conversation | all | METADATA | always | WHATSAPP, CHAT, INSTAGRAM, MESSENGER, TELEGRAM, EMAIL, APP … |
{{conversation.startedAt}} | conversation | conversation.ended, conversation.idle, conversation.started | METADATA | always | When the conversation started |
{{conversation.lastActivityAt}} | conversation | conversation.ended, conversation.idle, conversation.started | METADATA | always | The last message, from anyone |
{{conversation.userMessageCount}} | conversation | conversation.ended, conversation.idle, conversation.started | METADATA | always | How many messages the customer sent |
{{conversation.language}} | conversation | conversation.ended, conversation.idle, conversation.started | METADATA | always | The conversation’s language (ISO-639-1), when known |
{{conversation.closedAt}} | conversation | conversation.ended | METADATA | always | When the conversation closed |
{{conversation.closeReason}} | conversation | conversation.ended | METADATA | always | INACTIVITY_TIMEOUT, OPERATOR, CUSTOMER … |
{{conversation.idleAt}} | conversation | conversation.idle | METADATA | always | When the conversation became idle |
{{conversation.idleForMinutes}} | conversation | conversation.idle | METADATA | always | Minutes since the last activity when the idle event fired |
{{contact.id}} | contact | all | PII | includes.contact | The contact id |
{{contact.name}} | contact | all | PII | includes.contact | The contact’s name |
{{contact.email}} | contact | all | PII | includes.contact | |
{{contact.phone}} | contact | all | PII | includes.contact | Phone |
{{contact.channelUserId}} | contact | all | PII | includes.contact | The customer’s id on the channel (WhatsApp number, Instagram id …) |
{{contact.tags}} | contact | all | PII | includes.contact | The contact’s tags |
{{contact.firstSeenAt}} | contact | all | PII | includes.contact | When the contact was first seen |
{{contact.lastSeenAt}} | contact | all | PII | includes.contact | When the contact was last seen on any channel |
{{messages.first}} | messages | all | CONTENT | includes.messages | The customer’s first message: {text, at} |
{{messages.first.text}} | messages | all | CONTENT | includes.messages | The customer’s first message text |
{{messages.lastCustomer}} | messages | all | CONTENT | includes.messages | The customer’s last message: {text, at} |
{{messages.lastCustomer.text}} | messages | all | CONTENT | includes.messages | The customer’s last message text |
{{messages.lastAgent}} | messages | all | CONTENT | includes.messages | The last reply from the agent or an operator: {text, at} |
{{messages.lastAgent.text}} | messages | all | CONTENT | includes.messages | The last reply text |
{{intel.status}} | intel | conversation.ended | CONTENT | always | SETTLED, SKIPPED, FAILED, TIMEOUT or DISABLED — always present on conversation.ended |
{{intel.reason}} | intel | conversation.ended | CONTENT | present only when intel.status=SKIPPED | Present when intel.status is SKIPPED (e.g. NOT_REQUESTED_OR_INELIGIBLE) |
{{intel.outcome}} | intel | conversation.ended | CONTENT | includes.intel | resolved, abandoned … |
{{intel.sentiment}} | intel | conversation.ended | CONTENT | includes.intel | Sentiment |
{{intel.intent}} | intel | conversation.ended | CONTENT | includes.intel | Main intent |
{{intel.topics}} | intel | conversation.ended | CONTENT | includes.intel | Topics (array) |
{{intel.score}} | intel | conversation.ended | CONTENT | includes.intel | Quality score 0–100 |
{{intel.summary}} | intel | conversation.ended | CONTENT | includes.intel | AI summary |
{{lead.score}} | lead | conversation.ended | METADATA | includes.lead | Lead score 0–100 |
{{lead.interest}} | lead | conversation.ended | METADATA | includes.lead | What they want |
{{lead.dealTitle}} | lead | conversation.ended | METADATA | includes.lead | Deal title |
{{lead.budget.amount}} | lead | conversation.ended | METADATA | includes.lead | Stated budget |
{{lead.budget.currency}} | lead | conversation.ended | METADATA | includes.lead | ISO-4217 currency of the budget |
{{lead.timeframe}} | lead | conversation.ended | METADATA | includes.lead | Stated timing, verbatim |
{{lead.contactDeclined}} | lead | conversation.ended | METADATA | includes.lead | True when the customer said not to contact them |
{{memory}} | memory | conversation.ended | CONTENT | includes.memory | The customer-memory profile: language, sentiment, sessionCount, intents, preferences, fields |
{{transcript}} | transcript | conversation.ended | CONTENT | includes.transcript | Customer-visible messages [{role, text, at}], oldest first |
{{handoff.sessionId}} | handoff | handoff.ended, handoff.requested, operator.joined, operator.left | METADATA | always | The handoff session id |
{{handoff.reason}} | handoff | handoff.ended, handoff.requested, operator.joined, operator.left | METADATA | always | Why a human was asked for: USER_REQUEST, NO_ANSWER, SENSITIVE_TOPIC, ORDER_ACTION … |
{{handoff.source}} | handoff | handoff.ended, handoff.requested, operator.joined, operator.left | METADATA | always | Who asked for the human (the customer, an AI tool, a rule) |
{{handoff.joinKind}} | handoff | operator.joined | METADATA | always | ACCEPTED, PULLED (an admin took it), FORCED or TRANSFERRED |
{{handoff.waitSeconds}} | handoff | operator.joined | METADATA | always | Seconds the customer waited for this operator |
{{handoff.leftReason}} | handoff | operator.left | METADATA | always | CLOSED, TRANSFERRED, TAKEN_OVER, EXPIRED or CHAT_CLOSED |
{{handoff.handlingSeconds}} | handoff | handoff.ended, operator.left | METADATA | always | Seconds the operator held the chat |
{{handoff.outcome}} | handoff | handoff.ended | METADATA | always | RESOLVED_BY_OPERATOR, NO_OPERATOR, EXPIRED or CHAT_CLOSED |
{{handoff.botResumed}} | handoff | handoff.ended | METADATA | always | True when the AI agent answers the chat again |
{{handoffSummary.hadHandoff}} | handoffSummary | conversation.ended | METADATA | includes.handoff | True when a human was asked for in this conversation |
{{handoffSummary.sessions}} | handoffSummary | conversation.ended | METADATA | includes.handoff | How many handoff sessions the conversation had |
{{handoffSummary.outcome}} | handoffSummary | conversation.ended | METADATA | includes.handoff | The last session’s outcome |
{{handoffSummary.lastOperatorId}} | handoffSummary | conversation.ended | METADATA | includes.handoff | The last operator who held the chat (operators._id) |
{{handoffSummary.totalWaitSeconds}} | handoffSummary | conversation.ended | METADATA | includes.handoff | Seconds the customer waited for a human, summed |
{{handoffSummary.totalHandlingSeconds}} | handoffSummary | conversation.ended | METADATA | includes.handoff | Seconds operators held the chat, summed |
{{operator.id}} | operator | operator.joined, operator.left | STAFF | includes.operator | The operator this event is about: id |
{{operator.name}} | operator | operator.joined, operator.left | STAFF | includes.operator | The operator this event is about: the name the customer sees (“Support” when unknown) |
{{operator.title}} | operator | operator.joined, operator.left | STAFF | includes.operator | The operator this event is about: title |
{{operator.department}} | operator | operator.joined, operator.left | STAFF | includes.operator | The operator this event is about: department |
{{operator.avatarUrl}} | operator | operator.joined, operator.left | STAFF | includes.operator | The operator this event is about: the public chat avatar URL (Growth and above) |
{{operator.email}} | operatorEmail | operator.joined, operator.left | STAFF | includes.operatorEmail | The operator this event is about: e-mail |
{{previousOperator.id}} | operator | operator.joined | STAFF | includes.operator | The operator displaced by a transfer or an admin takeover: id |
{{previousOperator.name}} | operator | operator.joined | STAFF | includes.operator | The operator displaced by a transfer or an admin takeover: the name the customer sees (“Support” when unknown) |
{{previousOperator.title}} | operator | operator.joined | STAFF | includes.operator | The operator displaced by a transfer or an admin takeover: title |
{{previousOperator.department}} | operator | operator.joined | STAFF | includes.operator | The operator displaced by a transfer or an admin takeover: department |
{{previousOperator.avatarUrl}} | operator | operator.joined | STAFF | includes.operator | The operator displaced by a transfer or an admin takeover: the public chat avatar URL (Growth and above) |
{{previousOperator.email}} | operatorEmail | operator.joined | STAFF | includes.operatorEmail | The operator displaced by a transfer or an admin takeover: e-mail |
{{conversation.operator.id}} | operator | conversation.ended, conversation.idle, conversation.started | STAFF | includes.operator | The operator holding the chat now; null when nobody does: id |
{{conversation.operator.name}} | operator | conversation.ended, conversation.idle, conversation.started | STAFF | includes.operator | The operator holding the chat now; null when nobody does: the name the customer sees (“Support” when unknown) |
{{conversation.operator.title}} | operator | conversation.ended, conversation.idle, conversation.started | STAFF | includes.operator | The operator holding the chat now; null when nobody does: title |
{{conversation.operator.department}} | operator | conversation.ended, conversation.idle, conversation.started | STAFF | includes.operator | The operator holding the chat now; null when nobody does: department |
{{conversation.operator.avatarUrl}} | operator | conversation.ended, conversation.idle, conversation.started | STAFF | includes.operator | The operator holding the chat now; null when nobody does: the public chat avatar URL (Growth and above) |
{{conversation.operator.email}} | operatorEmail | conversation.ended, conversation.idle, conversation.started | STAFF | includes.operatorEmail | The operator holding the chat now; null when nobody does: e-mail |
{{contact.custom.<key>}} | contact | all | PII | includes.contact | Any custom profile field; a missing key renders empty |