Skip to main content
500CallsDocs
Browse the docs

Webhooks

A webhook tells your server when one of your messages changes status, so you do not have to poll. We send an HTTPS POST with a JSON body, signed with a secret that only you and we know.

Set up an endpoint

In the dashboard, open Developers, then Webhooks, and add your endpoint's URL. The URL must use HTTPS on port 443 or 8443, name a host rather than an IP address, carry no user name or password, and resolve only to public addresses. We check these rules when you save the endpoint and again before every attempt. You can add up to 5 endpoints.

When you add an endpoint we show its signing secret, which starts with whsec_, once: store it with your other secrets. We cannot show it again. If you lose it, add a new endpoint and delete the old one.

The request

HTTP
POST /webhooks/500calls HTTP/1.1Content-Type: application/jsonUser-Agent: 500Calls-Webhooks/1X-500Calls-Event-Type: message.status_changedX-500Calls-Delivery-Id: whd_01M3KR6DSA7X8ZC1TDHYM13J32X-500Calls-Signature: t=1790590540,v1=af0c23eff898143c8c55f5e0340c8a30fc220b3f04b5d71ce5dacbd91bc22edb
HeaderMeaning
X-500Calls-Signaturet=, the time we signed this attempt in Unix seconds, and v1=, the signature
X-500Calls-Event-TypeThe event type
X-500Calls-Delivery-IdThis delivery to your endpoint, whd_…; the same on every retry
User-Agent500Calls-Webhooks/1

The event

Today the dashboard subscribes each endpoint to one event type, message.status_changed. We send it each time one of your messages moves to sent, delivered, undelivered, expired, rejected, failed or cancelled, whether you sent it through the API or from the dashboard. A recipient rejected in the response to your send request gets no event: the response already told you.

The body is compact JSON with its keys in alphabetical order. Here is the event behind the request above, laid out for reading:

JSON
{  "api_version": "v1",  "created_at": "2026-09-28T10:15:39.870Z",  "data": {    "object": {      "amount": "3.2000",      "body": "Your ACME code is 482913. It expires in 5 minutes.",      "charge_state": "captured",      "created_at": "2026-09-28T10:15:30.000Z",      "error_code": null,      "from": "ACME",      "id": "msg_01M3KR6CEGVQVV5PV6Q9J1G6SP",      "object": "message",      "sent_at": "2026-09-28T10:15:31.412Z",      "status": "delivered",      "to": "+2348031234567",      "unit_price": "3.2000",      "updated_at": "2026-09-28T10:15:39.870Z"    },    "previous_status": "sent"  },  "id": "evt_01M3KR6D868QPH5MEE2236DZSH",  "tenant_id": "ten_01M3KR6B2W9T4XQZ8N5E7J1HAD",  "type": "message.status_changed"}

id identifies the event, and created_at is when the change happened, not when we sent it. data.object is the message after the change, and data.previous_status the status before it. Ignore fields you do not recognise: new ones can be added.

charge_state is held while its funds are held, captured once the message is charged, released once its hold is released, and none for a message that never had a hold. It changes with the status, but the money itself moves when the message is settled, within about a minute: until then the amount still counts as held in your balance.

error_code is the message error code, or null. unit_price and sent_at are null for a message that was never sent, such as one rejected, failed or cancelled before a route accepted it, and its amount is "0.0000".

Verify the signature

Check every request before you trust it:

  1. Read the raw request body as bytes, before any JSON parsing or re-encoding.
  2. Split X-500Calls-Signature on , and take the t= value and every v1= value. Ignore any other key.
  3. Refuse the request if t is more than 300 seconds away from your clock, so that a captured request cannot be replayed later. Keep your server's clock in sync.
  4. Compute HMAC-SHA256 with your signing secret as the key, whsec_ prefix included, over t, a . and the raw body, and write it as lowercase hex.
  5. Compare it with each v1 value in constant time, and accept the request if any one matches.

Answer a request that fails the check with a 400 status, and do not act on it. A request replayed within the 300 seconds carries an event id you have already processed, so deduplicating catches it (see below).

A request carries one v1 value today. Signing-secret rotation, when it arrives, sends one per active secret during the overlap, newest first: that is why the check accepts any match.

Each sample below returns true only for a genuine, fresh request. It takes the current time as an optional argument, so you can test it with a fixed one.

