Sending

Webhooks

Push every delivery event to your backend in real time. Sendara signs each request and retries it, and each request is 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. You manage a subscription under /v1/webhooks with your API key. Sendara HMAC-signs every delivery, so you can prove that it came from us.

Why webhooks? We recommend webhooks to react to an email event. Instead of polling for status, Sendara notifies your backend the instant it records delivered, bounced, complained, opened, clicked, or delivery_delayed. That lets you suppress bad addresses, update a UI, or trigger a follow-up in real time. If you only need to check a single message's status on demand, polling GET /v1/messages?idempotency_key=… works too. See the message lifecycle for the full status and event model.
You can also create and manage webhooks from the dashboard. It surfaces a newly generated secret when you create or rotate a subscription, plus 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 once in the create response. Copy it then and store it like a password on your server. List, get, and update responses omit signing secrets.

create-webhook.sh
curl https://api.sendara.dev/v1/webhooks \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "endpoint_url": "https://acme.com/hooks/sendara",
    "event_types": ["delivered", "bounced", "complained"]
  }'
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.

Sendara returns the subscription with its generated secret:

{
  "id": "wh_3f9a1c2b7d8e4f60a1b2c3d4e5f60718",
  "account_id": "acc_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), and cannot be recovered with a later read. Rotate the secret if you lose it. 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. Sendara scopes every route 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 public subscription fields as the list response; it does not return a 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 worker, provider, and inbound signals into a small, stable set of event types. Subscribe to the ones your application cares about:

  • sent: Sendara handed the message to the upstream mail provider.
  • delivered: the receiving mail server accepted the message.
  • opened: the recipient opened the email (open tracking only).
  • clicked: the recipient clicked a tracked link in the email (click tracking only).
  • delivery_delayed: the receiving system temporarily delayed delivery. A later delivered or bounced event can still follow.
  • bounced: delivery failed permanently. On a hard bounce Sendara suppresses the recipient automatically.
  • complained: the recipient marked the message as spam. Sendara suppresses the recipient automatically.
  • failed: the provider rejected the message or a transient delivery attempt failed.
  • email.received: Sendara received and parsed an inbound email on a domain where inbound mail is enabled.
queued is the first message status, not an emitted event. Pre-dispatch suppressed and unsubscribed decisions appear in the message timeline, but they are not webhook subscription event types.
A single message typically emits several events over its lifetime (e.g. sentdeliveredopened clicked). Each one is a separate webhook delivery with its own event_id.

Event payload

Every webhook request body has the same envelope. The nested payload object preserves its source shape: production email events carry the Amazon SES event, sandbox events carry a synthetic detail, and inbound events carry the parsed message.

{
  "event_id": "evt_5c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f",
  "event_type": "bounced",
  "message_id": "msg_a1b2c3",
  "account_id": "acc_8a21",
  "payload": {
    "notificationType": "Bounce",
    "bounce": {
      "bounceType": "Permanent",
      "bounceSubType": "General",
      "bouncedRecipients": [
        {
          "emailAddress": "[email protected]",
          "action": "failed",
          "status": "5.1.1",
          "diagnosticCode": "smtp; 550 5.1.1 user unknown"
        }
      ]
    },
    "mail": {
      "messageId": "0100018fbb2c1d3a",
      "timestamp": "2026-06-14T10:00:12.481Z"
    }
  },
  "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 sent, delivered, opened, clicked, delivery_delayed, bounced, complained, failed, or email.received.
message_idstringRequired
The outbound message this event belongs to (msg_…). For email.received on a subscription, it carries the inbound email id (inb_…), which is the same value as payload.id. Only an inbound route forward leaves it as an empty string.
account_idstringRequired
Your account ID. Lets a single endpoint disambiguate environments.
payloadobjectRequired
Source-specific event detail, such as provider delivery data or a parsed inbound email.
occurred_atstringRequired
RFC 3339 timestamp of when the event happened at its source.
created_atstringRequired
RFC 3339 timestamp of when Sendara recorded the event.

The request also carries 4 headers that you use to verify it. Sendara reuses the same Sendara-Event-Id on every retry of one event, so you can deduplicate on event_id.

Content-Type: application/json
Sendara-Event-Id: evt_5c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f
Sendara-Event-Type: bounced
Sendara-Timestamp: 1781776812
Sendara-Signature: 9f1d4b…<hex>

Verify a signature

Anyone who learns your endpoint URL can POST to it. Verify the signature before you trust a request. The signature proves that the request came from Sendara, and that nobody altered it 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.
verify.ts
import { createHmac, timingSafeEqual } from "node:crypto";

// 5-minute tolerance window to reject replayed requests.
const TOLERANCE_SECONDS = 5 * 60;

export function verifySendaraWebhook(
  secret: string,
  headers: Record<string, string>,
  rawBody: string, // the EXACT bytes of the request body
): boolean {
  const timestamp = headers["sendara-timestamp"];
  const signature = headers["sendara-signature"];
  if (!timestamp || !signature) return false;

  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(signature, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}
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.

Rotate the signing secret

If a secret leaks, rotate it with POST /v1/webhooks/{id}/rotate-secret. Sendara returns the fresh signing_secret once and never returns the previous secret. Keep your stored old secret during the rollout, deploy the new one to every handler, and temporarily have your receiver accept signatures made with either stored value.

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>",
  "event_types": ["delivered", "bounced", "complained"],
  "is_active": true,
  "created_at": "2026-06-14T10:00:00Z"
}
An already in-flight request can still carry a signature made with the old secret. During the cutover, accept a delivery if it verifies against either value you stored. Once every node has the new secret and old in-flight requests have drained, stop checking the old value.

Retries & delivery guarantees

Your endpoint returns a 2xx status within 30 seconds to acknowledge a delivery. Sendara treats any other result as a failure. A non-2xx status, a timeout and a connection error all count as a failure. Sendara then retries with an exponential delay and jitter.

  • The base delay is 30 seconds. It doubles on each attempt (30s, 1m, 2m, 4m). Sendara applies ±25% jitter, so a fleet of retries does not arrive at one moment.
  • Sendara stops after 12 attempts, or when the cumulative window passes 24 hours. Sendara then marks the delivery exhausted and retries no more. The delivery stays in the delivery log (GET /v1/webhooks/{id}/deliveries).
  • Every attempt reuses the same Sendara-Event-Id, so your handler can dedupe and stay idempotent.

A delivery moves through these statuses:

  • pending: Sendara created the delivery. The first attempt is in flight.
  • succeeded: your endpoint returned a 2xx.
  • failed: the last attempt failed. Sendara scheduled another retry.
  • exhausted: Sendara made 12 attempts, or the 24-hour window closed. Sendara retries no more.
Acknowledge the delivery quickly, then do the work on a queue. Validate the event, persist it, and return 200. A slow handler causes a timeout. A timeout looks like a failure, and Sendara then retries. If you do not deduplicate on event_id, you process the event twice.

Delivery log

Inspect the payload that Sendara sent, and the result of each attempt, with GET /v1/webhooks/{id}/deliveries. It returns the most recent attempts first (default 50, override with ?limit=). Each attempt carries the response status and the exact payload that Sendara sent. Use it to debug an endpoint that did not respond, or a signature that did not match.

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"
    }
  ]
}

