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)

  1. Parse t= and v1= from X-Switchly-Signature.
  2. Reject if abs(now - t) > 300 (5-minute tolerance).
  3. Compute hash_hmac('sha256', t + "." + raw_body, signing_secret).
  4. 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.delivered for lead A may arrive after a lead.routed for lead B even though A was routed first. Always branch on event + 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_id plus event.
  • Health counters. last_success_at, last_failure_at, and consecutive_failures track per-subscription health and surface in GET /v1/webhooks. Switchly does not auto-disable failing webhooks — that's an operator decision.