Ir al contenido

Lifecycle Hooks Guide

Esta página aún no está disponible en tu idioma.

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.

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)
  1. Something happens in a conversation: it starts, goes quiet, asks for a person, an operator joins or leaves, or it ends.
  2. 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.
  3. Your endpoint checks the signature and answers 2xx within 5 seconds. That is a delivery.
  4. 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.
EventTypical use
conversation.startedOpen a ticket or a CRM activity when a customer writes
conversation.idleFollow up a customer who went quiet before the chat closes
conversation.endedPush the AI summary, the lead and the contact to your CRM
handoff.requestedAlert your team that a customer wants a person
operator.joined / operator.leftTrack who handled the chat and for how long
handoff.endedMeasure 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.

Five rules make a receiver correct. The tutorials below follow all of them.

  1. 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 401 when it fails.
  2. Answer within 5 seconds, then work. Put the event on a queue and answer 2xx at once. A 2xx that 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.
  3. 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; the X-Synapsai-Event header is a hint.
  4. Do not rely on arrival order. Order by createdAt; keep the newest state per conversation.
  5. Answer hook.ping with a 2xx. It carries no conversation data and is how a quarantined hook comes back (see below).
verify.mjs
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.

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:

  1. Connectivity check: A signed hook.ping checks your endpoint. Any 2xx response within 5 seconds releases the hook immediately.
  2. Trial-event recovery: If the endpoint returns a 4xx client error (such as HTTP 400 typical 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 original X-Synapsai-Webhook-Id header (so receivers deduplicate idempotently). A 2xx ≤ 5 s response 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 next to New hook loads a ready-made hook as an editable copy; you add your URL and save.

TemplateWhen to use it
Lead to my CRMYour 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 requestedYour team watches a Slack channel; paste the channel’s incoming-webhook URL.
Zapier, Make or n8n: send everythingYou build the flow in an automation tool; it receives every event in the standard envelope.
Customer went quiet: win the sale backYour sales team follows up customers who stopped answering, with their last message.
Human-support SLAYou measure wait and handling times of human support in a BI tool.
Full conversation archiveYou keep the transcript, the AI analysis and the customer memory of every closed conversation.
Agendor: create a personYou 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.

No dependencies, Node.js 18 or later. It reads the raw body, verifies it, answers 204 at once, drops repeats and answers the ping.

receiver-node.mjs
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 KB
const 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.mjs
if (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));
}
Terminal window
SYNAPSAI_HOOK_SECRET=whsec_... PORT=3000 node receiver-node.mjs

Put 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.

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.

  1. Store the signing secret in Secrets Manager as plain text, and create an SQS queue whose name ends in .fifo.
  2. Create the Lambda (Node.js 20+ or Python 3.9+) with the code below, the environment variables SECRET_ARN and QUEUE_URL, and a role allowed secretsmanager:GetSecretValue on the secret and sqs:SendMessage on the queue.
  3. Add a Function URL with auth type NONE — the signature is the authentication — and paste it in the hook.
  4. Click Send test event; the delivery log shows 202 and 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.

index.mjs
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);
};

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.

crm-recipe.mjs
/**
* 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.