The event model
You register one webhook per operator + network combination, choosing which event category it covers:
There’s at most one registration per (account, operator, network) —
eventType isn’t part of that key, so registering a second event type for the same operator+network replaces the first rather than adding a second registration. Registering again for an existing combination without update: true in the body returns 409 CONFLICT.
Registering
voice (or both) always succeeds if the operator/network combination itself is valid — there’s no registration-time check that voice is actually enabled the way there is for sms. But you won’t actually receive voice events for a line unless that line’s active plan includes voice support (a plan with no voice allowance leaves voice disabled on the number). Make sure the SIMs you expect voice events for are on a voice-capable plan before assuming a registered voice webhook is misbehaving if nothing arrives. This applies to voiceStreamUrl (see Streaming live voice media) as well — there’s no separate “voice streaming” plan capability, it rides on the same voice allowance as voiceUrl/callbackVoiceUrl.Registering a webhook
"***ENCRYPTED***", plus a one-time signingSecret (format whsec_<64 hex chars>) — see Verifying delivery signatures below.
Streaming live voice media
webhookUrls.voiceStreamUrl is a different kind of field from messageUrl/voiceUrl/etc. — instead of an HTTP POST per event, Spenza opens an outbound WebSocket connection to it whenever a live voice call starts on that operator/network:
- Accepts
ws://,wss://,http://, orhttps://(Spenza always connects over WebSocket regardless of scheme). - On connect, Spenza sends a JSON handshake frame first, then streams binary PCM audio (8kHz, 16-bit, mono) frames in real time for the duration of the call.
- It’s independent of
voiceUrl/callbackVoiceUrl— you can set any combination of the three on avoice/bothregistration, includingvoiceStreamUrlalone. - It only ever activates if that line’s active plan includes voice support, same as any other voice delivery — see the note above.
- It’s never used as a
POST /api/v1.1/webhooks/{id}/testtarget — see Testing a registration.
signingSecret again — only the create/upsert response does. Store it the moment you get it; losing it means re-registering.
How your endpoint authenticates the caller
Separately from payload signing,authentication (type: basic | bearer | api_key | none) protects your webhook URL — Spenza presents this credential when it calls you, so your endpoint can reject callers that don’t have it. Set it to bearer or basic, not none, so your endpoint isn’t open to anyone who discovers its URL.
Verifying delivery signatures
Every delivery (including test events) carries two headers:X-Spenza-Timestamp is Unix seconds. The v1 value in X-Spenza-Signature is an HMAC-SHA256 computed over the exact string ${timestamp}.${rawBody}, keyed with your registration’s signingSecret — a Stripe-compatible scheme, chosen so most partners can reuse an existing verifier:
rawBody (the exact bytes received), not a re-serialized version of the parsed JSON — re-serialization can silently change key order or whitespace and break the comparison. A 5-minute tolerance window on X-Spenza-Timestamp is documented convention (WEBHOOK_SIGNATURE_TOLERANCE_SECONDS = 300) — Spenza doesn’t enforce it on the sending side; it’s your endpoint’s own replay-protection check to make if you want it.
Retry behavior
Delivery retries are configurable per registration viaretryPolicy:
A delivery is considered successful when your endpoint returns any
2xx response — respond fast and do your processing after responding, not before. After 10 consecutive delivery failures, a registration’s status automatically flips to suspended; check GET /api/v1.1/webhooks periodically, or the status field on GET /api/v1.1/webhooks/{id}, to catch this.
Testing a registration
Send a one-off synthetic event without waiting for a real operator event:messageUrl → callbackMessageUrl → voiceUrl → callbackVoiceUrl (voiceStreamUrl is never a test target — there’s no synthetic way to rehearse the WebSocket relay). It’s recorded in the deliveries list but doesn’t count toward the 10-consecutive-failure auto-suspend threshold, so you can rehearse your handler without risking a live registration’s status.
Inspecting and redelivering past events
deliveryId, webhookId, eventType, status, responseCode, attempt, deliveredAt). If you find a FAILED delivery worth retrying manually (after fixing your handler, for example), redeliver the original event and body to its original endpoint:
202 immediately with a new PENDING delivery record — it runs on a background queue, not the request cycle, so poll the deliveries list for the outcome.
Idempotency
Example payloads
Payload schema is being published. The exact JSON body sent to
messageUrl/voiceUrl on a real inbound SMS or voice event isn’t published yet. Until then, register a webhook against a test endpoint (a request-inspection tool) and use POST /api/v1.1/webhooks/{id}/test or a real event to capture the actual shape before writing a parser.Next steps
- Security & Best Practices for handling the credentials in
authenticationandsigningSecret. - API Reference → Webhooks (sidebar) for the full field-level schema of the registration and delivery objects.

