Sending

Webhooks

Push every delivery event to your backend in real time — signed, retried, and replay-safe.

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.

You can also create and manage webhooks from the dashboard — it surfaces the same signing secret and a live delivery log without writing any code.

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.

endpoint_urlstringRequired
Public HTTPS URL Sendara POSTs each event to. Must return a 2xx to acknowledge.
event_typesstring[]Optional
Event types to receive. Omit or pass an empty array to subscribe to every event type.

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"
}
The 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 }'
endpoint_urlstringOptional
New delivery URL. Omit to leave unchanged.
event_typesstring[]Optional
Replace the subscribed event-type filter. Omit to leave unchanged.
is_activebooleanOptional
Set false to pause delivery without deleting the subscription. Omit to leave unchanged.

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.
A single message typically emits several events over its lifetime (e.g. queuedsentdelivered 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"
}
event_idstringRequired
Unique event identifier (evt_…). Stable across retries — use it for idempotency.
event_typestringRequired
One of queued, sent, delivered, opened, bounced, complained, failed.
message_idstringRequired
The message this event belongs to (msg_…).
account_idstringRequired
Your account ID. Lets a single endpoint disambiguate environments.
payloadobjectRequired
The provider-specific event detail (recipient, bounce subtype, timestamps, etc.).
occurred_atstringRequired
RFC 3339 timestamp of when the event happened at the provider.
created_atstringRequired
RFC 3339 timestamp of when Sendara recorded the event.

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:

  1. 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.
  2. Concatenate the Sendara-Timestamp header, a literal ., and the raw body.
  3. Compute HMAC-SHA256 with your subscription's signing_secret as the key, hex-encode it, and compare it to Sendara-Signature using a constant-time comparison.
  4. Reject requests whose Sendara-Timestamp is too old (we recommend a five-minute tolerance) to defeat replay attacks.
The signature covers the exact bytes of the request body. Most frameworks parse JSON before your handler runs — configure a raw-body capture for this route (Express 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"
}
During the overlap, accept a delivery if it verifies against either secret. Once every node has the new secret, the old one can stop being checked at your next rotation.

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 exhausted and 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.
Acknowledge fast, work async. Do the minimum to validate and persist the event, return 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, then delivered events
  • bounced@… → a bounced event
  • complained@… → a complained event
# 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>" }
  }'
Need a public URL while developing locally? A tunnel (ngrok, Cloudflare Tunnel) plus a request inspector lets you watch the raw headers and body so you can confirm your signature verification against a real delivery.

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 missing endpoint_url on 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.