Webhook guide
Switchly delivers asynchronous lifecycle notifications to URLs you register
via POST /v1/webhooks. Each event is a signed HTTP POST with a JSON body
in a stable envelope.
Event catalog
| Event | Fires when | Payload data fields |
|---|---|---|
lead.routed |
A LeadAssignment is created. |
lead_id, assignment_id, routed_to_agent_id, routed_to_site_id, product_line |
lead.delivered |
Recipient site's webhook returned 2xx. | lead_id, assignment_id, agent_id, site_id, product_line, response_status |
lead.failed |
Recipient site's webhook returned non-2xx (or timed out). | lead_id, assignment_id, agent_id, site_id, product_line, response_status, attempts |
lead.scored |
POST /v1/leads/score ran and persisted scoring values. |
lead_id, quality_score, intent_score, contactability_score, fraud_score, confidence |
lead.flagged_fraud |
One or more structural fraud signals detected (duplicate email within 7 days, IP velocity burst, etc.). | lead_id, signals: [{type, details, created_at}], fraud_score |
lead.status_changed |
An agent transitioned an assignment status (e.g., pending → contacted). |
lead_id, assignment_id, agent_id, site_id, product_line, from_status, to_status, is_terminal, changed_at |
Envelope
Every event uses the same outer shape:
{
"event": "lead.routed",
"sent_at": "2026-05-22T15:00:00Z",
"platform_id": 42,
"data": {
"lead_id": "019e4ddd-…",
"assignment_id": 1024,
"routed_to_agent_id": 7,
"routed_to_site_id": 3,
"product_line": "medicare"
}
}
event and platform_id are stable across all event types so you can
route inside a single handler. data shape varies — see the table above.
Verifying the signature
Switchly sends two headers you care about:
X-Switchly-Signature: t=1779000000,v1=abc123…sha256hex
X-Switchly-Event: lead.routed
The signature is HMAC-SHA256 over <timestamp>.<raw_body> using the
signing_secret returned when you registered the webhook. Reject any
request that doesn't verify — that's how you defeat replay attacks and
forgery.
Verification recipe (any language)
- Parse
t=andv1=fromX-Switchly-Signature. - Reject if
abs(now - t) > 300(5-minute tolerance). - Compute
hash_hmac('sha256', t + "." + raw_body, signing_secret). - Compare against the
v1=value with a constant-time comparator (PHP:hash_equals, Python:hmac.compare_digest, Node:crypto.timingSafeEqual).
PHP (with the SDK)
use Switchly\Webhooks\SignatureVerifier;
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_SWITCHLY_SIGNATURE'] ?? null;
$verifier = new SignatureVerifier($_ENV['SWITCHLY_WEBHOOK_SECRET']);
if (! $verifier->verify($rawBody, $header)) {
http_response_code(401);
exit;
}
$event = json_decode($rawBody, true);
// handle...
Python
import hmac, hashlib, time
def verify(body: str, header: str | None, secret: str, tolerance: int = 300) -> bool:
if not header:
return False
parts = dict(p.strip().split('=', 1) for p in header.split(',') if '=' in p)
if 't' not in parts or 'v1' not in parts:
return False
if abs(int(time.time()) - int(parts['t'])) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{parts['t']}.{body}".encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts['v1'])
Node
const crypto = require('crypto');
function verify(body, header, secret, tolerance = 300) {
if (!header) return false;
const parts = Object.fromEntries(header.split(',').map(s => s.trim().split('=')));
if (!parts.t || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > tolerance) return false;
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${body}`).digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
Delivery semantics
- Best-effort delivery. Switchly retries with exponential backoff: 30s → 1m → 5m → 15m → 1h, up to 5 attempts. After that the delivery is given up; the assignment row keeps the errors trail for inspection.
- No ordering guarantees. A
lead.deliveredfor lead A may arrive after alead.routedfor lead B even though A was routed first. Always branch onevent+data.lead_id. - At-least-once. Net-net: a single business event may produce more
than one HTTP delivery if a retry happens after your server already
succeeded (the response was lost in transit). Make your handler
idempotent — the most-used pattern is to dedupe on
data.assignment_idplusevent. - Health counters.
last_success_at,last_failure_at, andconsecutive_failurestrack per-subscription health and surface inGET /v1/webhooks. Switchly does not auto-disable failing webhooks — that's an operator decision.