Skip to main content
There are two ways to receive webhooks, and each has its own secret.

Which secret signs what

Both use the same signature scheme and the same X-Mirai-Signature header. Only the secret differs. Every signed delivery also carries X-Mirai-Signed-With, which names the secret that signed it:
If your server receives webhooks from both, read this header first and pick the secret it names.

One URL, one secret

A URL receives each event once, signed with one secret. If a call’s webhook_url is the same URL as one of your enabled destinations, the destination takes over that URL:
  • When a subscription on that destination already covers the event for this call (the whole workspace, or the call’s agent), the destination delivers it. We do not send the per-call copy, so the event arrives once.
  • When no subscription covers the event, we still send the per-call copy, but sign it with the Destination secret, so that URL only ever sees one secret.
URLs match when they are the same after we lowercase the scheme and host, drop a default port and ignore a trailing slash. The query string must match exactly. This applies to every per-call webhook_url, including the ones set on inbound phone numbers and WhatsApp calls. Only enabled destinations count: a disabled destination never takes over a URL. A per-call webhook_url that matches no destination is signed the same way as before.

Our recommendation

Use destinations. You set the URL once, choose events for one agent or the whole workspace, and see every delivery and retry in the console. Keep webhook_url for simple one-off integrations, such as a script that places a few calls and wants their results. If you already pass webhook_url on every call and have now created a destination for the same URL, you can remove webhook_url from your call requests.

A webhook failed its signature check

Your server answered 401 or 403, or your logs show a signature mismatch. Work through these in order.
  1. Check X-Mirai-Signed-With. It names the secret that signed this delivery. A server that holds only the Destination secret will reject deliveries signed with the Call webhook secret, and the other way round.
  2. Verify with that secret. Use the full whsec_… value exactly as it was shown, with no spaces or newlines.
  3. Sign the raw body. Compute HMAC-SHA256(secret, t + "." + raw_body) over the exact bytes you received, before any JSON parsing, and compare the lowercase hex with v1 from X-Mirai-Signature: t=…,v1=…. A body that was parsed and serialised again will not match. See raw body.
  4. Check the time window. Reject only when t is more than 300 seconds from your server’s clock. If good deliveries fail this check, sync your clock with NTP.
  5. Run the test vector. Check your code against the test vector.
To see failed deliveries in the console, open Webhooks:
  • The Call webhook_url panel lists each webhook_url your calls used in the last 7 days, with its recent attempts, status codes and which secret signed them.
  • Deliveries lists every delivery to your destinations, with each attempt.
A call’s detail page shows the webhook deliveries for that call, of both kinds.

Read per-call deliveries with the API

GET /v2/webhooks/call-deliveries lists recent attempts to deliver to the webhook_url passed on calls, newest first. Deliveries to destinations are under GET /v2/webhooks/deliveries.
Response
When has_more is true, the response also has next_cursor. We keep the newest 500 records per workspace, for up to 30 days. GET /v2/webhooks/settings also returns call_webhook_urls: each distinct webhook_url your calls used in the last 7 days.
last_status is the outcome of the latest delivery: delivered, failed or superseded. last_http_status and last_hint appear when there is a value. failures_24h counts failed deliveries in the last 24 hours. endpoint_id is the enabled destination with the same URL, or null when there is none. When it is set, that destination now handles the URL as described in one URL, one secret.