Skip to main content

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:

  1. Create a public HTTPS endpoint on your server to receive POST requests
  2. Register your URL — Go to Dashboard → Workflows → Webhooks → Add Webhook (1 endpoint on the Base plan, up to 5 on Professional and Enterprise plans)
  3. Select the events you want to listen to (e.g., call.completed, call.failed)
  4. Return 200 OK immediately 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
  5. Process the event asynchronously after acknowledging

Example — Webhook listener:

# 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
}
}'
Testing Locally

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​

EventDescription
call.initiatedFired immediately when the telephony system successfully triggers the call, before it's answered or completed.
call.completedFired when the call ends successfully. Includes full call analytics and transcript.
call.failedFired when the call could not be completed due to a technical failure (busy, no answer, carrier/infrastructure error).
call.disallowedFired 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.
note

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
}
}
Field names below reflect the live payload (updated 2026-10-09)

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:

FieldTypeRequiredDescription
event_idstringYesUnique event delivery ID, prefixed evt_. Use for idempotency tracking.
event_typestringYesThe type of event: call.initiated, call.completed, call.failed, or call.disallowed.
created_atstring (ISO 8601)YesTimestamp when the event was generated (e.g., 2026-06-01T10:00:00.000Z).
dataobjectYesEvent-specific payload containing call details and metadata.

Call Metadata Fields​

Core information about the call itself:

FieldTypeRequiredDescription
call_idstringYesUnique identifier for the call.
statusstringYesCall status: completed, initiated, failed, or disallowed.
call_directionstringYesinbound (incoming call) or outbound (placed call).
call_tostring | nullYesDialed phone number. Populated for outbound calls; null for inbound calls.
call_fromstring | nullYesCaller's phone number. Populated for inbound calls; null for outbound calls.
call_duration_secondsnumberYesTotal duration of the call in seconds.
credits_consumednumber | nullNoNumber 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_idstring | nullNoID of the agent that handled or was assigned to the call.
agent_namestring | nullNoDisplay name of the agent.
errorstring | nullConditionalError message. Only populated on call.failed and call.disallowed events.
scheduled_call_idstring | nullNocall_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:

FieldTypeRequiredDescription
call_triggered_atstring (ISO 8601) | nullNoWhen the call was actually placed to the recipient.
scheduled_atstring (ISO 8601) | nullNoWhen the call was scheduled for (if placed via the scheduling API).
completed_atstring (ISO 8601) | nullNoWhen the call ended.

Contact Information​

Caller or contact details:

FieldTypeRequiredDescription
contact_namestring | nullNoName of the contact, from call setup or captured during the call.
contact_emailstring | nullNoEmail of the contact, from call setup or captured during the call.
contact_preferred_languagestring | nullNoThe 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_dataobject | nullNoCustom 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:

FieldTypeRequiredDescription
call_recordingstring | nullNoTemporary 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_urlstring | nullNoA 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_tokenstring | nullNoThe 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_transcriptstring | array | nullNoNormally 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_analyticsobject | nullNoAI-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 }
FieldTypeDescription
speakerstringWho said it — typically agent or user (raw diarization labels like speaker_0 are possible if speaker mapping wasn't resolved).
textstringThe utterance text.
timestampnumberSeconds from the start of the call.

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_url renders 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_token is the same token that's embedded in call_details_url's query string, sent again as its own field — use it directly if you'd rather call GET /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, and call_transcript.
  • If Public call share is left off (the default), call_details_url and share_token are always null, and call_recording / call_transcript are 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"
}
FieldTypeRequiredDescription
summarystringYes2–3 sentence summary of the call outcome and key points discussed.
sentimentstringYesOverall call sentiment: positive, neutral, or negative.
next_stepsstringYesRecommended next action (e.g., "Schedule follow-up call", "Send proposal", "No action needed").
dispositionstringYesCall 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.

FieldTypeRequiredDescription
extracted_detailsobject | nullNoKey-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.
About extracted_details

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.

FieldTypeRequiredDescription
appointment_detailsobject | nullConditionalnull 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:

HeaderAlways sent?Used in signature?Description
Content-TypeYesNoAlways application/json.
User-AgentYesNoAlways AppEQ-Webhooks/1.0.
X-Webhook-IdYesNoThe event_id of this delivery. Not part of the signed payload — see below.
X-Webhook-TimestampOnly if signing is enabledYesUnix timestamp (seconds) the request was sent. Reject requests where this is too far in the past to guard against replay attacks.
X-Webhook-SignatureOnly if signing is enabledOutputv1=<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-Id is not included in the signature input. Its presence alongside a timestamp naturally suggests an id.timestamp.body scheme — that is not what HuskyVoice uses. Only the timestamp and raw body are signed.
  • The digest is Base64-encoded, not hex.
  • raw_request_body must 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'))
# 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
Common signature verification mistakes
  • Do not include X-Webhook-Id in 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​

  1. Acknowledge Immediately: Your server should return a 200 OK response within 8 seconds, before doing any real work.
  2. Async Processing: Perform slow operations (like CRM sync) in a background worker after acknowledging the webhook.
  3. Idempotency: Use the event_id to ignore duplicate deliveries — retries reuse the same event_id.
  4. Security: Always verify signatures using a constant-time comparison, and use HTTPS endpoints only (enforced at registration).
  5. 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.
  6. 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.