API reference
Webhooks
Receive real-time HTTP notifications when calls and messages change state, with HMAC-signed payloads and durable delivery.
A webhook endpoint is a URL on your server that JagCall sends event notifications to. When something happens — a call completes, an SMS arrives — we POST a signed JSON payload to every endpoint subscribed to that event. Delivery is durable: failed attempts are retried with exponential backoff.
Creating an endpoint
Add endpoints from the dashboard under Tools → Webhooks, or via the API. Each organization can have up to 10 endpoints. Endpoint URLs must be publicly reachable — https:// is strongly recommended, and private, loopback, and reserved IPs are rejected. Omit events to subscribe to every event, or pass an array to filter.
/v1/webhook-endpointsCreate an endpoint. The response includes the signing secret exactly once — store it securely.
Request body
{ "url": "https://your-app.com/webhooks/jagcall", "events": ["call.completed", "sms.received"] }Response
{ "id": "3f2a1c9e-8b4d-4e21-9f77-0a1b2c3d4e5f", "url": "https://your-app.com/webhooks/jagcall", "description": null, "events": ["call.completed", "sms.received"], "enabled": true, "has_secret": true, "consecutive_failures": 0, "previous_secret_expires_at": null, "secret": "whsec_...", "created_at": "2025-11-01T00:00:00Z", "updated_at": "2025-11-01T00:00:00Z" }Example
curl -X POST https://jagcall.com/v1/webhook-endpoints \ -H "Authorization: Bearer jc_live_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.com/webhooks/jagcall", "events": ["call.completed", "sms.received"] }'One-time secret
whsec_…) is returned only when you create the endpoint or rotate its secret. Reveal it later from the dashboard, or rotate to generate a new one.Event types
Subscribe to any of these events:
| Event | Fires when |
|---|---|
call.started | A call has connected and started. |
call.ringing | An outbound call is ringing the destination. |
call.completed | A call ended normally. |
call.failed | A call failed to connect or errored. |
call.takeover | A human agent took over a live call. |
call.transcript.ready | The transcript for a completed call is available. |
call.summary.ready | The AI summary for a completed call has been written — call_summary, user_sentiment and call_successful are filled. It can arrive after call.transcript.ready; on the other call events these fields are usually still null. |
sms.received | An inbound SMS was received. |
sms.sent | An outbound SMS was sent. |
sms.delivered | The carrier confirmed an outbound SMS was delivered. |
sms.failed | An outbound SMS could not be delivered. error_code, error_message and failure_reason say why; failure_reason is set when the carrier filtered the message (for example an unregistered A2P sender). |
sms.opt_out | A contact opted out of messages (e.g. replied STOP). |
sms.opt_in | A contact opted back in (e.g. replied START). |
voicemail.left | A caller left a voicemail. |
contact.created | A contact was created (dashboard, API, MCP, phone import, or filled in from a call). |
contact.updated | A contact changed. changed_fields lists what changed. |
contact.deleted | A contact was deleted. Its notes and tasks are kept for 30 days and come back if a contact with the same phone is created again. |
simulation.completed | An agent test simulation finished. |
Payload format
Every delivery is a POST with a JSON body shaped as { event, data, timestamp }. The timestamp is an ISO-8601 UTC time; data depends on the event. All seven call.* events share the same call shape:
{ "event": "call.completed", "data": { "call_id": "call_a1b2c3", "organization_id": "org_m4n5o6", "agent_id": "agt_x1y2z3", "direction": "outbound", "status": "completed", "from_number": "+14155551234", "to_number": "+14155559876", "duration_seconds": 142, "cost_total": 0.0231, "error_message": null, "caller_name": "Dana Rivers", "call_summary": null, "user_sentiment": null, "call_successful": null, "phone_number_id": "pn_d4e5f6", "answered_by_identity": null, "routing": "none", "answered_at": null, "parameters": null, "started_at": "2025-11-01T14:30:00+00:00", "ended_at": "2025-11-01T14:32:22+00:00", "created_at": "2025-11-01T14:29:58+00:00" }, "timestamp": "2025-11-01T14:32:22.123456+00:00"}sms.received, sms.sent, sms.delivered and sms.failed use a message shape:
{ "event": "sms.received", "data": { "message_id": "sms_a1b2c3", "conversation_id": "conv_x1y2z3", "direction": "inbound", "from_number": "+14155559876", "to_number": "+14155551234", "body": "Yes, tomorrow at 2pm works!", "status": "received", "contact_name": "Jane Doe", "organization_id": "org_m4n5o6", "segments": 1, "error_code": null, "error_message": null, "failure_reason": null, "created_at": "2025-11-01T14:35:00+00:00" }, "timestamp": "2025-11-01T14:35:00.500000+00:00"}sms.opt_out and sms.opt_in describe the contact's consent record. Both dates are included on every event, and the opt-out applies to the whole billing account, so the event is also delivered to the parent account's endpoints:
{ "event": "sms.opt_out", "data": { "contact_number": "+14155559876", "billing_account_id": "org_m4n5o6", "organization_id": "org_m4n5o6", "origin_number": "+14155551234", "keyword": "STOP", "source": "inbound_sms", "opted_out_at": "2025-11-01T14:40:00+00:00", "opted_in_at": null, "is_opted_out": true, "scope": "billing_account" }, "timestamp": "2025-11-01T14:40:00.250000+00:00"}voicemail.left carries the voicemail:
{ "event": "voicemail.left", "data": { "voicemail_id": "vm_a1b2c3", "call_id": "call_a1b2c3", "organization_id": "org_m4n5o6", "phone_number_id": "pn_d4e5f6", "from_number": "+14155559876", "to_number": "+14155551234", "duration_seconds": 18, "transcript": "Hi, it's Dana. Please call me back.", "transcription_status": "completed", "is_read": false, "created_at": "2025-11-01T14:45:00+00:00", "caller_number": "+14155559876", "duration": "0:18", "voicemail_link": "https://jagcall.com/voicemails", "voicemail_text": "Hi, it's Dana. Please call me back." }, "timestamp": "2025-11-01T14:45:00.100000+00:00"}Call events also say how an inbound call was routed: phone_number_id, routing (partner_clients, app_clients, ai_agent, voicemail, forward or none) and, for partner-routed numbers, answered_by_identity, answered_at and the parameters your routing URL returned.
contact.* events carry the full contact with UTC timestamps. changed_by says who wrote it: {"type": "user", "user_id"}, {"type": "api_client", "client_id"} (an OAuth app; api_key_id for an API key) or {"type": "system"}. If you sync contacts both ways, ignore events whose changed_by.client_id is your own:
{ "event": "contact.updated", "data": { "id": "c_a1b2c3", "phone_number": "+14155559876", "name": "Dana Rivers", "email": "dana@example.com", "company": "Rivers Plumbing", "role": "Owner", "address": null, "notes": null, "tags": "vip", "is_favorite": false, "created_at": "2025-11-01T14:29:58Z", "updated_at": "2025-11-02T09:12:40Z", "organization_id": "org_m4n5o6", "changed_by": { "type": "user", "user_id": "usr_q7r8s9" }, "changed_fields": ["email"] }, "timestamp": "2025-11-02T09:12:40.512000+00:00"}Delivery id is in the headers, not the body
X-Webhook-Delivery-Id header (see below), not inside the JSON body.Request headers
| Header | Description |
|---|---|
Content-Type | application/json |
X-Webhook-Event | The event type, e.g. call.completed. |
X-Webhook-Timestamp | Unix timestamp (seconds) when the request was signed. |
X-Webhook-Signature | t=<ts>,v1=<hmac> — one or more v1 values (see rotation). |
X-Webhook-Delivery-Id | Stable UUID for this delivery. Identical across retries — deduplicate on it. |
Signature verification
Each signed request carries an X-Webhook-Signature header of the form t=<timestamp>,v1=<signature>. The signature is the hex HMAC-SHA256 of the string <timestamp>.<raw request body>, keyed with your endpoint's signing secret (the full whsec_… value). Verify against the raw request body bytes exactly as received — do not re-serialize the JSON.
import hashlibimport hmacimport timedef verify(secret: str, headers: dict, raw_body: bytes) -> bool: sig_header = headers["X-Webhook-Signature"] # "t=1730470000,v1=abc...,v1=def..." items = [p.split("=", 1) for p in sig_header.split(",")] timestamp = next(v for k, v in items if k == "t") # Reject stale timestamps to prevent replay (5 minute tolerance). if abs(time.time() - int(timestamp)) > 300: return False signed = f"{timestamp}.".encode() + raw_body # verify against the RAW body bytes expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() # A delivery may carry multiple v1 signatures during a secret rotation. provided = [v for k, v in items if k == "v1"] return any(hmac.compare_digest(expected, v) for v in provided)const crypto = require('crypto')function verify(secret, headers, rawBody /* Buffer */) { const sigHeader = headers['x-webhook-signature'] // "t=...,v1=...,v1=..." const items = sigHeader.split(',').map((p) => p.split('=')) const timestamp = items.find(([k]) => k === 't')?.[1] // Reject stale timestamps to prevent replay (5 minute tolerance). if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false const expected = crypto .createHmac('sha256', secret) .update(timestamp + '.') .update(rawBody) // verify against the RAW body bytes .digest('hex') // A delivery may carry multiple v1 signatures during a secret rotation. const provided = items.filter(([k]) => k === 'v1').map(([, v]) => v) return provided.some( (v) => v.length === expected.length && crypto.timingSafeEqual(Buffer.from(v), Buffer.from(expected)) )}<?phpfunction verify(string $secret, array $headers, string $rawBody): bool { $sigHeader = $headers['X-Webhook-Signature']; // "t=...,v1=...,v1=..." $parts = []; foreach (explode(',', $sigHeader) as $piece) { [$k, $v] = explode('=', $piece, 2); $parts[$k][] = $v; } $timestamp = $parts['t'][0] ?? ''; // Reject stale timestamps to prevent replay (5 minute tolerance). if (abs(time() - (int) $timestamp) > 300) return false; $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); // A delivery may carry multiple v1 signatures during a secret rotation. foreach ($parts['v1'] ?? [] as $sig) { if (hash_equals($expected, $sig)) return true; } return false;}Verify safely
X-Webhook-Timestamp is more than a few minutes old to prevent replay attacks, and (3) accept the request if any v1 value matches — a delivery is dual-signed during a secret rotation.Retries & delivery
Delivery is at-least-once. Your endpoint should return a 2xx status within 10 seconds. Any non-2xx response, timeout, or network error is retried with exponential backoff — up to 6 attempts total:
| Attempt | Sent |
|---|---|
| 1 | Immediately |
| 2 | 30 seconds after attempt 1 fails |
| 3 | 2 minutes later |
| 4 | 10 minutes later |
| 5 | 1 hour later |
| 6 | 4 hours later |
After the 6th attempt fails, the delivery is marked failed. Responding 410 Gone stops retries for that delivery immediately. An endpoint that accumulates 20 consecutive failures is automatically disabled; any successful delivery resets the counter.
Handle duplicates
X-Webhook-Delivery-Id header, which is stable across all retries of a delivery.Secret rotation
Rotating an endpoint's secret generates a new whsec_… value and returns it once. For a 24-hour grace window, deliveries are signed with both the new and previous secrets (two v1= values in the signature header), so you can roll the stored secret on your side at any point without missing a verification. After the window, only the new secret signs.
/v1/webhook-endpoints/{endpoint_id}/rotate-secretRotate the signing secret. Returns the new secret and the previous secret's expiry.
Response
{ "secret": "whsec_...", "previous_secret_expires_at": "2025-11-02T00:00:00Z" }Example
curl -X POST "https://jagcall.com/v1/webhook-endpoints/3f2a1c9e-8b4d-4e21-9f77-0a1b2c3d4e5f/rotate-secret?grace=true" \ -H "Authorization: Bearer jc_live_..."Replaying deliveries
Inspect and replay past deliveries from the dashboard, or via the API. A replay creates a new delivery (with its own X-Webhook-Delivery-Id) that references the original.
/v1/webhook-deliveries/{delivery_id}/replayRe-enqueue a past delivery to the same endpoint.
Example
curl -X POST https://jagcall.com/v1/webhook-deliveries/9b8c7d6e-5a4b-3c2d-1e0f-a1b2c3d4e5f6/replay \ -H "Authorization: Bearer jc_live_..."Testing
Use the Test button on any endpoint in the dashboard to send a sample call.completed event and see the response. For local development, expose your server with a tunnel such as ngrok and point an endpoint at the public URL. See the full API reference for all webhook management endpoints.
Still need help?
Can't find what you're looking for? Send us a message and our team will get back to you.