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", "events": ["call.completed", "sms.received"], "enabled": true, "has_secret": true, "secret": "whsec_...", "created_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. |
sms.received | An inbound SMS was received. |
sms.sent | An outbound SMS was sent. |
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 six call.* events share the same call shape:
{ "event": "call.completed", "data": { "call_id": "call_a1b2c3", "agent_id": "agt_x1y2z3", "direction": "outbound", "status": "completed", "from_number": "+14155551234", "to_number": "+14155559876", "duration_seconds": 142, "cost_total": 0.0231, "error_message": 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 events 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", "created_at": "2025-11-01T14:35:00+00:00" }, "timestamp": "2025-11-01T14:35:00.500000+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.