Verify reply signatures
Dieser Inhalt ist noch nicht in deiner Sprache verfügbar.
Replies to your callbackUrl and to your subscriptions are signed exactly like
lifecycle hooks:
X-Synapsai-Signature: sha256=hex( HMAC-SHA256( secret, X-Synapsai-Timestamp + "." + X-Synapsai-Webhook-Id + "." + raw body ) )| You receive it at | The secret is |
|---|---|
the callbackUrl you sent with a message | the agent’s signing secret (agent → Extensions → Workflow API (n8n · Make)) |
| a subscription URL (trigger) | the signingSecret returned when the subscription was created |
Four rules:
- Compute the HMAC over the raw body bytes, before any JSON parsing, and compare in constant time.
- Refuse a timestamp more than 5 minutes from your clock (it is fresh on every attempt).
- Answer
2xxquickly and work afterwards; anything else is retried (30 s, 1, 2, 4, 8 min). - Deduplicate on
X-Synapsai-Webhook-Id: it is the same on every retry.
The check
Section titled “The check”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);}Node.js receiver
Section titled “Node.js receiver”import http from 'node:http';import { verifySynapsaiSignature } from '../lifecycle-hooks/verify.mjs';
const MAX_BODY = 1024 * 1024;const MAX_SEEN = 10_000;const REPLY_EVENTS = new Set(['message.replied', 'message.operator', 'message.failed']);
/** * Receives the replies of the SynapsAI API channel at your `callbackUrl` (Node.js 18+, no dependencies). * - The signing secret is the AGENT's API channel secret for a callbackUrl, or the subscription's secret for a hook * subscription. * - Verifies the signature on the raw body (±5 minutes), answers 204 at once, and hands the reply on. * - Correlates on `message.id`: the `messageId` that POST /v1/agents/{agentKey}/messages returned. * - Drops repeats on X-Synapsai-Webhook-Id (stable across retries). This set is per process and bounded; keep the ids * durably in production. */export function createReplyReceiver({ secret, onReply, seen = new Set() }) { 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(); if (seen.has(webhookId)) return; seen.add(webhookId); if (seen.size > MAX_SEEN) seen.delete(seen.values().next().value); let event; try { event = JSON.parse(rawBody.toString('utf8')); } catch { return; // signed but not JSON: nothing to hand on, and never a crash after the answer } if (!REPLY_EVENTS.has(event?.type)) return; const reply = { type: event.type, messageId: event.message?.id ?? null, conversationId: event.conversation?.id ?? null, text: event.message?.text ?? null, components: event.message?.components ?? [], metadata: event.message?.metadata ?? null, errorCode: event.error?.code ?? null, }; setImmediate(() => Promise.resolve(onReply(reply)).catch(() => {})); }); });}Python receiver
Section titled “Python receiver”"""Receives the replies of the SynapsAI API channel at your callbackUrl (Python 3.9+, standard library only).
The signing secret is the agent's API channel secret (direct callbackUrl) or the subscription's secret (hooksubscription). The signature covers the raw body; the timestamp may be at most 5 minutes from your clock."""import hashlibimport hmacimport jsonimport time
REPLY_EVENTS = {"message.replied", "message.operator", "message.failed"}
def verify_synapsai_signature(secret, timestamp, webhook_id, raw_body, signature, tolerance_seconds=300, now=None): if not secret or not webhook_id or not signature: return False 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() return hmac.compare_digest(expected.encode(), signature.encode("utf-8", "replace"))
def parse_reply(headers, raw_body, secret, seen, now=None): """Returns (status, reply): 401 and None when the signature fails; 204 and None for a repeat or another event; 204 and the flattened reply otherwise. `headers` keys are lower-case.""" webhook_id = headers.get("x-synapsai-webhook-id") if not verify_synapsai_signature(secret, headers.get("x-synapsai-timestamp"), webhook_id, raw_body, headers.get("x-synapsai-signature"), now=now): return 401, None if webhook_id in seen: return 204, None seen.add(webhook_id) event = json.loads(raw_body) if event.get("type") not in REPLY_EVENTS: return 204, None message = event.get("message") or {} return 204, { "type": event["type"], "messageId": message.get("id"), "conversationId": (event.get("conversation") or {}).get("id"), "text": message.get("text"), "components": message.get("components") or [], "metadata": message.get("metadata"), "errorCode": (event.get("error") or {}).get("code"), }
# Flask:# @app.post("/synapsai/replies")# def replies():# status, reply = parse_reply({k.lower(): v for k, v in request.headers.items()}, request.get_data(), SECRET, SEEN)# if reply:# queue.put(reply)# return "", statusMake cannot check the signature (it has no raw body). The SynapsAI app’s triggers instead re-read every event by id with your API key and pass on only what they read back, so a forged POST to a Make webhook URL cannot inject data — see the Make quickstart.
In an n8n Code node
Section titled “In an n8n Code node”When you use the built-in Wait node instead of the SynapsAI node, enable Options → Raw Body on it and check the signature in a Code node. The SynapsAI node does this for you.