Outbound Webhooks
Outbound webhooks allow HuskyVoice AI to push real-time event data to your server as JSON payloads via HTTPS POST requests.
How to Listen to Webhooks
HuskyVoice sends real-time event data to a URL you provide. Follow these steps to start receiving events:
- Create a public HTTPS endpoint on your server to receive POST requests
- Register your URL — Go to Dashboard → Workflows → Webhooks → Add Webhook (1 endpoint on the Base plan, up to 5 on Professional and Enterprise plans)
- Select the events you want to listen to (e.g.,
call.completed,call.failed) - Return
200 OKimmediately when your server receives the request — HuskyVoice waits up to 8 seconds for a response before treating the attempt as failed and queuing a retry - Process the event asynchronously after acknowledging
Example — Webhook listener:
- cURL
- Python
- Node.js
- n8n
# Simulate a webhook delivery to test your endpoint.
# X-Webhook-Signature below is a placeholder — see "Signature Verification" further
# down this page for a runnable example that computes a real v1=<base64> value.
curl -X POST https://your-server.com/webhook \
-H "Content-Type: application/json" \
-H "X-Webhook-Id: evt_test123" \
-H "X-Webhook-Timestamp: 1748000000" \
-H "X-Webhook-Signature: v1=your_expected_hmac_base64" \
-d '{
"event_id": "evt_test123",
"event_type": "call.completed",
"created_at": "2026-06-01T10:00:00Z",
"data": {
"call_id": "call_987654321",
"status": "completed",
"call_direction": "outbound",
"call_to": "+15551234567",
"call_from": null,
"call_duration_seconds": 124,
"credits_consumed": 2,
"agent_id": "agent_alpha",
"agent_name": "Priya",
"call_recording": "https://huskyvoice-media.s3.ap-south-1.amazonaws.com/recordings/call_987654321.mp3?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIA.../20260601/ap-south-1/s3/aws4_request&X-Amz-Date=20260601T100000Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=examplesig123",
"call_details_url": null,
"share_token": null,
"call_analytics": {
"summary": "Customer confirmed appointment.",
"sentiment": "positive",
"disposition": "appointment_booked",
"next_steps": "Send appointment confirmation"
},
"extracted_details": {
"appointment_date": "2026-06-05",
"appointment_time": "2:00 PM",
"service_type": "Consultation"
},
"appointment_details": {
"appointment_id": "apt_123456",
"customer_name": "Jordan Lee",
"appointment_type": "consultation",
"duration_minutes": 30
},
"call_transcript": "https://huskyvoice-media.s3.ap-south-1.amazonaws.com/transcripts/call_987654321.json?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIA.../20260601/ap-south-1/s3/aws4_request&X-Amz-Date=20260601T100000Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=examplesig456",
"call_triggered_at": "2026-06-01T09:58:00Z",
"scheduled_at": null,
"completed_at": "2026-06-01T10:00:00Z",
"error": null,
"contact_name": "Jordan Lee",
"contact_email": "jordan@example.com",
"contact_preferred_language": null,
"custom_data": null
}
}'
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route('/webhook', methods=['POST'])
def webhook():
# Always acknowledge immediately
response = request.get_json()
event_id = response.get('event_id')
event_type = response.get('event_type')
data = response.get('data', {})
print(f"Webhook received: {event_type} (event_id: {event_id})")
# For call.completed events
if event_type == 'call.completed':
call_id = data.get('call_id')
duration = data.get('call_duration_seconds')
summary = data.get('call_analytics', {}).get('summary')
print(f"Call {call_id} completed in {duration}s")
print(f"Summary: {summary}")
# call_transcript is normally a temporary signed S3 URL (valid 5 minutes) —
# fetch it right away. It falls back to an inline array of utterances only
# for calls that never produced a full transcript file.
transcript = data.get('call_transcript')
if isinstance(transcript, str):
print(f"Transcript URL (expires in 5 min): {transcript}")
elif isinstance(transcript, list):
print(f"Got inline transcript with {len(transcript)} utterances")
return jsonify({"status": "ok"}), 200
if __name__ == '__main__':
app.run(port=3000)
const express = require('express');
const app = express();
app.use(express.json());
app.post('/webhook', async (req, res) => {
// Always acknowledge immediately
res.status(200).send('OK');
const { event_id, event_type, data } = req.body;
console.log(`Webhook: ${event_type} (${event_id})`);
if (event_type === 'call.completed') {
const { call_id, call_duration_seconds, call_analytics, call_transcript } = data;
console.log(`Call ${call_id} completed in ${call_duration_seconds}s`);
console.log(`Summary: ${call_analytics?.summary}`);
console.log(`Sentiment: ${call_analytics?.sentiment}`);
// call_transcript is normally a temporary signed S3 URL (valid 5 minutes) —
// fetch it right away. It falls back to an inline array of utterances only
// for calls that never produced a full transcript file.
if (typeof call_transcript === 'string') {
console.log(`Transcript URL (expires in 5 min): ${call_transcript}`);
} else if (Array.isArray(call_transcript)) {
console.log(`Inline transcript: ${call_transcript.length} utterances`);
}
}
});
app.listen(3000, () => console.log('Webhook listener running on port 3000'));
Add a Webhook trigger node in n8n. Register the generated webhook URL in the HuskyVoice Dashboard under Workflows → Webhooks → Add Webhook.
{
"name": "HuskyVoice Event Listener",
"nodes": [
{
"parameters": {
"path": "huskyvoice-events",
"options": {}
},
"id": "1",
"name": "HuskyVoice Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [250, 300]
},
{
"parameters": {
"jsCode": "const { event_type, data } = $input.first().json.body;\n\nif (event_type === 'call.completed') {\n console.log(`Call ${data.call_id} completed. Duration: ${data.call_duration_seconds}s`);\n console.log(`Summary: ${data.call_analytics?.summary}`);\n}\n\nif (event_type === 'call.failed') {\n console.log(`Call ${data.call_id} failed: ${data.error}`);\n}\n\nreturn $input.all();"
},
"id": "2",
"name": "Process Event",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [450, 300]
}
],
"connections": {
"HuskyVoice Webhook": {
"main": [[{ "node": "Process Event", "type": "main", "index": 0 }]]
}
},
"settings": {},
"meta": { "instanceId": "huskyvoice-docs" }
}
Use a tool like ngrok to expose your local server to the internet while testing — run ngrok http 3000 and use the generated HTTPS URL in your dashboard.
Event Types
| Event | Description |
|---|---|
call.initiated | Fired immediately when the telephony system successfully triggers the call, before it's answered or completed. |
call.completed | Fired when the call ends successfully. Includes full call analytics and transcript. |
call.failed | Fired when the call could not be completed due to a technical failure (busy, no answer, carrier/infrastructure error). |
call.disallowed | Fired when a call is blocked for an internal policy reason (e.g. insufficient credits) — distinct from call.failed, which is a technical failure during an attempted call. |
call.answered is not currently a supported event — only the four events above are deliverable.
Payload Schema
Every webhook delivery shares a consistent envelope structure. The data object contains the event-specific payload and varies by event type.
Complete Example
{
"event_id": "evt_c961fe0c-9cbd-4b7b-855e-0a555205f8f0",
"event_type": "call.completed",
"created_at": "2026-06-01T10:00:00.000Z",
"data": {
"call_id": "call_987654321",
"status": "completed",
"call_direction": "outbound",
"call_to": "+15551234567",
"call_from": null,
"call_duration_seconds": 124,
"credits_consumed": 2,
"agent_id": "agent_alpha",
"agent_name": "Priya",
"call_recording": "https://huskyvoice-media.s3.ap-south-1.amazonaws.com/recordings/call_987654321.mp3?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIA.../20260601/ap-south-1/s3/aws4_request&X-Amz-Date=20260601T100000Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=examplesig123",
"call_details_url": null,
"call_analytics": {
"summary": "Customer confirmed appointment for June 5th at 2:00 PM.",
"sentiment": "positive",
"next_steps": "Send appointment confirmation email.",
"disposition": "appointment_booked"
},
"extracted_details": {
"appointment_date": "2026-06-05",
"appointment_time": "2:00 PM",
"service_type": "Consultation"
},
"appointment_details": {
"appointment_id": "appt_a1b2c3d4e5",
"start_time": "2026-06-05T14:00:00.000Z",
"status": "confirmed"
},
"call_transcript": "https://huskyvoice-media.s3.ap-south-1.amazonaws.com/transcripts/call_987654321.json?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIA.../20260601/ap-south-1/s3/aws4_request&X-Amz-Date=20260601T100000Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=examplesig456",
"call_triggered_at": "2026-06-01T09:58:00.000Z",
"scheduled_at": null,
"completed_at": "2026-06-01T10:00:00.000Z",
"error": null,
"contact_name": "Jordan Lee",
"contact_email": "jordan@example.com",
"contact_preferred_language": "hindi",
"custom_data": {
"campaign_id": "camp_123",
"lead_id": "lead_456"
},
"scheduled_call_id": null
}
}
call_recording, call_transcript, call_details_url, share_token, credits_consumed, custom_data, scheduled_call_id, and the top-level extracted_details / appointment_details fields are the exact field names and structure the outbound webhook dispatcher sends today. By default, call_recording and call_transcript are temporary signed S3 URLs, valid for 5 minutes from when a delivery attempt was sent, and call_details_url / share_token are null — enable Public call share to get stable, non-expiring public links instead (see Public Call Share Links below). Earlier versions of this page described call_recording as a dashboard deep-link, described call_transcript as always an inline array, and described extracted_details / appointment_details as nested inside call_analytics — none of that matches the current implementation.
Example: Callback Scheduled
When the agent schedules a callback mid-conversation (Dashboard → Agent → Call tab → Enable Callback
Scheduling), the triggering call's own call.completed event carries scheduled_call_id — the call_id of
the new call that will be placed later:
{
"event_id": "evt_1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"event_type": "call.completed",
"created_at": "2026-06-01T10:00:00.000Z",
"data": {
"call_id": "call_987654321",
"status": "completed",
"call_direction": "outbound",
"call_analytics": {
"summary": "Customer asked to be called back tomorrow afternoon.",
"sentiment": "neutral",
"next_steps": "Agent will call back as scheduled.",
"disposition": "callback_scheduled"
},
"scheduled_call_id": "call_abc123de-f456-7890-abcd-ef1234567890"
}
}
Example: Automatic Retry Scheduled
When a dial attempt fails or goes unanswered and Call Retry is configured on the agent, the failed call's
own call.failed event also carries scheduled_call_id — the call_id of the automatically-scheduled retry
attempt. call.failed events never include call_analytics/disposition (see the Call Metadata Fields table
below), so this is the only signal a retry was scheduled:
{
"event_id": "evt_2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e",
"event_type": "call.failed",
"created_at": "2026-06-01T10:00:00.000Z",
"data": {
"call_id": "call_123456789",
"status": "failed",
"call_direction": "outbound",
"error": "SIP status: 480 Temporarily Unavailable",
"scheduled_call_id": "call_def456gh-i789-0123-defg-hi4567890123"
}
}
Envelope Structure
The top-level wrapper that wraps every webhook event:
| Field | Type | Required | Description |
|---|---|---|---|
event_id | string | Yes | Unique event delivery ID, prefixed evt_. Use for idempotency tracking. |
event_type | string | Yes | The type of event: call.initiated, call.completed, call.failed, or call.disallowed. |
created_at | string (ISO 8601) | Yes | Timestamp when the event was generated (e.g., 2026-06-01T10:00:00.000Z). |
data | object | Yes | Event-specific payload containing call details and metadata. |
Call Metadata Fields
Core information about the call itself:
| Field | Type | Required | Description |
|---|---|---|---|
call_id | string | Yes | Unique identifier for the call. |
status | string | Yes | Call status: completed, initiated, failed, or disallowed. |
call_direction | string | Yes | inbound (incoming call) or outbound (placed call). |
call_to | string | null | Yes | Dialed phone number. Populated for outbound calls; null for inbound calls. |
call_from | string | null | Yes | Caller's phone number. Populated for inbound calls; null for outbound calls. |
call_duration_seconds | number | Yes | Total duration of the call in seconds. |
credits_consumed | number | null | No | Number of credits deducted for this call. Only populated on call.completed — null for call.initiated, call.failed, and call.disallowed events, since credits are only deducted when a call completes. |
agent_id | string | null | No | ID of the agent that handled or was assigned to the call. |
agent_name | string | null | No | Display name of the agent. |
error | string | null | Conditional | Error message. Only populated on call.failed and call.disallowed events. |
scheduled_call_id | string | null | No | call_id of a new call scheduled as a direct result of this one — either the agent scheduling a callback during the conversation, or this call's outcome triggering an automatic retry. Can appear on call.completed (callback scheduled, or a retryable outcome reached after the agent connected — e.g. voicemail) or call.failed (a retryable SIP-level failure before the agent connected — e.g. no answer). null otherwise. |
Timestamp Fields
When key events occurred:
| Field | Type | Required | Description |
|---|---|---|---|
call_triggered_at | string (ISO 8601) | null | No | When the call was actually placed to the recipient. |
scheduled_at | string (ISO 8601) | null | No | When the call was scheduled for (if placed via the scheduling API). |
completed_at | string (ISO 8601) | null | No | When the call ended. |
Contact Information
Caller or contact details:
| Field | Type | Required | Description |
|---|---|---|---|
contact_name | string | null | No | Name of the contact, from call setup or captured during the call. |
contact_email | string | null | No | Email of the contact, from call setup or captured during the call. |
contact_preferred_language | string | null | No | The language the call was conducted in, from call setup, or null if none was set (in which case the agent's default language was used). |
custom_data | object | null | No | Custom metadata (key-value pairs) attached when triggering the call, or null if none was set. Useful for linking back to your internal IDs or campaigns. |
Media & Analysis
Call recordings, transcripts, and AI-generated insights:
| Field | Type | Required | Description |
|---|---|---|---|
call_recording | string | null | No | Temporary signed S3 URL to the call recording audio file, valid for 5 minutes from when this delivery attempt was sent. null if no recording exists for this call. Because the link expires quickly, download or process it promptly rather than storing the URL for later use — a retried or manually replayed delivery gets a freshly re-signed URL at send time, but the copy shown in the Dashboard's Deliveries view is signed once at creation and will look expired after 5 minutes. Becomes a stable, non-expiring public link instead when your org has Public call share enabled — see Public Call Share Links below. |
call_details_url | string | null | No | A public, no-login URL to an embeddable call-details page (Call Details, Insights, Recording & Transcript, Appointments) — safe to load in an <iframe> in your own app. Only sent when your org has Public call share enabled (Dashboard → Account Settings → Organization); null otherwise, or if the call has no resolvable call_id. See Public Call Share Links below. |
share_token | string | null | No | The same token embedded in call_details_url's share_token query parameter, sent again here as a plain value — use this if you want to call GET https://api.huskyvoice.ai/public/call/{call_id} (with ?token={share_token}) yourself instead of following the pre-built URL. null under the same conditions as call_details_url. |
call_transcript | string | array | null | No | Normally a temporary signed S3 URL to the full transcript file (same 5-minute expiry and re-signing behavior as call_recording, and the same permanent-link behavior when Public call share is enabled). Falls back to an inline array of utterances (shape below) only for calls that never produced a transcript file, only a real-time transcript. null if no transcript is available at all. |
call_analytics | object | null | No | AI-generated insights: summary, sentiment, next steps, and disposition — see schema below. null if analysis hasn't completed yet. |
When call_transcript falls back to an inline array, each entry has the shape:
{ "speaker": "agent", "text": "Hi, this is Priya...", "timestamp": 0.0 }
| Field | Type | Description |
|---|---|---|
speaker | string | Who said it — typically agent or user (raw diarization labels like speaker_0 are possible if speaker mapping wasn't resolved). |
text | string | The utterance text. |
timestamp | number | Seconds from the start of the call. |
Public Call Share Links
call_details_url (and, when a recording/transcript exist, call_recording / call_transcript) can be a
stable, public link rather than a short-lived signed URL — enable Public call share under
Dashboard → Account Settings → Organization to turn this on for your org.
"call_details_url": "https://studio.huskyvoice.ai/public/call/call_987654321?share_token=8f2a1c9d4e7b0a3c6f5d2e1b9a8c7d6e5f4a3b2c",
"share_token": "8f2a1c9d4e7b0a3c6f5d2e1b9a8c7d6e5f4a3b2c"
- The link itself never expires by default; you can optionally set an expiry (1 hour / day / week / month) for newly-issued links under the same setting. Existing links keep whatever expiry they were issued with even if you change the setting later — changing the expiry setting only affects calls handled afterward.
- No login is required to open it — it's meant to be embedded (e.g. in an
<iframe>) inside your own app. call_details_urlrenders a read-only page with the call's Details, Insights, Recording & Transcript, and Appointments (if any). Agent Logs are never included on this public page, even for orgs that can see them in the Dashboard.share_tokenis the same token that's embedded incall_details_url's query string, sent again as its own field — use it directly if you'd rather callGET /public/call/{call_id}?token={share_token}yourself (returns the raw JSON: call, insights, transcript, appointments, recording URL) than embed the prebuilt page. One token gates all three public links for a call —call_details_url,call_recording, andcall_transcript.- If Public call share is left off (the default),
call_details_urlandshare_tokenare alwaysnull, andcall_recording/call_transcriptare short-lived signed URLs instead (valid for a few minutes from delivery time).
For how to embed call_details_url in your app (iframe example, cross-origin/security notes), or how to build
your own recording/transcript player from call_recording / call_transcript / share_token instead, see
Embedding & Parsing Call Data.
call_analytics Schema
Nested object containing AI-generated analysis:
{
"summary": "2–3 sentence overview of the call",
"sentiment": "positive|neutral|negative",
"next_steps": "recommended follow-up action",
"disposition": "appointment_booked|transferred|not_interested|voicemail|callback_scheduled|ended_by_customer|session_ended"
}
| Field | Type | Required | Description |
|---|---|---|---|
summary | string | Yes | 2–3 sentence summary of the call outcome and key points discussed. |
sentiment | string | Yes | Overall call sentiment: positive, neutral, or negative. |
next_steps | string | Yes | Recommended next action (e.g., "Schedule follow-up call", "Send proposal", "No action needed"). |
disposition | string | Yes | Call outcome code: appointment_booked, transferred, not_interested, voicemail, callback_scheduled, ended_by_customer, or session_ended. callback_scheduled is always paired with a populated scheduled_call_id (see above). |
extracted_details and appointment_details are top-level data fields, not nested here — see below.
extracted_details
Top-level field, sibling of call_analytics.
| Field | Type | Required | Description |
|---|---|---|---|
extracted_details | object | null | No | Key-value pairs of concrete facts mentioned in the call (dynamic schema, keys vary per call). null if call analysis hasn't completed yet for this event; otherwise an object, empty {} if nothing concrete was mentioned. |
The extracted_details object contains only the facts that were actually mentioned in the call. It uses a dynamic schema — keys are determined by call content, not a fixed format.
Examples:
- Customer gave appointment date →
{ "appointment_date": "2026-06-05" } - Customer mentioned a complaint →
{ "complaint": "Long wait times", "satisfaction_rating": 3 } - Nothing concrete discussed →
{}(empty object)
Best practice: Iterate safely over Object.entries(data.extracted_details || {}) rather than accessing specific keys, since the schema varies per call.
appointment_details
Top-level field, sibling of call_analytics.
| Field | Type | Required | Description |
|---|---|---|---|
appointment_details | object | null | Conditional | null unless the call resulted in a matched appointment record. When present, it's the raw appointment document — its field shape matches the appointment object described in Appointment Webhooks, not a fixed subset. |
Signature Verification
HuskyVoice signs every webhook delivery with an HMAC-SHA256 signature so you can verify it actually came from us. Every request includes:
| Header | Always sent? | Used in signature? | Description |
|---|---|---|---|
Content-Type | Yes | No | Always application/json. |
User-Agent | Yes | No | Always AppEQ-Webhooks/1.0. |
X-Webhook-Id | Yes | No | The event_id of this delivery. Not part of the signed payload — see below. |
X-Webhook-Timestamp | Only if signing is enabled | Yes | Unix timestamp (seconds) the request was sent. Reject requests where this is too far in the past to guard against replay attacks. |
X-Webhook-Signature | Only if signing is enabled | Output | v1=<signature> — see formula below. |
Signature verification is enabled by default — if it's turned off for an endpoint, X-Webhook-Timestamp and X-Webhook-Signature will not be sent at all. Disabling it is not recommended.
Signed payload formula
signed_payload = X-Webhook-Timestamp + "." + raw_request_body
signature = "v1=" + Base64(
HMAC-SHA256(webhook_secret, signed_payload)
)
X-Webhook-Idis not included in the signature input. Its presence alongside a timestamp naturally suggests anid.timestamp.bodyscheme — that is not what HuskyVoice uses. Only the timestamp and raw body are signed.- The digest is Base64-encoded, not hex.
raw_request_bodymust be the exact HTTP request body bytes, captured before JSON parsing. Parsing and re-serializing the JSON can change whitespace, key order, or escaping and will cause verification to fail:
// Correct — capture the raw text first, then parse
const rawBody = await request.text();
const payload = JSON.parse(rawBody);
// Wrong — re-serializing can produce different bytes than what was signed
const payload = await request.json();
const rawBody = JSON.stringify(payload);
Your webhook secret is shown once at creation time, in the format whsec_.... Use it exactly as provided, including the whsec_ prefix — the prefix is part of the HMAC key, not metadata. Do not strip it and do not Base64-decode the secret before using it as the key:
// Correct
crypto.createHmac('sha256', 'whsec_abc123...')
// Wrong — the prefix must stay, and the secret is not Base64-encoded
crypto.createHmac('sha256', Buffer.from(secret.replace('whsec_', ''), 'base64'))
- cURL
- Python
- Node.js
- n8n
# Full example: compute a signature and send a test request to your own endpoint
SECRET="whsec_your_webhook_secret"
TIMESTAMP="$(date +%s)"
BODY='{"event_id":"evt_test123","event_type":"call.completed","data":{}}'
SIGNATURE=$(printf '%s' "${TIMESTAMP}.${BODY}" \
| openssl dgst -sha256 -hmac "$SECRET" -binary \
| base64)
curl -X POST https://your-server.com/webhook \
-H "Content-Type: application/json" \
-H "X-Webhook-Id: evt_test123" \
-H "X-Webhook-Timestamp: ${TIMESTAMP}" \
-H "X-Webhook-Signature: v1=${SIGNATURE}" \
--data-binary "$BODY"
# --data-binary preserves the body exactly — the whole point is signing the raw bytes
import base64
import hashlib
import hmac
def verify_signature(timestamp: str, raw_body: str, signature: str, secret: str) -> bool:
payload = f"{timestamp}.{raw_body}".encode()
expected = "v1=" + base64.b64encode(
hmac.new(secret.encode(), payload, hashlib.sha256).digest()
).decode()
# hmac.compare_digest is constant-time and safe even if lengths differ
return hmac.compare_digest(signature, expected)
const crypto = require('crypto');
function verifySignature(secret, timestamp, rawBody, signatureHeader) {
const expected = 'v1=' + crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('base64');
const expectedBuf = Buffer.from(expected);
const actualBuf = Buffer.from(signatureHeader || '');
// timingSafeEqual throws if lengths differ, so check that first
if (expectedBuf.length !== actualBuf.length) return false;
return crypto.timingSafeEqual(expectedBuf, actualBuf);
}
// Usage in a request handler — headers are lowercased by most frameworks
const timestamp = request.headers.get('x-webhook-timestamp');
const signature = request.headers.get('x-webhook-signature');
const rawBody = await request.text(); // read raw text before any JSON.parse
if (!verifySignature(process.env.WEBHOOK_SECRET, timestamp, rawBody, signature)) {
return new Response('Invalid signature', { status: 401 });
}
const payload = JSON.parse(rawBody);
// n8n Code node — verify signature before processing the event
const crypto = require("crypto");
const secret = "YOUR_WEBHOOK_SECRET";
const timestamp = $input.first().json.headers["x-webhook-timestamp"];
const receivedSig = $input.first().json.headers["x-webhook-signature"];
// Note: n8n's Webhook node parses the body for you, so re-serializing it here
// may not byte-for-byte match the original raw body in edge cases (key order,
// whitespace). Prefer a raw-body-capable webhook setup if you hit signature
// mismatches in production.
const body = JSON.stringify($input.first().json.body);
const expectedSig = "v1=" + crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${body}`)
.digest("base64");
const expectedBuf = Buffer.from(expectedSig);
const receivedBuf = Buffer.from(receivedSig || "");
const isValid = expectedBuf.length === receivedBuf.length &&
crypto.timingSafeEqual(expectedBuf, receivedBuf);
if (!isValid) {
throw new Error("Invalid webhook signature — request rejected");
}
return $input.all();
- Do not include
X-Webhook-Idin the signed payload — only the timestamp and raw body are signed. - Do not strip the
whsec_prefix from the secret. - Do not Base64-decode the webhook secret — use it as-is as the HMAC key.
- Do not hash only the JSON body — the signed string is
timestamp + "." + raw_body. - Do not use a hex digest — the signature is Base64-encoded.
- Do not parse and re-serialize the JSON before verification — hash the exact raw bytes received.
- Compare the complete header value, including the
v1=prefix.
Implementation Best Practices
- Acknowledge Immediately: Your server should return a
200 OKresponse within 8 seconds, before doing any real work. - Async Processing: Perform slow operations (like CRM sync) in a background worker after acknowledging the webhook.
- Idempotency: Use the
event_idto ignore duplicate deliveries — retries reuse the sameevent_id. - Security: Always verify signatures using a constant-time comparison, and use HTTPS endpoints only (enforced at registration).
- Retries: A failed delivery (non-2xx response, timeout, or connection error) is retried up to 5 times total with backoff: immediately, then after 30s, 2min, 10min, and 1hr.
- Auto-disable: A webhook endpoint is automatically disabled after 50 consecutive failed delivery attempts (including retries) with no successful delivery in between. Re-enable it from the dashboard once your endpoint is healthy again.