A webhook subscription tells Sendara to POST every matching event to a URL you control, the moment it happens. Instead of polling GET /v1/messages, your systems react as mail is delivered, bounced, or marked as spam. Subscriptions are managed under /v1/webhooks with your API key, and every delivery is HMAC-signed so you can prove it came from us.
Create a subscription
POST an endpoint_url and the list of event_types you want. Sendara generates a per-endpoint signing_secret and returns it in the response. The secret is shown in full every time you read the subscription, but treat it like a password and store it server-side — it is the only thing that lets you verify deliveries.
The subscription is returned with its generated secret:
{
"id": "wh_3f9a1c2b7d8e4f60a1b2c3d4e5f60718",
"account_id": "acct_8a21",
"endpoint_url": "https://acme.com/hooks/sendara",
"signing_secret": "4f3a39ab8c2d4e6f1b0a9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f",
"event_types": ["delivered", "bounced", "complained"],
"is_active": true,
"created_at": "2026-06-14T10:00:00Z"
}signing_secret is 32 random bytes, hex-encoded (64 characters). An empty event_types array means all event types — explicit is better, so list the ones you actually handle.Manage subscriptions
List, read, update, pause, and delete subscriptions with the standard REST verbs. All routes are scoped to your account, so a subscription ID from another account returns 404 not_found.
List subscriptions
curl https://api.sendara.dev/v1/webhooks \
-H "Authorization: Bearer sk_live_xxx"{
"webhooks": [
{ "id": "wh_3f9a…0718", "endpoint_url": "https://acme.com/hooks/sendara",
"event_types": ["delivered", "bounced", "complained"],
"is_active": true, "created_at": "2026-06-14T10:00:00Z" }
]
}Read a single subscription with GET /v1/webhooks/{id}; it returns the same object, including the current signing_secret.
Update a subscription
PUT /v1/webhooks/{id} patches only the fields you send. Flip is_active to false to pause deliveries while you debug an endpoint, then back to true to resume — no need to delete and recreate.
curl -X PUT https://api.sendara.dev/v1/webhooks/wh_3f9a…0718 \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "is_active": false }'Delete a subscription
DELETE /v1/webhooks/{id} permanently removes the subscription. In-flight retries for past events stop.
curl -X DELETE https://api.sendara.dev/v1/webhooks/wh_3f9a…0718 \
-H "Authorization: Bearer sk_live_xxx"{ "message": "Webhook subscription deleted" }Event types
Sendara normalizes every provider signal into a small, stable set of canonical event types. Subscribe to the ones your application cares about:
- queued — the message was accepted by Sendara and is waiting to be handed to the provider.
- sent — the message was handed off to the upstream mail provider.
- delivered — the receiving mail server accepted the message.
- opened — the recipient opened the email (open tracking only).
- bounced — delivery failed permanently. On a hard bounce the recipient is automatically suppressed.
- complained — the recipient marked the message as spam. The recipient is automatically suppressed.
- failed — the provider rejected the message or a transient delivery attempt failed.
queued → sent → delivered → opened). Each one is a separate webhook delivery with its own event_id.Event payload
Every webhook request body has the same envelope. The channel- and provider-specific detail lives inside the nested payload object.
{
"event_id": "evt_5c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f",
"event_type": "bounced",
"message_id": "msg_a1b2c3",
"account_id": "acct_8a21",
"payload": {
"recipient": "user@acme.com",
"bounce_type": "Permanent",
"diagnostic": "smtp; 550 5.1.1 user unknown"
},
"occurred_at": "2026-06-14T10:00:12.481Z",
"created_at": "2026-06-14T10:00:12.642Z"
}The request also carries four headers you'll use to verify it. The same Sendara-Event-Id is reused across every retry of the same event, which is what makes deduplication on event_id reliable.
Content-Type: application/json
Sendara-Event-Id: evt_5c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f
Sendara-Event-Type: bounced
Sendara-Timestamp: 1781776812
Sendara-Signature: 9f1d4b…<hex>Verifying signatures
Anyone who learns your endpoint URL can POST to it. Before you trust a request, verify the signature so you know it came from Sendara and wasn't tampered with in transit.
The scheme is a single HMAC-SHA256:
signed_payload = Sendara-Timestamp + "." + raw_request_body
Sendara-Signature = hex( HMAC_SHA256(signing_secret, signed_payload) )To verify, recompute it and compare in constant time:
- Read the raw request body as bytes, before any JSON parsing. Re-serializing a parsed object changes whitespace and key order and will break the signature.
- Concatenate the
Sendara-Timestampheader, a literal., and the raw body. - Compute
HMAC-SHA256with your subscription'ssigning_secretas the key, hex-encode it, and compare it toSendara-Signatureusing a constant-time comparison. - Reject requests whose
Sendara-Timestampis too old (we recommend a five-minute tolerance) to defeat replay attacks.
express.raw, Flask request.get_data(), Next.js Route Handlers await req.text()) or verification will always fail.Rotating the signing secret
If a secret leaks, rotate it with POST /v1/webhooks/{id}/rotate-secret. Sendara issues a fresh secret and keeps the old one as previous_signing_secret for a cutover window: signatures made with either secret verifyuntil you rotate again. Deploy the new secret to your handler, confirm deliveries verify, then you're done — there is no gap where valid events are rejected.
curl -X POST https://api.sendara.dev/v1/webhooks/wh_3f9a…0718/rotate-secret \
-H "Authorization: Bearer sk_live_xxx"{
"id": "wh_3f9a…0718",
"endpoint_url": "https://acme.com/hooks/sendara",
"signing_secret": "<new 64-char hex secret>",
"previous_signing_secret": "<old 64-char hex secret>",
"event_types": ["delivered", "bounced", "complained"],
"is_active": true,
"created_at": "2026-06-14T10:00:00Z"
}Retries & delivery guarantees
Your endpoint must return a 2xx within 30 seconds to acknowledge a delivery. Anything else — a non-2xx status, a timeout, or a connection error — is treated as a failure and retried with exponential backoff and jitter:
- Base delay 30 seconds, doubling each attempt (30s → 1m → 2m → 4m → …), with ±25% jitterso retries don't thunder.
- Retries continue until the cumulative window exceeds 24 hours, after which the delivery is marked
exhaustedand parked in a dead-letter queue. - Every attempt reuses the same
Sendara-Event-Id, so your handler can dedupe and stay idempotent.
A delivery moves through these statuses:
pending— created, first attempt in flight.succeeded— your endpoint returned a 2xx.failed— last attempt failed; another retry is scheduled.exhausted— the 24-hour window elapsed; no further retries.
200, and process it on a queue. Slow handlers cause timeouts, which look like failures and trigger unnecessary retries (and duplicate processing if you aren't deduping on event_id).Delivery log
Inspect what was sent and how each attempt fared with GET /v1/webhooks/{id}/deliveries. It returns the most recent attempts first (default 50, override with ?limit=), including the response status and the exact payload we POSTed — invaluable when an endpoint was down or a signature mismatch needs debugging.
curl "https://api.sendara.dev/v1/webhooks/wh_3f9a…0718/deliveries?limit=20" \
-H "Authorization: Bearer sk_live_xxx"{
"deliveries": [
{
"id": "whd_7a2b…",
"subscription_id": "wh_3f9a…0718",
"event_id": "evt_5c1d…4e5f",
"event_type": "bounced",
"status": "succeeded",
"attempt_count": 1,
"response_status": 200,
"created_at": "2026-06-14T10:00:12Z"
},
{
"id": "whd_3c8d…",
"subscription_id": "wh_3f9a…0718",
"event_id": "evt_9b0a…1c2d",
"event_type": "delivered",
"status": "failed",
"attempt_count": 3,
"response_status": 503,
"next_retry_at": "2026-06-14T10:08:31Z",
"created_at": "2026-06-14T10:00:09Z"
}
]
}Testing in the sandbox
Webhooks fire in the sandbox exactly as they do in production, with no real email sent and no billing. Create a subscription with a test key (sk_test_…), then trigger sends whose recipient address drives the simulated outcome:
delivered@…→queued,sent, thendeliveredeventsbounced@…→ abouncedeventcomplained@…→ acomplainedevent
# 1. Subscribe (test key) — point it at any URL you can inspect.
curl https://api.sendara.dev/v1/webhooks \
-H "Authorization: Bearer sk_test_xxx" \
-H "Content-Type: application/json" \
-d '{ "endpoint_url": "https://acme.com/hooks/sendara",
"event_types": ["delivered", "bounced", "complained"] }'
# 2. Trigger a bounce — drives the webhook without sending real mail.
curl https://api.sendara.dev/v1/send \
-H "Authorization: Bearer sk_test_xxx" \
-H "Content-Type: application/json" \
-d '{
"channel": "email",
"idempotency_key": "wh_test_1",
"destination": { "email": "bounced@example.com" },
"payload": { "subject": "Test", "body_html": "<p>Test</p>" }
}'Errors
Subscription-management endpoints return the standard error envelope — { "error": { "code": "…", "message": "…" } } — with these codes:
unauthorized(401) — missing or invalid API key.invalid_request(400) — malformed body, or a missingendpoint_urlon create.not_found(404) — the subscription ID doesn't exist on your account.
On the receiving side, the 401 your handler returns for a bad signature simply marks that attempt as failed and schedules a retry — it never reveals anything to a forger.