Webhooks
Receive a signed HTTP POST when a call finishes. Use webhooks to update your CRM and store the call's available transcript, recording and analysis.
Manage webhooks → · API reference →
Supported event
call.completed is the only subscription event currently supported. call.started, call.answered, call.ended, call.record, transcript-specific events and batch.completed are not accepted. Unsupported event names return 400.
The backend schedules a post-call snapshot after a default 45-second delay (deployment configuration can change this). Delivery does not wait for a recording or analytics. Those fields can be null, and the event is not resent solely because artifacts arrive later. Retrieve updated details with GET /call/detail/{session_id} if needed.
Create an endpoint
In Developer Hub → Webhooks, select your workspace, click Create Webhook, enter your receiver URL and choose all agents or one agent. Copy the signing secret shown after creation into your receiver's server environment.
To register with the public API:
curl --fail-with-body -X POST "$TUNK_BASE_URL/webhooks" \
-H "X-Api-Key: $TUNK_API_KEY" \
-H "X-Workspace-Id: $TUNK_WORKSPACE_ID" \
-H "Idempotency-Key: $(uuidgen)" \
-H 'Content-Type: application/json' \
-d '{
"url": "https://your-domain.com/webhooks/tunk",
"events": ["call.completed"],
"agent_id": "123e4567-e89b-42d3-a456-426614174000",
"description": "CRM call results"
}'Set the credentials as described in API authentication. Omit agent_id, or set it to null, to subscribe to all agents in that workspace. The create response is 201, with endpoint fields and the one-time data.secret. Its ID is data.webhook_id, not id. List/get responses do not reveal the secret.
Use a public HTTPS URL in production. Private, loopback and reserved addresses are rejected; redirects are not followed. For local development, use a public HTTPS tunnel to your receiver. An agent's legacy webhook_url setting is separate from these registered, signed subscriptions.
Completed event payload
Webhooks are sent directly as this event envelope, without the REST API's outer status/data response wrapper:
{
"id": "c027443a-7995-4d55-8b7c-91bfa8629daf",
"type": "call.completed",
"created_at": "2030-01-15T04:32:45.000Z",
"data": {
"session_id": "d9416526-8334-4a6a-b943-376c37588537",
"agent_id": "123e4567-e89b-42d3-a456-426614174000",
"call_status": "completed",
"to_phone_number": "+14155552671",
"call_type": "outbound",
"duration": 120,
"call_sid": "provider-call-id",
"call_initiated_time": "2030-01-15T04:30:00.000Z",
"call_completed_time": "2030-01-15T04:32:00.000Z",
"call_terminated_by": "agent",
"transcript": null,
"recording": { "url": null, "expiry": null },
"analytics": {
"summary": null,
"sentiment": null,
"disposition": null,
"data_extraction": null
}
}
}| Field | Meaning |
|---|---|
id | Event UUID. Deduplicate by endpoint ID and this event ID |
type | call.completed |
created_at | Time the snapshot was prepared, not necessarily the hangup time |
data.session_id | Call session identifier for retrieving details |
data.agent_id | Agent UUID |
data.call_status | Provider/session status as stored; can be null |
data.duration | Stored call duration; can be null |
data.call_sid | Provider's call identifier; can be null |
data.call_initiated_time, data.call_completed_time | ISO timestamps or null |
data.to_phone_number, data.call_type, data.call_terminated_by | Available session metadata or null |
data.transcript | Stored transcript, or null; handle the available representation |
data.recording.url, data.recording.expiry | Recording location and expiry, or null |
data.analytics | Summary, sentiment, disposition and extraction; individual values can be null |
The event name does not guarantee that the recipient answered. Inspect data.call_status and the call record. Do not require a recording, transcript or analysis to process an event.
Delivery headers
| Header | Value |
|---|---|
Content-Type | application/json |
X-Tunk-Webhook-Id | Registered endpoint UUID |
X-Tunk-Event | call.completed |
X-Tunk-Delivery-Id | Delivery UUID; retained across retries |
X-Tunk-Timestamp | Unix seconds for this attempt |
X-Tunk-Signature | t=<unix-seconds>,v1=<64-character-hex-hmac> |
The API key authenticates requests to Tunk. The signing secret authenticates requests from Tunk to your receiver. They are different credentials. Tunk does not send your API key to the receiver.
Verify signatures
Compute HMAC-SHA256 using the complete secret string, including whsec_, over:
timestamp + "." + exact raw request bodyDo not parse and reserialize the JSON before verification. Validate that the timestamp is within five minutes of your server clock and that X-Tunk-Timestamp matches the signature's t value. Reject malformed signatures before using a constant-time comparison.
Node.js verification function
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyTunkWebhook(rawBody, headers, secret, now = Math.floor(Date.now() / 1000)) {
if (!secret) return false;
const header = headers["x-tunk-signature"] || headers["X-Tunk-Signature"];
if (typeof header !== "string") return false;
const parts = header.split(",").map(part => part.trim());
const timestamps = parts.filter(part => part.startsWith("t="));
if (timestamps.length !== 1) return false;
const timestamp = timestamps[0].slice(2);
if (!/^\d+$/.test(timestamp)) return false;
const seconds = Number(timestamp);
if (!Number.isSafeInteger(seconds) || Math.abs(now - seconds) > 300) return false;
const headerTimestamp = headers["x-tunk-timestamp"] || headers["X-Tunk-Timestamp"];
if (headerTimestamp && headerTimestamp !== timestamp) return false;
const bodyBuf = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, "utf8");
const expected = createHmac("sha256", secret)
.update(Buffer.concat([Buffer.from(timestamp + "."), bodyBuf]))
.digest();
return parts
.filter(part => part.startsWith("v1="))
.some(part => {
const hex = part.slice(3);
return /^[a-fA-F0-9]{64}$/.test(hex) && timingSafeEqual(Buffer.from(hex, "hex"), expected);
});
}Python verification function (FastAPI / Flask)
import hmac
import hashlib
import time
from typing import Dict, Union
def verify_tunk_webhook(
raw_body: Union[bytes, str],
headers: Dict[str, str],
secret: str,
tolerance_seconds: int = 300
) -> bool:
"""Verifies a signed Tunk.ai webhook payload."""
sig_header = headers.get("x-tunk-signature") or headers.get("X-Tunk-Signature")
if not sig_header or not secret:
return False
parts = dict(
part.strip().split("=", 1)
for part in sig_header.split(",")
if "=" in part
)
try:
timestamp = int(parts.get("t", "0"))
v1_sig = parts.get("v1", "")
except ValueError:
return False
if abs(time.time() - timestamp) > tolerance_seconds:
return False
body_bytes = raw_body if isinstance(raw_body, bytes) else raw_body.encode("utf-8")
signed_payload = f"{timestamp}.".encode("utf-8") + body_bytes
expected = hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(v1_sig, expected)Express receiver
Register this route before a global express.json() middleware. Save the verification function above as verify-tunk-webhook.js.
import express from "express";
import { verifyTunkWebhook } from "./verify-tunk-webhook.js";
// Implement a durable queue with a unique (webhookId, eventId) constraint.
import { enqueueOnce } from "./your-durable-event-queue.js";
const app = express();
app.post("/webhooks/tunk", express.raw({ type: "application/json", limit: "1mb" }), async (req, res) => {
if (!verifyTunkWebhook(req.body, req.headers, process.env.TUNK_WEBHOOK_SECRET)) {
return res.status(401).json({ error: "Invalid signature" });
}
try {
const event = JSON.parse(req.body.toString("utf8"));
if (event.type !== "call.completed" || typeof event.id !== "string") {
return res.status(400).json({ error: "Invalid event" });
}
await enqueueOnce(req.headers["x-tunk-webhook-id"], event.id, event);
return res.sendStatus(204);
} catch {
return res.sendStatus(500);
}
});
app.listen(4242);enqueueOnce is your application's queue adapter, not a Tunk SDK function. Persist the event before returning success, then process it in a worker. Handle duplicate events as success. Match the raw body limit to your expected transcript sizes.
Test the receiver
Click Send test event in an endpoint's details, or send:
curl --fail-with-body -X POST "$TUNK_BASE_URL/webhooks/<webhook-uuid>/test" \
-H "X-Api-Key: $TUNK_API_KEY" \
-H "X-Workspace-Id: $TUNK_WORKSPACE_ID" \
-H "Idempotency-Key: $(uuidgen)"This sends a signed sample immediately and does not place a call. Inspect data.status (success or failed), data.http_status and data.delivery_id. A 200 response from the test API alone does not prove your receiver accepted the payload. The receiver result is in the body. Manual tests are a single delivery attempt, without automatic queue retries.
For an end-to-end check, enable the endpoint, make an approved call through the API, let it end and wait for post-call processing. In your receiver logs, confirm the event ID, signature verification, 2xx response and saved session ID. A request visible in webhook.site confirms delivery but does not verify the signature unless you implement verification there.
Retries and acknowledgement
Queued call-event deliveries allow three total attempts (the initial attempt and two retries), with exponential backoff starting at 5 seconds (normally 5 then 10 seconds between failures, plus queue delay). Network failures, timeouts and all non-2xx responses, including 4xx and redirects, count as failures. Each attempt has a 10-second timeout that includes DNS and HTTP.
Respond quickly with a 2xx status after durable acceptance. Retries reuse the event and delivery IDs and the saved payload, but have a fresh timestamp/signature. Use deduplication; do not rely on delivery order. An inactive/deleted endpoint or changed scope can prevent a queued delivery from being sent.
Rotate or disable
Use Rotate secret in details, or POST /webhooks/{id}/rotate-secret with a fresh idempotency key. Store the returned data.secret immediately and update the receiver. Rotation replaces the previous secret; there is no overlap period. List/get cannot recover the current secret.
To disable delivery, PATCH /webhooks/{id} with {"status":"disabled"}. Re-enable with {"status":"active"}. Delete with DELETE /webhooks/{id} when no longer needed.
Troubleshooting
| Symptom | What to check |
|---|---|
| Create returns 400 | Use only call.completed, a valid agent UUID and a public receiver URL |
| Receiver sees nothing | Correct workspace/agent scope, active endpoint, post-call trigger and deployed queue worker |
| Signature verification fails | Raw bytes, correct secret, synchronized clock, matching timestamp and no middleware body transformation |
| Test API returns 200 but delivery failed | Read data.http_status; check receiver routing, TLS, timeout and redirects |
| Missing recording/analytics | Expected for an early snapshot; fetch updated call detail if needed |
| Repeated event | Deduplicate by endpoint and event ID, then acknowledge with 2xx |
| First test works, next does not | Use a new Idempotency-Key for a new test; inspect receiver and worker logs |