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_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
}
}'
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_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"
}
}
}
Field names below reflect the live payload (updated 2026-08-13)

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:

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 | nullConditionalDialed phone number. Only present when call_direction is outbound.
call_fromstring | nullConditionalCaller's phone number. Only present when call_direction is inbound.
call_duration_secondsnumberYesTotal duration of the call in seconds.
credits_consumednumber | nullNoNumber of credits deducted for this call. Only populated on call.completednull 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.

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.
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.
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). 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_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"
}
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, 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.

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.