One webhook for everything
Calls, messages, numbers and usage all arrive at one URL, in one envelope, signed the same way — or over a websocket, if you would rather hold the connection.
The envelope
{
"id": "evt_…",
"type": "call.ended",
"account_id": "…",
"created_at": 1794146700.25,
"data": { "call_key": "…", "outcome": "completed", "duration_seconds": 83 }
}created_at is a JSON number: seconds since the Unix epoch, possibly with a fractional part — not a date string.
| Event | When |
|---|---|
call.started | A call begins, inbound or outbound. |
call.turn | The synchronous conversation transport: the caller's words, expecting your reply (see below). |
call.ended | A call closes — outcome, duration and a pointer to its transcript. |
message.received | A text arrives on one of your numbers. |
voicemail.received | An inbound call fell back to voicemail; the transcript follows the message. |
number.provisioned | A bought number is live. |
usage.threshold | A usage alert fires (plan threshold or a low wallet balance). |
agent.action | A hosted agent fires its webhook action. |
webhook.test | You asked for a test event. |
Verify the signature
Every delivery carries X-CallMe-Signature: t=<unix seconds>,v1=<hex>. t is when that attempt was signed (a retry is signed again, with a fresh t). Each v1 is the lowercase HMAC-SHA256 hex of <t>.<the raw request body> under one signing secret: the secret is shown once, when the webhook is set or rotated, and its string is the HMAC key as is.
Accept a request only when t is within 300 seconds of your clock (either way) and any v1 equals the value you compute under any secret you hold. Ignore header keys you do not know, and reject anything malformed. Verification helpers ship in both SDKs (verify_webhook_signature in Python, verifyWebhookSignature from @olorin/callme/webhooks in TypeScript); by hand it is:
import hashlib, hmac, time
def verify(secrets, raw_body, header, tolerance=300):
parts = [element.partition("=") for element in header.split(",")]
if any(not separator for _key, separator, _value in parts):
return False
stamps = [value for key, _sep, value in parts if key == "t"]
candidates = [value for key, _sep, value in parts if key == "v1"]
if len(stamps) != 1 or not stamps[0].isascii() or not stamps[0].isdigit() or not candidates:
return False
timestamp = int(stamps[0])
if abs(time.time() - timestamp) > tolerance:
return False
signed = f"{timestamp}.".encode() + raw_body
ok = False
for secret in secrets:
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
for candidate in candidates:
ok |= hmac.compare_digest(expected.encode(), candidate.encode())
return ok
ok = verify([secret], raw_body, request.headers["X-CallMe-Signature"])The timestamp bounds replay of a captured request. It does not replace idempotent handling: delivery is at least once, so dedupe on the event id.
Rotating the secret
Rotating (POST /v1/webhooks/secret) mints a new secret and keeps the old one valid for a grace window, so a receiver mid-deploy never fails. The window is 24 hours by default; send grace_seconds to shorten it, and 0 cuts the old secret off at once (use that when it leaked). While the window is open every request carries two v1 values, the current secret's first and the previous one's second. To roll: deploy a build that accepts both secrets, rotate, wait out the window, then drop the old secret. A secret is shown once, over REST or the account page; the MCP connector never returns one.
The websocket handshake
The websocket transport has no body at connect time, so its handshake carries the same header, signed over callme-ws-connect\n<account id>\n<t> with the account id taken from the X-CallMe-Account header. t in the signature header is the only timestamp. Verify it the same way: verify_ws_handshake / verifyWsHandshake.
Delivery rules
- At least once, for 24 hours. The first attempt is inline; retries back off (30s, 2m, 10m, 30m, 1h, 2h, then 4h) until the event is delivered or the window closes and the delivery is marked dead.
- Five consecutive failures pause delivery. Events while paused are recorded, not attempted; any success resets the counter, and only you can un-pause. Inbound calls to a webhook-routed number fall back to voicemail while your webhook is failing — callers never get dead air.
- Every attempt is logged. Read the delivery log from the API and see each event, attempt and last status.
Events caused by a test key's sandbox calls carry "sandbox": true in the envelope, and a test key manages its own sandbox webhook (its own URL, secret and delivery log), separate from the live one. Sandbox calls, messages, recordings, delivery logs and the sandbox wallet balance are ephemeral and per instance; sandbox configuration (numbers, webhook, agents, registrations) persists.
Events carry the numbers in full: your own number and the other party's (an inbound caller's caller ID), because they are delivered only to your own signed endpoint.
Driving a conversation
For a webhook-routed number, each caller utterance is delivered as a synchronous call.turn: reply within 8 seconds with exactly {"reply_text": "…"} and it is spoken. Anything else — extra action fields, empty text, a timeout — counts as a failed delivery, never as something to say. The same envelope and reply contract runs over a websocket transport for lower latency; the platform connects out to your wss:// endpoint once per call, with an HMAC-signed handshake. A websocket reply frame may also carry "turn", the number of the call.turn it answers; a frame whose turn is not the turn in flight is dropped as stale, not spoken (reply_text stays the only field that carries speech). Notifications that are not turns (call started, call ended, messages) reach a websocket-transport webhook through an optional events_url, an https endpoint set with the webhook; without one they are recorded as skipped and never sent to the wss:// URL. Working examples of both ship with the service.