Skip to main content
API Reference

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/webhooks

Events across your entire org

Project webhook

PATCH /v1/project/webhook

Events for one project

Agent webhook

agent.webhook_url field

Events for one agent's calls only

All event types

Call Lifecycle

call.ringingcall.startedcall.endedcall.failedcall.recording_readycall.dtmf.receivedcall.gather.completed

Real-time Speech

transcript.updatedspeech.startedspeech.endedai.speech.startedai.speech.endedbarge_in

Tools

tool.invokedtool.completedtool.timeout

Transfers

transfer.initiatedtransfer.completedtransfer.failed

SMS

sms.sent

Knowledge Base

knowledge_base.refreshedknowledge_base.refresh_failedknowledge_base.auto_refresh_disabled

Address Verification

address.verification.pendingaddress.verification.verifiedaddress.verification.partially_verifiedaddress.verification.rejectedaddress.verification.expired

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.started
data: { call_id, agent_id, from, to }
call.ended
data: {
  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.ended
data: { call_id, agent_id, from | null, timestamp }

speech.ended also includes transcript (transcribed text | null).

ai.speech.startedai.speech.ended
data: { 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.expired
data: { address_id, status }

verified, partially_verified, rejected, and expired also include number_types: [{ type, status, rejection_reason }].

Agent-level webhook scope: The agent's 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

HeaderValue
Content-Typeapplication/json
X-Webhook-Eventcall.ended (the event type)
X-Webhook-TimestampUnix timestamp (seconds)
X-Webhook-Signaturesha256=<HMAC-SHA256 hex>
X-Staffify-Api-Version2026-05-20
User-AgentStaffify-API/1.0
X-Staffify-Test-Event1 — only on test deliveries
X-Staffify-Replay1 — only on replayed deliveries

Signature verification

Always verify the signature. Never process webhook payloads without checking the X-Webhook-Signature header. Skipping this opens your server to spoofed events.

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 '', 200

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

POST/v1/webhooks
The response includes a secret field. This is shown once only -- save it immediately to verify signatures.
curl -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

POST
/v1/webhooks

Create a webhook (returns secret once)

GET
/v1/webhooks

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

GET
/v1/webhooks/:webhookId

Get single webhook

PATCH
/v1/webhooks/:webhookId

Update: url, events, filters, is_active

DELETE
/v1/webhooks/:webhookId

Delete webhook

POST
/v1/webhooks/:webhookId/rotate

Issue new secret (old immediately invalid)

POST
/v1/webhooks/:webhookId/test

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

GET
/v1/webhooks/:webhookId/deliveries

Delivery history

POST
/v1/webhooks/:webhookId/deliveries/:id/resend

Resend a single delivery

POST
/v1/webhooks/:webhookId/replay

Replay 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 }.

GET
/v1/webhooks/:webhookId/dlq

List dead-letter queue items

POST
/v1/webhooks/:webhookId/dlq/resend-all

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

GET
/v1/project/webhook

Get the project webhook config (webhook_url, webhook_secret_hint, webhook_events)

PATCH
/v1/project/webhook

Set or update: { webhook_url, webhook_events }. Set webhook_url: null to remove.

DELETE
/v1/project/webhook

Remove the project webhook. Returns { deleted: true }.

POST
/v1/project/webhook/rotate

Rotate the secret. Returns { webhook_secret: "...", note: "..." }.

POST
/v1/project/webhook/test

Send a test payload. Body: { event_type: "call.ended" }. Wildcards not accepted.

GET
/v1/project/webhook/deliveries

Delivery history (same fields as org webhook deliveries, cursor paginated)

GET
/v1/project/webhook/dlq

Dead-letter queue items

POST
/v1/project/webhook/dlq/resend-all

Resend up to 200 DLQ items. Returns { queued: N }.

Webhooks - Staffify API