Zum Inhalt springen

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.

POST {your endpoint}
Content-Type: application/json (application/x-www-form-urlencoded for a Form body)
User-Agent: SynapsAI-Webhooks/2026-10
X-Synapsai-Event: conversation.ended
X-Synapsai-Webhook-Id: evt_0123456789abcdef0123456789abcdef
X-Synapsai-Timestamp: 1761220800
X-Synapsai-Signature: sha256=<hex>
HeaderMeaning
X-Synapsai-Eventconversation.started, conversation.idle, conversation.ended, handoff.requested, operator.joined, operator.left, handoff.ended, or hook.ping (quarantine check)
X-Synapsai-Webhook-IdThe event id. Identical on every retry and redeliver — deduplicate on it.
X-Synapsai-TimestampUnix seconds when this attempt was signed
X-Synapsai-Signaturesha256= + 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.

  • Answer 2xx within 5 seconds to acknowledge. Do the work asynchronously if it takes longer.
  • A 2xx after 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 Gone pauses the hook at once (no more deliveries until it is turned back on).
  • Answer hook.ping (below) with a 2xx as well.

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.

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') })
import hashlib
import hmac
import 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));
}

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-Id and 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 2xx after 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 first 2xx within 5 s (to hook.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 403 STEP_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.

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" } }

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.

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.

VariableSectionEventsClassOpt-inDescription
{{event.id}}eventallMETADATAalwaysThe delivery id; same on every retry and redeliver
{{event.type}}eventallMETADATAalwaysgenerated
{{event.createdAt}}eventallMETADATAalwaysWhen the event happened (ISO-8601)
{{event.apiVersion}}eventallMETADATAalwaysPayload contract version
{{account.id}}accountallMETADATAalwaysYour SynapsAI account id
{{agent.key}}accountallMETADATAalwaysThe agent’s key
{{agent.name}}accountallMETADATAalwaysThe agent’s name
{{conversation.id}}conversationallMETADATAalwaysThe chat id
{{conversation.channel}}conversationallMETADATAalwaysWHATSAPP, CHAT, INSTAGRAM, MESSENGER, TELEGRAM, EMAIL, APP …
{{conversation.startedAt}}conversationconversation.ended, conversation.idle, conversation.startedMETADATAalwaysWhen the conversation started
{{conversation.lastActivityAt}}conversationconversation.ended, conversation.idle, conversation.startedMETADATAalwaysThe last message, from anyone
{{conversation.userMessageCount}}conversationconversation.ended, conversation.idle, conversation.startedMETADATAalwaysHow many messages the customer sent
{{conversation.language}}conversationconversation.ended, conversation.idle, conversation.startedMETADATAalwaysThe conversation’s language (ISO-639-1), when known
{{conversation.closedAt}}conversationconversation.endedMETADATAalwaysWhen the conversation closed
{{conversation.closeReason}}conversationconversation.endedMETADATAalwaysINACTIVITY_TIMEOUT, OPERATOR, CUSTOMER …
{{conversation.idleAt}}conversationconversation.idleMETADATAalwaysWhen the conversation became idle
{{conversation.idleForMinutes}}conversationconversation.idleMETADATAalwaysMinutes since the last activity when the idle event fired
{{contact.id}}contactallPIIincludes.contactThe contact id
{{contact.name}}contactallPIIincludes.contactThe contact’s name
{{contact.email}}contactallPIIincludes.contactE-mail
{{contact.phone}}contactallPIIincludes.contactPhone
{{contact.channelUserId}}contactallPIIincludes.contactThe customer’s id on the channel (WhatsApp number, Instagram id …)
{{contact.tags}}contactallPIIincludes.contactThe contact’s tags
{{contact.firstSeenAt}}contactallPIIincludes.contactWhen the contact was first seen
{{contact.lastSeenAt}}contactallPIIincludes.contactWhen the contact was last seen on any channel
{{messages.first}}messagesallCONTENTincludes.messagesThe customer’s first message: {text, at}
{{messages.first.text}}messagesallCONTENTincludes.messagesThe customer’s first message text
{{messages.lastCustomer}}messagesallCONTENTincludes.messagesThe customer’s last message: {text, at}
{{messages.lastCustomer.text}}messagesallCONTENTincludes.messagesThe customer’s last message text
{{messages.lastAgent}}messagesallCONTENTincludes.messagesThe last reply from the agent or an operator: {text, at}
{{messages.lastAgent.text}}messagesallCONTENTincludes.messagesThe last reply text
{{intel.status}}intelconversation.endedCONTENTalwaysSETTLED, SKIPPED, FAILED, TIMEOUT or DISABLED — always present on conversation.ended
{{intel.reason}}intelconversation.endedCONTENTpresent only when intel.status=SKIPPEDPresent when intel.status is SKIPPED (e.g. NOT_REQUESTED_OR_INELIGIBLE)
{{intel.outcome}}intelconversation.endedCONTENTincludes.intelresolved, abandoned …
{{intel.sentiment}}intelconversation.endedCONTENTincludes.intelSentiment
{{intel.intent}}intelconversation.endedCONTENTincludes.intelMain intent
{{intel.topics}}intelconversation.endedCONTENTincludes.intelTopics (array)
{{intel.score}}intelconversation.endedCONTENTincludes.intelQuality score 0–100
{{intel.summary}}intelconversation.endedCONTENTincludes.intelAI summary
{{lead.score}}leadconversation.endedMETADATAincludes.leadLead score 0–100
{{lead.interest}}leadconversation.endedMETADATAincludes.leadWhat they want
{{lead.dealTitle}}leadconversation.endedMETADATAincludes.leadDeal title
{{lead.budget.amount}}leadconversation.endedMETADATAincludes.leadStated budget
{{lead.budget.currency}}leadconversation.endedMETADATAincludes.leadISO-4217 currency of the budget
{{lead.timeframe}}leadconversation.endedMETADATAincludes.leadStated timing, verbatim
{{lead.contactDeclined}}leadconversation.endedMETADATAincludes.leadTrue when the customer said not to contact them
{{memory}}memoryconversation.endedCONTENTincludes.memoryThe customer-memory profile: language, sentiment, sessionCount, intents, preferences, fields
{{transcript}}transcriptconversation.endedCONTENTincludes.transcriptCustomer-visible messages [{role, text, at}], oldest first
{{handoff.sessionId}}handoffhandoff.ended, handoff.requested, operator.joined, operator.leftMETADATAalwaysThe handoff session id
{{handoff.reason}}handoffhandoff.ended, handoff.requested, operator.joined, operator.leftMETADATAalwaysWhy a human was asked for: USER_REQUEST, NO_ANSWER, SENSITIVE_TOPIC, ORDER_ACTION …
{{handoff.source}}handoffhandoff.ended, handoff.requested, operator.joined, operator.leftMETADATAalwaysWho asked for the human (the customer, an AI tool, a rule)
{{handoff.joinKind}}handoffoperator.joinedMETADATAalwaysACCEPTED, PULLED (an admin took it), FORCED or TRANSFERRED
{{handoff.waitSeconds}}handoffoperator.joinedMETADATAalwaysSeconds the customer waited for this operator
{{handoff.leftReason}}handoffoperator.leftMETADATAalwaysCLOSED, TRANSFERRED, TAKEN_OVER, EXPIRED or CHAT_CLOSED
{{handoff.handlingSeconds}}handoffhandoff.ended, operator.leftMETADATAalwaysSeconds the operator held the chat
{{handoff.outcome}}handoffhandoff.endedMETADATAalwaysRESOLVED_BY_OPERATOR, NO_OPERATOR, EXPIRED or CHAT_CLOSED
{{handoff.botResumed}}handoffhandoff.endedMETADATAalwaysTrue when the AI agent answers the chat again
{{handoffSummary.hadHandoff}}handoffSummaryconversation.endedMETADATAincludes.handoffTrue when a human was asked for in this conversation
{{handoffSummary.sessions}}handoffSummaryconversation.endedMETADATAincludes.handoffHow many handoff sessions the conversation had
{{handoffSummary.outcome}}handoffSummaryconversation.endedMETADATAincludes.handoffThe last session’s outcome
{{handoffSummary.lastOperatorId}}handoffSummaryconversation.endedMETADATAincludes.handoffThe last operator who held the chat (operators._id)
{{handoffSummary.totalWaitSeconds}}handoffSummaryconversation.endedMETADATAincludes.handoffSeconds the customer waited for a human, summed
{{handoffSummary.totalHandlingSeconds}}handoffSummaryconversation.endedMETADATAincludes.handoffSeconds operators held the chat, summed
{{operator.id}}operatoroperator.joined, operator.leftSTAFFincludes.operatorThe operator this event is about: id
{{operator.name}}operatoroperator.joined, operator.leftSTAFFincludes.operatorThe operator this event is about: the name the customer sees (“Support” when unknown)
{{operator.title}}operatoroperator.joined, operator.leftSTAFFincludes.operatorThe operator this event is about: title
{{operator.department}}operatoroperator.joined, operator.leftSTAFFincludes.operatorThe operator this event is about: department
{{operator.avatarUrl}}operatoroperator.joined, operator.leftSTAFFincludes.operatorThe operator this event is about: the public chat avatar URL (Growth and above)
{{operator.email}}operatorEmailoperator.joined, operator.leftSTAFFincludes.operatorEmailThe operator this event is about: e-mail
{{previousOperator.id}}operatoroperator.joinedSTAFFincludes.operatorThe operator displaced by a transfer or an admin takeover: id
{{previousOperator.name}}operatoroperator.joinedSTAFFincludes.operatorThe operator displaced by a transfer or an admin takeover: the name the customer sees (“Support” when unknown)
{{previousOperator.title}}operatoroperator.joinedSTAFFincludes.operatorThe operator displaced by a transfer or an admin takeover: title
{{previousOperator.department}}operatoroperator.joinedSTAFFincludes.operatorThe operator displaced by a transfer or an admin takeover: department
{{previousOperator.avatarUrl}}operatoroperator.joinedSTAFFincludes.operatorThe operator displaced by a transfer or an admin takeover: the public chat avatar URL (Growth and above)
{{previousOperator.email}}operatorEmailoperator.joinedSTAFFincludes.operatorEmailThe operator displaced by a transfer or an admin takeover: e-mail
{{conversation.operator.id}}operatorconversation.ended, conversation.idle, conversation.startedSTAFFincludes.operatorThe operator holding the chat now; null when nobody does: id
{{conversation.operator.name}}operatorconversation.ended, conversation.idle, conversation.startedSTAFFincludes.operatorThe operator holding the chat now; null when nobody does: the name the customer sees (“Support” when unknown)
{{conversation.operator.title}}operatorconversation.ended, conversation.idle, conversation.startedSTAFFincludes.operatorThe operator holding the chat now; null when nobody does: title
{{conversation.operator.department}}operatorconversation.ended, conversation.idle, conversation.startedSTAFFincludes.operatorThe operator holding the chat now; null when nobody does: department
{{conversation.operator.avatarUrl}}operatorconversation.ended, conversation.idle, conversation.startedSTAFFincludes.operatorThe operator holding the chat now; null when nobody does: the public chat avatar URL (Growth and above)
{{conversation.operator.email}}operatorEmailconversation.ended, conversation.idle, conversation.startedSTAFFincludes.operatorEmailThe operator holding the chat now; null when nobody does: e-mail
{{contact.custom.<key>}}contactallPIIincludes.contactAny custom profile field; a missing key renders empty