Python
import hashlibimport hmacimport timefrom typing import Optionaldef verify_signature(    secret: str,    header: Optional[str],    raw_body: bytes,    now: Optional[int] = None,    tolerance: int = 300,) -> bool:    """Check the X-500Calls-Signature header of a 500Calls webhook.    secret: your endpoint's signing secret, whsec_ prefix included.    header: the X-500Calls-Signature value, "t=<unix seconds>,v1=<hex>", or None if missing.    raw_body: the request body exactly as received, before any JSON parsing    (with Flask, for example, request.get_data()).    """    timestamp = None    signatures = []    for part in (header or "").split(","):        key, _, value = part.strip(" \t").partition("=")        if key == "t" and len(value) <= 15 and value.isascii() and value.isdigit():            timestamp = int(value)        elif key == "v1" and value:            signatures.append(value)    if timestamp is None or not signatures:        return False    current = int(time.time()) if now is None else now    if abs(current - timestamp) > tolerance:        return False    signed = f"{timestamp}.".encode() + raw_body    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest().encode()    return any(hmac.compare_digest(expected, signature.encode()) for signature in signatures)
Node.js
import { Buffer } from "node:buffer";import { createHmac, timingSafeEqual } from "node:crypto";/** * Checks the X-500Calls-Signature header of a 500Calls webhook. * secret: your endpoint's signing secret, whsec_ prefix included. * header: the X-500Calls-Signature value, "t=<unix seconds>,v1=<hex>", or undefined if missing. * rawBody: the request body exactly as received (a Buffer or a string), before any JSON parsing. * With Express, for example, read it with express.raw({ type: "application/json" }) on this route. */export function verifySignature(  secret,  header,  rawBody,  { now = Math.floor(Date.now() / 1000), tolerance = 300 } = {},) {  let timestamp = null;  const signatures = [];  for (const part of String(header ?? "").split(",")) {    const [key, ...rest] = part.replace(/^[ \t]+|[ \t]+$/g, "").split("=");    const value = rest.join("=");    if (key === "t" && /^\d{1,15}$/.test(value)) timestamp = Number(value);    else if (key === "v1" && /^[0-9a-f]{64}$/.test(value)) signatures.push(value);  }  if (timestamp === null || signatures.length === 0) return false;  if (Math.abs(now - timestamp) > tolerance) return false;  const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();  return signatures.some((signature) => timingSafeEqual(Buffer.from(signature, "hex"), expected));}
PHP
<?php/** * Checks the X-500Calls-Signature header of a 500Calls webhook. * $secret: your endpoint's signing secret, whsec_ prefix included. * $header: the X-500Calls-Signature value, "t=<unix seconds>,v1=<hex>", or null if missing. * $rawBody: the request body exactly as received: file_get_contents('php://input'). */function verify_signature(string $secret, ?string $header, string $rawBody, ?int $now = null, int $tolerance = 300): bool{    $timestamp = null;    $signatures = [];    foreach (explode(',', $header ?? '') as $part) {        $pieces = explode('=', trim($part, " \t"), 2);        if (count($pieces) !== 2) {            continue;        }        [$key, $value] = $pieces;        if ($key === 't' && strlen($value) <= 15 && ctype_digit($value)) {            $timestamp = (int) $value;        } elseif ($key === 'v1' && $value !== '') {            $signatures[] = $value;        }    }    if ($timestamp === null || $signatures === []) {        return false;    }    $now = $now ?? time();    if (abs($now - $timestamp) > $tolerance) {        return false;    }    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);    foreach ($signatures as $signature) {        if (hash_equals($expected, $signature)) {            return true;        }    }    return false;}

A worked example

With the secret whsec_Zx8Kq2Lm4Np6Rt8Vw0Yb2Dc4Fg6Hj8Kl0Mn2Pq4Rs6T and t = 1790590530, the body below is signed t=1790590530,v1=4640e6ace80b3737256f60e6ba9edc127f51840a166384ed2cec1660e1363b71. Test your code with it, passing 1790590530 as the current time: at any other time the 300-second check refuses it. This secret is for testing only.

Text
{"id":"evt_01J9Z3K4X5M6N7P8Q9R0S1T2V3","type":"webhook.test","api_version":"v1","created_at":"2026-09-28T10:15:30.000Z","tenant_id":"ten_01J7A2B3C4D5E6F7G8H9J0K1M2","data":{"object":{"message":"This is a test event"}}}

The example request is signed with the same secret: the example event, written as compact JSON with its keys in alphabetical order, verifies at t = 1790590540.

Answer quickly

Answer with any 2xx status within 10 seconds, and do slow work afterwards, for example from a queue. We do not read the response body. Anything else counts as a failure: another status, a timeout, a redirect (we do not follow redirects) or a connection error.

Retries

A failed delivery gets up to 5 attempts in all: the first at once, then retries after 1 minute, 5 minutes, 15 minutes and 1 hour, each counted from the failure before it. Every attempt is signed afresh with a new t, so a retry passes the 300-second check. After the fifth failure we stop trying.

If you disable or delete an endpoint, we stop sending to it straight away: retries still due are dropped, and events from while it was disabled are not sent later.

Duplicates and order

Delivery is at least once: the same event can reach you more than once, for example when your 2xx was lost on the way back or came after 10 seconds. Deduplicate on the event's id, and keep the IDs you have processed for at least 72 hours.

Events arrive in any order: a retry of an older event can arrive after a newer one. Apply a status only if it ranks higher than the status you have stored for that message:

StatusRank
scheduled1
queued2
sent3
expired4
undelivered5
delivered6
rejected, failed, cancelled6

This follows the statuses' own rules: expired can still become undelivered or delivered, undelivered can still become delivered, and nothing leaves delivered.

Coming next

Today message.status_changed is the only event type you can subscribe to in the dashboard. More is on the way, and this page will document each part when it ships:

  • one event type per message status, each carrying the full message object: message.sent, message.delivered, message.undelivered, message.expired, message.rejected, message.failed and message.cancelled;
  • campaign events, sent as a campaign starts, pauses, resumes, completes, is cancelled or fails: campaign.started, campaign.paused, campaign.resumed, campaign.completed, campaign.cancelled and campaign.failed;
  • sender ID events, sent as a sender ID is approved, rejected, suspended or reinstated: sender_id.approved, sender_id.rejected, sender_id.suspended and sender_id.reinstated;
  • wallet events: wallet.funded when a top-up succeeds, and wallet.low_balance when your balance falls below your alert threshold;
  • webhook.test, a test event you can send to an endpoint from the dashboard;
  • an X-500Calls-Event-Id header carrying the event's id, and an X-500Calls-Delivery-Attempt header carrying the attempt number;
  • choosing which events and which message sources an endpoint receives;
  • signing-secret rotation with an overlap, during which both secrets sign each request;
  • a delivery log in the dashboard, and endpoints that switch themselves off after repeated failures.