Skip to main content
Unpod signs the requests it sends your app with one org-level key, the webhook secret. It’s the same key for status callbacks and inbound answer-URL requests, with no secret per number or per call. Create it once with rotate() - the only call that returns it in full - and store it as UNPOD_WEBHOOK_SECRET. Then verify each request with parse_call_event.

Which requests are signed

“Yes” applies once your org has a secret: until then every request goes out unsigned, so create it before you point anything at a server that verifies. parse_call_event rejects an unsigned request. Parse those with verify=False and trust only what the API returns, as Recording and Number Masking do.

Get the secret

Each rotate() creates a new version and invalidates the previous one, so a server still verifying with the old key rejects every request until you give it the new one. pushed_to_sbc is true when the new key is already in use for signing. When it’s false, Unpod keeps retrying the update in the background; until it lands, requests are still signed with the previous key (or unsigned, for your first secret). See The webhook secret for the cURL call.

Verify and parse

Rules:
  • Pass the raw body exactly as received. Re-encoding it breaks the signature.
  • Content-Type decides form or JSON; parse_call_event reads both.
  • The same event can arrive more than once (see Delivery and retries). De-duplicate on ev.event_id, and pass an idempotency_key when an event triggers add_participant.
  • Return 2xx for events you don’t need, so they aren’t retried.
  • tolerance (default 300 s) rejects replays. verify=False skips the check: use it only for local tests and for the unsigned requests.
The signature header is X-Webhook-Signature: t=<unix ts>,v1=<hex HMAC-SHA256(secret, "<ts>.<raw body>")>.

Delivery and retries

Unpod POSTs each status callback and recording event and waits up to 10 s for a 2xx. Any other status, a timeout or a connection error is a failure, and Unpod tries again: 4 attempts in all, about 1 s, 5 s and 30 s apart (each wait varies by up to 25%, so 27-45 s of waiting in all). Each attempt can take up to 10 s, so the window from the first attempt to the last is roughly 27-75 s. Then the event is marked failed and never sent again.
  • Events for one leg arrive in order (ev.sequence). While one is being retried, that leg’s later events wait behind it; other legs aren’t held up.
  • An event queued before you call rotate() is retried signed with the old key, so a server that already has the new key rejects it.
  • Return 2xx fast and do slow work afterwards. A reply slower than 10 s is a failure.
  • Make handlers idempotent: the same ev.event_id can arrive twice, for example when your 2xx is lost.
  • A lost Answered or Hangup can leave someone on the line. As a backstop, sweep your active calls with calls.list() every few minutes, walking every page until one comes back empty, and end any that should be over.
  • Answer-URL requests aren’t retried: a failed StartApp applies the fallback, and Hangup and RecordStop are sent once.

Events

CallEvent fields

Form wire names are below; with status_callback_format="json" the same fields come in snake_case (call_id, leg_id, …).
ev.metadata works only with the default form format. With status_callback_format="json", parse_call_event (SDK 0.6.2) leaves it empty because it reads only the form field Metadata. Keep the form format, or read ev.raw["metadata"].

Hangup causes

The strings are exact. Match on ev.hangup_cause first. ev.sip_code is always set for Busy Line, No Answer, Invalid Destination Address, Forbidden and Service Unavailable. It’s None on an answered leg, and can be None for Rejected, Cancelled, Unknown and Ring Timeout Reached. A ring timeout also reports the 487 from cancelling the ringing leg, so 487 alone doesn’t mean a cancel.