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
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,
"agent_id": "agent_alpha",
"agent_name": "Priya",
"call_recording": "https://studio.huskyvoice.ai/spark/calls/call_987654321",
"call_analytics": {
"summary": "Customer confirmed appointment.",
"sentiment": "positive",
"disposition": "appointment_booked"
},
"call_transcript": [ { "speaker": "agent", "text": "Hi, this is Priya...", "timestamp": 0.5 } ],
"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
data = request.get_json()
event_type = data.get('event_type')
event_data = data.get('data', {})
if event_type == 'call.completed':
analytics = event_data.get('call_analytics') or {}
print(f"Call {event_data['call_id']} completed. Duration: {event_data['call_duration_seconds']}s")
print(f"Summary: {analytics.get('summary')}")
if event_type == 'call.failed':
print(f"Call {event_data['call_id']} failed: {event_data.get('error')}")
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', (req, res) => {
// Always acknowledge immediately
res.status(200).send('OK');
// Process the event asynchronously
const { event_type, data } = req.body;
if (event_type === 'call.completed') {
console.log(`Call ${data.call_id} completed. Duration: ${data.call_duration_seconds}s`);
console.log(`Summary: ${data.call_analytics?.summary}`);
}
if (event_type === 'call.failed') {
console.log(`Call ${data.call_id} failed: ${data.error}`);
}
});
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 shares a consistent envelope structure. The data object contains the event-specific details and varies slightly by event type — not every field is populated for every event.
{
"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_duration_seconds": 124,
"agent_id": "agent_alpha",
"agent_name": "Priya",
"call_recording": "https://studio.huskyvoice.ai/spark/calls/call_987654321",
"call_analytics": {
"summary": "Customer confirmed appointment.",
"sentiment": "positive",
"next_steps": "No further action needed.",
"disposition": "appointment_booked",
"extracted_details": { "...": "..." },
"appointment_details": { "...": "..." }
},
"call_transcript": [
{ "speaker": "agent", "text": "Hi, this is Priya from...", "timestamp": 11.6 }
],
"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": null
}
}
Envelope fields
| Field | Type | Description |
|---|---|---|
event_id | string | Unique ID for this event delivery, prefixed evt_. Use this for idempotency — see below. |
event_type | string | One of the event types above. |
created_at | string (ISO 8601) | When this event was generated. |
data | object | Event-specific payload — see below. |
data fields
| Field | Type | Description |
|---|---|---|
call_id | string | null | Unique identifier for the call. |
status | string | completed, initiated, disallowed, or failed. |
call_direction | string | inbound or outbound. |
call_to | string | Phone number dialed. Present only when call_direction is outbound. |
call_from | string | Caller's phone number. Present only when call_direction is inbound. |
call_duration_seconds | number | null | Total call duration in seconds. |
agent_id | string | null | ID of the agent that handled the call. |
agent_name | string | null | Display name of the agent. |
call_recording | string | null | URL to view/play the call recording. |
call_analytics | object | null | AI-generated call analysis. Commonly includes summary, sentiment, next_steps, disposition, and extracted_details — the exact set of fields is configurable per organization, so don't assume a fixed schema. Also includes appointment_details when an appointment was booked during the call. Populated on call.completed only. |
call_transcript | array | null | Ordered list of { speaker, text, timestamp } turns. Populated on call.completed only. |
call_triggered_at | string (ISO 8601) | null | When the call was actually placed. |
scheduled_at | string (ISO 8601) | null | When the call was scheduled for, if placed via the scheduling API. |
completed_at | string (ISO 8601) | null | When the call ended. |
error | string | null | Error/reason message, populated on call.failed and call.disallowed; null otherwise. |
contact_name | string | null | Name of the contact, as submitted at call setup or captured by the agent during the call. |
contact_email | string | null | Email of the contact, as submitted at call setup or captured by the agent during the call. |
custom_data | object | null | Any custom metadata you attached when triggering the call. |
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? | Description |
|---|---|---|
Content-Type | Yes | Always application/json. |
User-Agent | Yes | Always AppEQ-Webhooks/1.0. |
X-Webhook-Id | Yes | The event_id of this delivery. |
X-Webhook-Timestamp | Only if signing is enabled | 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 | v1=<signature> — HMAC-SHA256 of "{timestamp}.{raw_body}", base64-encoded, using your webhook's secret. |
Your webhook secret is shown once at creation time, in the format whsec_.... 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.
- cURL
- Python
- Node.js
- n8n
# Compute the expected signature to compare with X-Webhook-Signature
SECRET="whsec_your_webhook_secret"
TIMESTAMP="1748000000" # value of the X-Webhook-Timestamp header
BODY='{"event_id":"evt_123",...}' # exact raw request body, unmodified
echo -n "${TIMESTAMP}.${BODY}" | openssl dgst -sha256 -hmac "$SECRET" -binary | base64
# Compare the output (prefixed with "v1=") against the X-Webhook-Signature header value
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);
}
// 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();
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.