> ## Documentation Index
> Fetch the complete documentation index at: https://docs.spenza.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Register endpoints to receive operator SMS and voice events, verify their signature, and inspect deliveries.

Spenza can push operator-originated SMS and voice events to a URL you control, instead of you polling for them. You manage this through a REST collection — register a webhook, list your registrations, update or delete them — and every delivery is signed so you can verify it actually came from Spenza.

## The event model

You register **one webhook per operator + network combination**, choosing which event category it covers:

| `eventType` | Delivers                                        |
| ----------- | ----------------------------------------------- |
| `sms`       | Inbound SMS events for that operator/network.   |
| `voice`     | Inbound voice events for that operator/network. |
| `both`      | Both.                                           |

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`.

<Note>
  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.
</Note>

## Registering a webhook

```bash theme={null}
curl -X POST "https://api.spenza.com/api/v1.1/webhooks" \
  -H "Authorization: Bearer $SPENZA_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "operator": "SpenzaJ",
    "network": "T-Mobile",
    "eventType": "sms",
    "authentication": { "type": "bearer", "token": "a-secret-you-choose" },
    "webhookUrls": { "messageUrl": "https://partner.example.com/hooks/sms" },
    "retryPolicy": { "maxAttempts": 5, "backoffStrategy": "exponential", "initialDelaySeconds": 5, "maxDelaySeconds": 300 },
    "status": "active"
  }'
```

The response echoes your registration back with credential fields masked as `"***ENCRYPTED***"`, **plus a one-time `signingSecret`** (format `whsec_<64 hex chars>`) — see [Verifying delivery signatures](#verifying-delivery-signatures) below.

Manage registrations as a normal REST collection:

```bash theme={null}
curl "https://api.spenza.com/api/v1.1/webhooks" -H "Authorization: Bearer $SPENZA_TOKEN"
curl "https://api.spenza.com/api/v1.1/webhooks/{id}" -H "Authorization: Bearer $SPENZA_TOKEN"
curl -X PUT "https://api.spenza.com/api/v1.1/webhooks/{id}" -H "Authorization: Bearer $SPENZA_TOKEN" -d '{ }'
curl -X DELETE "https://api.spenza.com/api/v1.1/webhooks/{id}" -H "Authorization: Bearer $SPENZA_TOKEN"
```

None of the read/list/get calls ever return `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:

```http theme={null}
X-Spenza-Signature: t=1719792000,v1=9f8b3c2e1a4d6f7b8c9e0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d
X-Spenza-Timestamp: 1719792000
```

`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:

```js theme={null}
const crypto = require("crypto");

function verifySpenzaSignature(rawBody, signatureHeader, timestampHeader, signingSecret) {
  const expected = crypto
    .createHmac("sha256", signingSecret)
    .update(`${timestampHeader}.${rawBody}`)
    .digest("hex");
  const provided = signatureHeader.split(",").find((p) => p.startsWith("v1="))?.slice(3);
  return provided && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided));
}
```

Compare against `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 via `retryPolicy`:

| Field                 | Purpose                                                 | Default       |
| --------------------- | ------------------------------------------------------- | ------------- |
| `maxAttempts`         | How many times Spenza retries a failed delivery (1–10). | `3`           |
| `backoffStrategy`     | `fixed`, `exponential`, or `linear`.                    | `exponential` |
| `initialDelaySeconds` | Delay before the first retry (1–60).                    | `5`           |
| `maxDelaySeconds`     | Ceiling on the delay between retries (60–3600).         | `300`         |

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:

```bash theme={null}
curl -X POST "https://api.spenza.com/api/v1.1/webhooks/{id}/test" \
  -H "Authorization: Bearer $SPENZA_TOKEN" -H "Content-Type: application/json" \
  -d '{ "eventType": "webhook.test" }'
```

This sends one attempt (no retries) to whichever URL is configured, in priority order `messageUrl` → `callbackMessageUrl` → `voiceUrl` → `callbackVoiceUrl`. 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

```bash theme={null}
curl "https://api.spenza.com/api/v1.1/webhook-deliveries?webhookId={id}&status=FAILED" \
  -H "Authorization: Bearer $SPENZA_TOKEN"
```

This never returns the delivered payload, response body, or raw error text — just status/timing metadata (`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:

```bash theme={null}
curl -X POST "https://api.spenza.com/api/v1.1/webhook-deliveries/{deliveryId}/redeliver" \
  -H "Authorization: Bearer $SPENZA_TOKEN"
```

This queues the redelivery and returns `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

<Warning>
  **No delivery/event ID for dedup yet.** There's no dedicated event ID field in the delivered payload itself. Retries (both automatic and manual redelivery) mean **a given event may be delivered more than once**; design your handler to be safe to run twice — for example, keying off whatever naturally-unique fields the payload contains (message SID, timestamp + from/to number) once you've inspected a real payload, and ignoring exact duplicates.
</Warning>

## Example payloads

<Note>
  **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.
</Note>

## Next steps

* **[Security & Best Practices](/security)** for handling the credentials in `authentication` and `signingSecret`.
* **API Reference → Webhooks** (sidebar) for the full field-level schema of the registration and delivery objects.
