Webhooks
Receive HTTP POST notifications when events happen in Staffify. Use webhooks to sync call data to your CRM, trigger post-call automations, and monitor agent performance in real-time.
Three levels of webhooks
Org webhooks
POST /v1/webhooksEvents across your entire org
Project webhook
PATCH /v1/project/webhookEvents for one project
Agent webhook
agent.webhook_url fieldEvents for one agent's calls only
All event types
Call Lifecycle
Real-time Speech
Tools
Transfers
SMS
Knowledge Base
Address Verification
Use "*" to subscribe to all events at once.
Event payload structure
{
"id": "whe_a1b2c3d4-e5f6-...",
"event": "call.ended",
"timestamp": "2026-01-15T10:02:07Z",
"api_version": "2026-05-20",
"org_id": 42,
"project_id": 1,
"agent_id": "agent_28c51f81",
"data": {
"call_id": "call_00000001",
"from_number": "+31612345678",
"to_number": "+31850012345",
"status": "completed",
"duration_seconds": 127,
"total_cost": 0.4664
}
}Event payload reference
Every event uses the same envelope structure. The data object contains event-specific fields listed below. Fields annotated | null may be null depending on call state.
Call Lifecycle
call.ringingcall.starteddata: { call_id, agent_id, from, to }call.endeddata: {
call_id, agent_id, from, to,
started_at, ended_at, // ISO-8601 | null
duration_seconds, // integer
minutes_used, // float (billable)
call_cost, recording_cost,
transfer_cost, llm_discount,
total_cost, rate_per_minute, // EUR floats
disposition, ended_reason, // string | null
ws_unavailable, // boolean
transferred_to, // E.164 | null
metadata, // object | null
language, // BCP-47 | null
turn_count, tool_call_count, // integers
tools_called, // string[]
extracted_data, // object | null
transcript // [{ role, content }]
}call.faileddata: { call_id, agent_id, from | null, to | null, ended_reason }call.recording_readydata: { call_id, agent_id, recording_url, recording_expires_at }recording_url is a pre-signed S3 URL valid for 24 hours.
call.dtmf.receiveddata: { call_id, agent_id, digit, action }digit: single character (0-9, *, #). action: context string (e.g. "gather").
call.gather.completeddata: { call_id, agent_id, status, input }status: "completed" | "timeout". input: digits string | null on timeout.
Real-time Speech
transcript.updateddata: { call_id, agent_id, from | null, turn: { role, content }, turn_index }role: "user" | "assistant". turn_index is 0-based. Fire-and-forget.
speech.startedspeech.endeddata: { call_id, agent_id, from | null, timestamp }speech.ended also includes transcript (transcribed text | null).
ai.speech.startedai.speech.endeddata: { call_id, agent_id, text | null, timestamp }ai.speech.ended also includes duration_ms (integer ms | null).
barge_indata: { call_id, agent_id, from | null, user_text | null, interrupted_text | null, timestamp }Tools
tool.invokeddata: { call_id, agent_id, tool_name, tool_call_id, arguments }tool.completeddata: { call_id, agent_id, tool_name, tool_call_id, result }tool.timeoutdata: { call_id, agent_id, tool_name, tool_call_id, timeout_seconds }Transfers
transfer.initiateddata: { call_id, agent_id, from | null, transfer_to, reason | null }transfer.completeddata: { call_id, agent_id, from | null, transfer_to | null }transfer.faileddata: { call_id, agent_id, from | null, transfer_to | null, reason | null, failure_reason }failure_reason: e.g. "Destination busy", "Unknown error".
SMS
sms.sentdata: { call_id, agent_id, from | null, to, from_number | null, sender_name | null, message, sender_type, telnyx_message_id | null, cost_eur | null, status }status: "sent" | "failed".
Knowledge Base
knowledge_base.refresheddata: { kb_id, entries_added, entries_removed, entries_truncated }knowledge_base.refresh_faileddata: { kb_id, error }error: e.g. "HTTP 404".
knowledge_base.auto_refresh_disableddata: { kb_id, reason, count }reason: "consecutive_failures". count: number of failures that triggered the disable.
Address Verification
address.verification.pendingaddress.verification.verifiedaddress.verification.partially_verifiedaddress.verification.rejectedaddress.verification.expireddata: { address_id, status }verified, partially_verified, rejected, and expired also include number_types: [{ type, status, rejection_reason }].
webhook_url receives all call, speech, tool, transfer, and SMS events. Knowledge base and address verification events are only delivered to org-level and project-level webhooks.The webhook object
{
"id": 42,
"url": "https://your-server.com/webhooks",
"events": ["call.started", "call.ended"],
"filters": { "agent_id": 123 },
"is_active": true,
"agent_id": null,
"project_id": null,
"is_agent_managed": false,
"is_project_managed": false,
"failure_count": 0,
"consecutive_failures": 0,
"disabled_reason": null,
"last_failure_at": null,
"api_version": "2026-05-20",
"created_at": "2026-01-15T10:00:00Z",
"updated_at": "2026-01-15T10:00:00Z"
}The delivery object
Returned by GET /v1/webhooks/:id/deliveries and the DLQ endpoints.
{
"id": 1001,
"event": "call.ended",
"event_id": "whe_a1b2c3d4-e5f6-...",
"url": "https://your-server.com/webhooks",
"attempt": 1,
"status_code": 200,
"success": true,
"error": null,
"is_dlq": false,
"created_at": "2026-01-15T10:02:07Z"
}Request headers sent with every delivery
| Header | Value |
|---|---|
| Content-Type | application/json |
| X-Webhook-Event | call.ended (the event type) |
| X-Webhook-Timestamp | Unix timestamp (seconds) |
| X-Webhook-Signature | sha256=<HMAC-SHA256 hex> |
| X-Staffify-Api-Version | 2026-05-20 |
| User-Agent | Staffify-API/1.0 |
| X-Staffify-Test-Event | 1 — only on test deliveries |
| X-Staffify-Replay | 1 — only on replayed deliveries |
Signature verification
Every delivery includes X-Webhook-Signature: sha256=<HMAC_SHA256_HEX> and X-Webhook-Timestamp. The HMAC is computed over timestamp + "." + rawBody using your webhook secret.
Node.js / Express
const crypto = require('crypto');
app.post('/webhooks', express.raw({ type: '*/*' }), (req, res) => {
const sig = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
const secret = process.env.STAFFIFY_WEBHOOK_SECRET;
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(timestamp + '.' + req.body) // timestamp.rawBody, NOT just body
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
processEventAsync(event).catch(console.error); // process async
res.sendStatus(200); // respond immediately
});Python / Flask
import hmac, hashlib
@app.route('/webhooks', methods=['POST'])
def handle_webhook():
sig = request.headers.get('X-Webhook-Signature', '')
timestamp = request.headers.get('X-Webhook-Timestamp', '')
body = request.get_data()
expected = 'sha256=' + hmac.new(
SECRET.encode(), (timestamp + '.').encode() + body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(sig, expected):
return 'Invalid signature', 401
event = request.json
return '', 200Delivery and retries
Attempt 1Immediate
Attempt 2~30 seconds
Attempt 3~5 minutes
Attempt 4~30 minutes
Auto-disableAfter 10 consecutive failures -- re-enable with PATCH is_active=true
Each attempt has a 10-second HTTP timeout. All-failed deliveries go to the dead-letter queue (DLQ). Retrieve them with GET /v1/webhooks/:id/dlq and replay with POST /v1/webhooks/:id/dlq/resend-all. The resend-all operation processes up to 200 items at a time.
Important: Always respond 200 within 5 seconds. Process events asynchronously.
/v1/webhookscurl -X POST https://api.staffifyai.com/v1/webhooks \
-H "Authorization: Bearer sfy_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-server.com/webhooks",
"events": ["call.started", "call.ended", "call.recording_ready"]
}'Valid filter keys: agent_id (numeric) and project_id (numeric).
All webhook endpoints
/v1/webhooksCreate a webhook (returns secret once)
/v1/webhooksList all webhooks. Hard-capped at 100 results. No cursor pagination — if you have more than 100 webhooks, use GET /v1/webhooks/:id to fetch individual ones by ID.
/v1/webhooks/:webhookIdGet single webhook
/v1/webhooks/:webhookIdUpdate: url, events, filters, is_active
/v1/webhooks/:webhookIdDelete webhook
/v1/webhooks/:webhookId/rotateIssue new secret (old immediately invalid)
/v1/webhooks/:webhookId/testSend a test payload. Request body: { event_type: 'call.ended' } (any valid event type, wildcards not accepted). Test deliveries include X-Staffify-Test-Event: 1 header.
/v1/webhooks/:webhookId/deliveriesDelivery history
/v1/webhooks/:webhookId/deliveries/:id/resendResend a single delivery
/v1/webhooks/:webhookId/replayReplay events in a time window. Body: { from: ISO, to: ISO } (max 7-day range, max 500 events). Replayed deliveries include X-Staffify-Replay: 1 header. Returns { queued: N }.
/v1/webhooks/:webhookId/dlqList dead-letter queue items
/v1/webhooks/:webhookId/dlq/resend-allResend all DLQ items
Project-level webhook
A simpler alternative -- one webhook per project, managed directly on the project resource. The project webhook is scoped to events from that project only. Wildcard "*" is not accepted for webhook_events; use explicit event names.
GET /v1/project/webhook — response
{
"webhook_url": "https://your-server.com/project-hook",
"webhook_secret_hint": "...abcd1234",
"webhook_events": ["call.started", "call.ended"]
}PATCH /v1/project/webhook — set or update
curl -X PATCH https://api.staffifyai.com/v1/project/webhook \
-H "Authorization: Bearer sfy_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"webhook_url": "https://your-server.com/project-hook",
"webhook_events": ["call.started", "call.ended"]
}'
# Response includes webhook_secret once (only on first set or rotation)
{
"webhook_url": "https://your-server.com/project-hook",
"webhook_secret_hint": "...abcd1234",
"webhook_events": ["call.started", "call.ended"],
"webhook_secret": "abc123...full64charHex",
"webhook_secret_note": "Save this secret — it will not be shown again."
}Set webhook_url: null to remove the project webhook entirely.
/v1/project/webhookGet the project webhook config (webhook_url, webhook_secret_hint, webhook_events)
/v1/project/webhookSet or update: { webhook_url, webhook_events }. Set webhook_url: null to remove.
/v1/project/webhookRemove the project webhook. Returns { deleted: true }.
/v1/project/webhook/rotateRotate the secret. Returns { webhook_secret: "...", note: "..." }.
/v1/project/webhook/testSend a test payload. Body: { event_type: "call.ended" }. Wildcards not accepted.
/v1/project/webhook/deliveriesDelivery history (same fields as org webhook deliveries, cursor paginated)
/v1/project/webhook/dlqDead-letter queue items
/v1/project/webhook/dlq/resend-allResend up to 200 DLQ items. Returns { queued: N }.