DocsDeveloper HubWebhooks & Events
Receive signed call.completed events and verify webhook deliveries

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:

Shell
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:

JSON
{
  "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
    }
  }
}
FieldMeaning
idEvent UUID. Deduplicate by endpoint ID and this event ID
typecall.completed
created_atTime the snapshot was prepared, not necessarily the hangup time
data.session_idCall session identifier for retrieving details
data.agent_idAgent UUID
data.call_statusProvider/session status as stored; can be null
data.durationStored call duration; can be null
data.call_sidProvider's call identifier; can be null
data.call_initiated_time, data.call_completed_timeISO timestamps or null
data.to_phone_number, data.call_type, data.call_terminated_byAvailable session metadata or null
data.transcriptStored transcript, or null; handle the available representation
data.recording.url, data.recording.expiryRecording location and expiry, or null
data.analyticsSummary, 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

HeaderValue
Content-Typeapplication/json
X-Tunk-Webhook-IdRegistered endpoint UUID
X-Tunk-Eventcall.completed
X-Tunk-Delivery-IdDelivery UUID; retained across retries
X-Tunk-TimestampUnix seconds for this attempt
X-Tunk-Signaturet=<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:

Plain text
timestamp + "." + exact raw request body

Do 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

JavaScript
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)

python
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.

JavaScript
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:

Shell
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

SymptomWhat to check
Create returns 400Use only call.completed, a valid agent UUID and a public receiver URL
Receiver sees nothingCorrect workspace/agent scope, active endpoint, post-call trigger and deployed queue worker
Signature verification failsRaw bytes, correct secret, synchronized clock, matching timestamp and no middleware body transformation
Test API returns 200 but delivery failedRead data.http_status; check receiver routing, TLS, timeout and redirects
Missing recording/analyticsExpected for an early snapshot; fetch updated call detail if needed
Repeated eventDeduplicate by endpoint and event ID, then acknowledge with 2xx
First test works, next does notUse a new Idempotency-Key for a new test; inspect receiver and worker logs
Need help? Reach out on our support portal.