Ir al contenido

Verify reply signatures

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

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 atThe secret is
the callbackUrl you sent with a messagethe agent’s signing secret (agent → Extensions → Workflow API (n8n · Make))
a subscription URL (trigger)the signingSecret returned when the subscription was created

Four rules:

  1. Compute the HMAC over the raw body bytes, before any JSON parsing, and compare in constant time.
  2. Refuse a timestamp more than 5 minutes from your clock (it is fresh on every attempt).
  3. Answer 2xx quickly and work afterwards; anything else is retried (30 s, 1, 2, 4, 8 min).
  4. Deduplicate on X-Synapsai-Webhook-Id: it is the same on every retry.
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);
}
reply-receiver.mjs
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(() => {}));
});
});
}
reply_receiver.py
"""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 (hook
subscription). The signature covers the raw body; the timestamp may be at most 5 minutes from your clock.
"""
import hashlib
import hmac
import json
import 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 "", status

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

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.