Lifecycle Hooks Guide
This guide takes a lifecycle hook from the first click to a receiver you can run in production. The exact contract (headers, envelope, every variable) is in the reference.
How it works
Section titled “How it works” Customer SynapsAI Your system │ message ──► conversation.started ──── signed POST ──► 2xx in ≤ 5 s │ (nobody writes for N min) │ conversation.idle ─────── signed POST ──► 2xx │ "a person please" │ handoff.requested / operator.joined / operator.left / handoff.ended ──► │ chat closes AI analysis (≤ 15 min) │ conversation.ended ────── signed POST ──► 2xx │ │ no 2xx? retry after 30 s, 1, 2, 4, 8 min │ │ 5 bad answers in a row? quarantine (below)- Something happens in a conversation: it starts, goes quiet, asks for a person, an operator joins or leaves, or it ends.
- SynapsAI builds the event with only the data sections you turned on, signs it with the hook’s secret and sends it to your URL — one event at a time per hook, oldest first.
- Your endpoint checks the signature and answers 2xx within 5 seconds. That is a delivery.
- Anything else is retried. An endpoint that keeps failing is put in quarantine so it stops slowing down your other events, and is checked automatically until it answers again.
The events
Section titled “The events”| Event | Typical use |
|---|---|
conversation.started | Open a ticket or a CRM activity when a customer writes |
conversation.idle | Follow up a customer who went quiet before the chat closes |
conversation.ended | Push the AI summary, the lead and the contact to your CRM |
handoff.requested | Alert your team that a customer wants a person |
operator.joined / operator.left | Track who handled the chat and for how long |
handoff.ended | Measure the human-support service level |
Every variable and the events that carry it are listed in the reference. In the console, the variable picker greys out the ones your selected events do not carry.
Receive safely
Section titled “Receive safely”Five rules make a receiver correct. The tutorials below follow all of them.
- Verify the signature on the raw body. Compute the HMAC over the bytes you received, before any JSON parsing,
and compare in constant time. Refuse a timestamp more than 5 minutes from your clock. Answer
401when it fails. - Answer within 5 seconds, then work. Put the event on a queue and answer
2xxat once. A2xxthat takes longer than 5 seconds counts as a slow answer, and a request still open after 10 seconds is a timeout. Both count towards quarantine. - Deduplicate on
X-Synapsai-Webhook-Id, durably. Delivery is at least once: a retry and a redeliver keep the same id, and a redeliver can come days later — keep the ids you processed in a store that outlives a restart. Read the event type from the signed body; theX-Synapsai-Eventheader is a hint. - Do not rely on arrival order. Order by
createdAt; keep the newest state per conversation. - Answer
hook.pingwith a 2xx. It carries no conversation data and is how a quarantined hook comes back (see below).
import crypto from 'node:crypto';
/** * Checks a SynapsAI lifecycle-hook request: the HMAC-SHA256 of `{timestamp}.{webhookId}.{raw body}` keyed with the * hook's signing secret, compared in constant time, and a timestamp no more than 5 minutes from your clock. * `rawBody` is the exact bytes you received (a Buffer or a string), never a re-serialised JSON object. */export function verifySynapsaiSignature({ secret, timestamp, webhookId, rawBody, signature, toleranceSeconds = 300, now = Date.now() }) { const age = Math.abs(Math.floor(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);}Python and Java versions of the check are in the reference.
Quarantine
Section titled “Quarantine”A hook whose endpoint keeps failing is put in quarantine, so one broken endpoint never slows down your other hooks.
What counts. An attempt is bad when your endpoint answers anything other than 2xx, does not answer within
10 seconds, refuses the connection or the TLS handshake, redirects, or answers 2xx after more than 5 seconds. An
answer of 2xx within 5 seconds is good and resets the count. Our own failures never count.
When. After 5 bad attempts in a row — across all the hook’s events, retries included — the hook enters quarantine:
- the events waiting for it are skipped (cancelled, never lost: you can send them again);
- new events for it are recorded as skipped instead of sent;
- the hook shows a Quarantine card on its page, the home page shows a banner and the bell lists it. Nobody is e-mailed: the console is the only notice.
Recovery. SynapsAI probes your endpoint automatically after 1 minute, then 2, 4, 8, 16, 32 and every 60 minutes (up to 20 % sooner, never later). You can also click Test now on the card to probe it at once.
SynapsAI checks recovery in two steps:
- Connectivity check: A signed
hook.pingchecks your endpoint. Any2xxresponse within 5 seconds releases the hook immediately. - Trial-event recovery: If the endpoint returns a 4xx client error (such as HTTP
400typical of vendor APIs like Slack that reject generic schemas) or a slow 2xx answer (> 5 s), SynapsAI sends a half-open trial event using the oldest skipped event for that hook. The trial carries a real, signed event with its originalX-Synapsai-Webhook-Idheader (so receivers deduplicate idempotently). A2xx ≤ 5 sresponse proves the endpoint processes real payloads and immediately releases the hook from quarantine! To prevent unbounded re-delivery loops, trials are retried at most 6 times before transitioning the row to exhausted (keeping the row’s original expiration date). Network, DNS, TLS, timeout, and 5xx errors never qualify for trial recovery.
{ "id": "ping_3f9a…", "type": "hook.ping", "apiVersion": "2026-10", "createdAt": "2026-10-25T14:00:00Z", "test": true, "hook": { "id": "…" }, "agent": { "key": "loja-bot" } }The ping is signed like every event, uses the hook’s headers and is sent in the hook’s body type: JSON, or a form with the same fields when the hook sends a form.
After recovery, click Redeliver all skipped to send any remaining skipped events, oldest first, with their original ids. Changing the URL of a quarantined hook, or turning it back on, probes it before any event is sent.
410 Gone is different: it pauses the hook at once, without quarantine. Turn it back on from the list when your
endpoint exists again.
Start from a template
Section titled “Start from a template”Start from a template next to New hook loads a ready-made hook as an editable copy; you add your URL and save.
| Template | When to use it |
|---|---|
| Lead to my CRM | Your CRM or backend takes a contact with name, e-mail, phone, interest, budget and the AI summary. |
| Tell the team on Slack when a human is requested | Your team watches a Slack channel; paste the channel’s incoming-webhook URL. |
| Zapier, Make or n8n: send everything | You build the flow in an automation tool; it receives every event in the standard envelope. |
| Customer went quiet: win the sale back | Your sales team follows up customers who stopped answering, with their last message. |
| Human-support SLA | You measure wait and handling times of human support in a BI tool. |
| Full conversation archive | You keep the transcript, the AI analysis and the customer memory of every closed conversation. |
| Agendor: create a person | You use Agendor: the hook calls POST /v3/people; add your API token after Token in the Authorization header. |
Read the caution above before relying on the Slack and Agendor templates in production.
Tutorial: a Node.js receiver
Section titled “Tutorial: a Node.js receiver”No dependencies, Node.js 18 or later. It reads the raw body, verifies it, answers 204 at once, drops repeats and
answers the ping.
import http from 'node:http';import { eventTypeOf } from './event-type.mjs';import { verifySynapsaiSignature } from './verify.mjs';
const MAX_BODY = 1024 * 1024; // SynapsAI sends at most 256 KBconst MAX_SEEN = 10_000;
/** * A SynapsAI lifecycle-hook receiver with no dependencies (Node.js 18+). * - Reads the RAW body: the signature covers the exact bytes. * - Answers fast (204) and does the work afterwards: a 2xx later than 5 seconds counts as a slow answer. * - Answers `hook.ping` with a 2xx too: it is how a quarantined hook comes back. * - Drops repeats: a retry or a redeliver keeps the same X-Synapsai-Webhook-Id. This in-memory set is per process and * bounded; in production keep the ids in a durable store (see the CRM recipe). */export function createReceiver({ secret, handle, seen = new Set() }) { const remember = (id) => { seen.add(id); if (seen.size > MAX_SEEN) seen.delete(seen.values().next().value); }; return http.createServer((req, res) => { if (req.method !== 'POST') { res.writeHead(405).end(); return; } const chunks = []; let size = 0; req.on('data', (chunk) => { size += chunk.length; if (size > MAX_BODY) { res.writeHead(413).end(); req.destroy(); return; } chunks.push(chunk); }); req.on('end', () => { const rawBody = Buffer.concat(chunks); const webhookId = req.headers['x-synapsai-webhook-id']; const ok = verifySynapsaiSignature({ secret, timestamp: req.headers['x-synapsai-timestamp'], webhookId, rawBody, signature: req.headers['x-synapsai-signature'], }); if (!ok) { res.writeHead(401).end(); return; } res.writeHead(204).end(); const type = eventTypeOf(rawBody); if (type === 'hook.ping' || seen.has(webhookId)) return; remember(webhookId); // Your work runs after the answer. Keep a durable queue in production (see the AWS Lambda tutorial). setImmediate(() => Promise.resolve(handle({ type, webhookId, body: rawBody.toString('utf8') })).catch(() => {})); }); });}
// Run: SYNAPSAI_HOOK_SECRET=whsec_... node receiver-node.mjsif (import.meta.url === `file://${process.argv[1]}`) { createReceiver({ secret: process.env.SYNAPSAI_HOOK_SECRET, handle: async ({ type, webhookId }) => console.log('received', type, webhookId), }).listen(Number(process.env.PORT ?? 3000));}SYNAPSAI_HOOK_SECRET=whsec_... PORT=3000 node receiver-node.mjsPut it behind HTTPS on port 443 (a load balancer, a reverse proxy or a tunnel) and paste that URL in the hook. Click
Send test event: the delivery log shows 204.
Tutorial: AWS Lambda
Section titled “Tutorial: AWS Lambda”A Lambda behind a Function URL is the smallest production receiver: it verifies, puts the event on an SQS FIFO
queue and answers 202 in milliseconds. A second Lambda consumes the queue at its own pace.
- Store the signing secret in Secrets Manager as plain text, and create an SQS queue whose name ends in
.fifo. - Create the Lambda (Node.js 20+ or Python 3.9+) with the code below, the environment variables
SECRET_ARNandQUEUE_URL, and a role allowedsecretsmanager:GetSecretValueon the secret andsqs:SendMessageon the queue. - Add a Function URL with auth type
NONE— the signature is the authentication — and paste it in the hook. - Click Send test event; the delivery log shows
202and the message is on the queue.
The FIFO queue keeps one conversation’s events in order (MessageGroupId = the conversation id) and drops a repeat
that arrives within 5 minutes (MessageDeduplicationId = the webhook id). A retry or a redeliver can come later
with the same id, so the queue consumer keeps a durable dedupe — the recipe
below uses a DynamoDB conditional write. After you rotate the secret, the function reads it again on the first
signature miss — at most once a minute — instead of waiting for a cold start.
import { eventTypeOf } from './event-type.mjs';import { verifySynapsaiSignature } from './verify.mjs';
/** * AWS Lambda behind a Function URL (Node.js 20+). It verifies the request, puts it on an SQS FIFO queue and answers * 202 at once; a separate consumer does the slow work. The FIFO queue drops a repeat of the same event within 5 minutes * (MessageDeduplicationId = the webhook id) and keeps one conversation's events in order (MessageGroupId). A retry or * a redeliver can come later than 5 minutes: the consumer keeps the durable dedupe (see crm-recipe.mjs). * * Environment: SECRET_ARN (Secrets Manager secret holding the signing secret as plain text), QUEUE_URL (a .fifo queue). */export function makeHandler({ getSecret, enqueue, now = () => Date.now() }) { return async (event) => { const headers = Object.fromEntries(Object.entries(event.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v])); const rawBody = Buffer.from(event.body ?? '', event.isBase64Encoded ? 'base64' : 'utf8'); const webhookId = headers['x-synapsai-webhook-id']; const check = (secret) => verifySynapsaiSignature({ secret, timestamp: headers['x-synapsai-timestamp'], webhookId, rawBody, signature: headers['x-synapsai-signature'], now: now(), }); // A rotated secret: on a miss, read it once more instead of waiting for a cold start. if (!check(await getSecret(false)) && !check(await getSecret(true))) { return { statusCode: 401 }; } const type = eventTypeOf(rawBody); if (type !== 'hook.ping') { await enqueue({ webhookId, type, body: rawBody.toString('utf8') }); } return { statusCode: 202 }; };}
/** Wires the handler to Secrets Manager and SQS (the AWS SDK v3 ships with the Lambda Node.js runtime). */export async function awsHandler() { const { SecretsManagerClient, GetSecretValueCommand } = await import('@aws-sdk/client-secrets-manager'); const { SQSClient, SendMessageCommand } = await import('@aws-sdk/client-sqs'); const secrets = new SecretsManagerClient({}); const sqs = new SQSClient({}); let cached = null; let readAt = 0; return makeHandler({ getSecret: async (refresh) => { if (!cached || (refresh && Date.now() - readAt > 60_000)) { const out = await secrets.send(new GetSecretValueCommand({ SecretId: process.env.SECRET_ARN })); cached = out.SecretString; readAt = Date.now(); } return cached; }, enqueue: async ({ webhookId, type, body }) => { let group = 'synapsai'; try { group = JSON.parse(body)?.conversation?.id ?? group; } catch { // A form body: one group keeps the order across all events. } await sqs.send(new SendMessageCommand({ QueueUrl: process.env.QUEUE_URL, MessageBody: body, MessageGroupId: group, MessageDeduplicationId: webhookId, MessageAttributes: { type: { DataType: 'String', StringValue: type } }, })); }, });}
let wired;export const handler = async (event) => { wired ??= await awsHandler(); return wired(event);};"""AWS Lambda behind a Function URL (Python 3.9+).
It verifies the request, puts it on an SQS FIFO queue and answers 202 at once. The FIFO queue drops a repeatwithin 5 minutes only: a later retry or redeliver keeps the same id, so the consumer keeps a durable dedupe. Environment: SECRET_ARN (the signingsecret, plain text, in Secrets Manager) and QUEUE_URL (a .fifo queue)."""import base64import hashlibimport hmacimport jsonimport osimport time
def verify_synapsai_signature(secret, timestamp, webhook_id, raw_body, signature, tolerance_seconds=300, now=None): try: if abs(int(now if now is not None else time.time()) - int(timestamp)) > tolerance_seconds: return False except (TypeError, ValueError): return False message = f"{timestamp}.{webhook_id}.".encode() + raw_body expected = "sha256=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest() # bytes on both sides: a non-ASCII header is a mismatch (401), never a TypeError (500) return hmac.compare_digest(expected.encode(), (signature or "").encode("utf-8", "replace"))
def event_type_of(raw_body): """The event type from the SIGNED body (JSON, or a form's `type`); the X-Synapsai-Event header is a hint only.""" text = raw_body.decode("utf-8", "replace") try: value = json.loads(text).get("type") if isinstance(value, str): return value except (ValueError, AttributeError): pass from urllib.parse import parse_qs return (parse_qs(text).get("type") or [None])[0]
def make_handler(get_secret, enqueue, now=time.time): def handler(event, _context=None): headers = {k.lower(): v for k, v in (event.get("headers") or {}).items()} body = event.get("body") or "" raw_body = base64.b64decode(body) if event.get("isBase64Encoded") else body.encode() webhook_id = headers.get("x-synapsai-webhook-id")
def check(secret): return verify_synapsai_signature(secret, headers.get("x-synapsai-timestamp"), webhook_id, raw_body, headers.get("x-synapsai-signature"), now=now())
# A rotated secret: on a miss, read it once more instead of waiting for a cold start. if not check(get_secret(False)) and not check(get_secret(True)): return {"statusCode": 401} event_type = event_type_of(raw_body) if event_type != "hook.ping": enqueue(webhook_id, event_type, raw_body.decode()) return {"statusCode": 202}
return handler
def _aws_handler(): import boto3 # ships with the Lambda Python runtime
secrets = boto3.client("secretsmanager") sqs = boto3.client("sqs") state = {"secret": None, "read_at": 0.0}
def get_secret(refresh): if state["secret"] is None or (refresh and time.time() - state["read_at"] > 60): state["secret"] = secrets.get_secret_value(SecretId=os.environ["SECRET_ARN"])["SecretString"] state["read_at"] = time.time() return state["secret"]
def enqueue(webhook_id, event_type, body): try: group = (json.loads(body).get("conversation") or {}).get("id") or "synapsai" except ValueError: group = "synapsai" # a form body: one group keeps the order sqs.send_message(QueueUrl=os.environ["QUEUE_URL"], MessageBody=body, MessageGroupId=group, MessageDeduplicationId=webhook_id, MessageAttributes={"type": {"DataType": "String", "StringValue": event_type}})
return make_handler(get_secret, enqueue)
_wired = None
def handler(event, context): global _wired if _wired is None: _wired = _aws_handler() return _wired(event, context)Recipe: conversation ended → CRM contact
Section titled “Recipe: conversation ended → CRM contact”Turn on Contact, AI analysis and Lead, subscribe to Conversation ended, and point the hook at the
Lambda above. The queue consumer maps each event to a contact and calls your CRM’s API. It claims the webhook id in a
DynamoDB table first (partition key id, TTL attribute expiresAt), so a redeliver days later never creates a second
contact; a failed CRM call releases the claim and throws, so SQS retries the message.
/** * Recipe: from `conversation.ended` to a CRM contact. The SQS consumer of the Lambda tutorial calls `toCrmContact` * for each message and sends the result to your CRM's API (`postContact`). Hooks send the data you chose: turn on * Contact, AI analysis and Lead for this recipe. */export function toCrmContact(envelope) { if (envelope.type !== 'conversation.ended' || envelope.test) return null; const contact = envelope.contact ?? {}; if (!contact.email && !contact.phone) return null; const lead = envelope.lead ?? {}; return { externalId: contact.id, name: contact.name ?? null, email: contact.email ?? null, phone: contact.phone ?? null, note: envelope.intel?.status === 'SETTLED' ? envelope.intel.summary : null, leadScore: lead.score ?? null, interest: lead.interest ?? null, budget: lead.budget ? `${lead.budget.amount} ${lead.budget.currency}` : null, source: `SynapsAI ${envelope.agent?.key ?? ''} · ${envelope.conversation?.channel ?? ''}`.trim(), conversationId: envelope.conversation?.id, };}
/** * The SQS consumer: one message, one event. `claim(id)` records the webhook id durably and answers false for a repeat * (the FIFO queue only drops repeats within 5 minutes; a retry or a redeliver can come days later with the same id). * A failed CRM call releases the claim and throws, so SQS retries the message. */export async function handleMessage(message, postContact, { claim, release }) { const envelope = JSON.parse(message.body); const contact = toCrmContact(envelope); if (!contact) return; if (!(await claim(envelope.id))) return; try { await postContact(contact); } catch (error) { await release(envelope.id); throw error; }}
/** * A durable claim in DynamoDB: a conditional put on the webhook id, kept 45 days (longer than the 30-day delivery log * a redeliver can come from). Table: partition key `id` (string), TTL attribute `expiresAt`. */export async function dynamoClaims(tableName) { const { DynamoDBClient, PutItemCommand, DeleteItemCommand } = await import('@aws-sdk/client-dynamodb'); const db = new DynamoDBClient({}); return { claim: async (id) => { try { await db.send(new PutItemCommand({ TableName: tableName, Item: { id: { S: id }, expiresAt: { N: String(Math.floor(Date.now() / 1000) + 45 * 86_400) } }, ConditionExpression: 'attribute_not_exists(id)', })); return true; } catch (error) { if (error.name === 'ConditionalCheckFailedException') return false; throw error; } }, release: async (id) => { await db.send(new DeleteItemCommand({ TableName: tableName, Key: { id: { S: id } } })); }, };}Test events ("test": true) and contacts without an e-mail or phone are skipped. When intel.status is not
SETTLED, the AI summary is missing and the note stays empty.