Test in the sandbox

Sandbox webhooks use the same signed envelope and delivery mechanics as production, but their nested payload is synthetic. Sendara sends no real email and bills you nothing. Create a subscription with a test key (sk_test_…), then trigger sends whose recipient address drives the simulated outcome:

  • delivered@…sentdelivered
  • bounced@…sentbounced
  • complained@…sentcomplained
# 1. Subscribe with a 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. This 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": "[email protected]" },
    "payload": { "subject": "Test", "body_html": "<p>Test</p>" }
  }'
You may need a public URL while you develop 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): your account holds no subscription with that ID.

At your endpoint, a 401 that you return for a bad signature marks the attempt as failed and schedules a retry. It gives a forger no information.

Common questions

Why did Sendara refuse my endpoint URL?
Sendara accepts an https URL whose host resolves to a publicly routable address. It refuses a loopback address, a private address, a link-local address and the cloud metadata address. While you develop locally, put a tunnel with a public https URL in front of your handler.
How long does Sendara retry a failed delivery?
The base delay is 30 seconds, and it doubles on each attempt, with jitter of plus or minus 25%. Sendara stops after 12 attempts, or when the cumulative window passes 24 hours. It then marks the delivery exhausted.
Does Sendara disable an endpoint that keeps failing?
No. Sendara marks the individual delivery exhausted and leaves the subscription active. Read the outcome of each attempt at GET /v1/webhooks/{id}/deliveries, then fix the endpoint. Sendara resumes on the next event.
Can Sendara deliver the same event twice?
Yes. Every attempt reuses the same Sendara-Event-Id, so deduplicate on it. Return a 2xx within 30 seconds, then do the real work on a queue. A slow handler causes a timeout, and a timeout counts as a failure.
How do I verify a signature?
Read the Sendara-Timestamp header, then compute HMAC-SHA256 over the string {timestamp}.{raw body}, keyed with the signing secret of the subscription, and hex-encode the digest. Compare it with the Sendara-Signature header in constant time. Read the raw body before any JSON parsing. Reject a timestamp older than five minutes.