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_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_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",
"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_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",
"custom_data": {
"campaign_id": "camp_123",
"lead_id": "lead_456"
}
}
}
call_recording, call_transcript, credits_consumed, custom_data, and the top-level extracted_details / appointment_details fields are the exact field names and structure the outbound webhook dispatcher sends today. call_recording and call_transcript are temporary signed S3 URLs, valid for 5 minutes from when a delivery attempt was sent — not permanent links (see Media & Analysis 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.
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 | Conditional | Dialed phone number. Only present when call_direction is outbound. |
call_from | string | null | Conditional | Caller's phone number. Only present when call_direction is inbound. |
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. |
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. |
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. |
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). 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. |
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|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, ended_by_customer, or session_ended. |
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.