Sendry Docs
API Reference

Webhooks

Receive real-time email delivery events via HTTP callbacks.

Webhooks push delivery events to your server as they happen. Each delivery creates an HTTP POST to your endpoint with a signed JSON payload.


Create webhook

POST /v1/webhooks

Requires full_access scope.

Request body

{
  "url": "https://api.acme.com/webhooks/sendry",
  "events": ["email.delivered", "email.bounced", "email.opened", "email.clicked"]
}
FieldTypeRequiredDescription
urlstringYesHTTPS endpoint to receive events. Must be a public URL.
eventsstring[]YesEvent types to subscribe to. At least one required.

Available event types:

EventFires when...
email.deliveredEmail successfully delivered to the recipient's server
email.bouncedEmail permanently or temporarily rejected
email.openedRecipient opened the email (requires tracking enabled)
email.clickedRecipient clicked a tracked link
email.complainedRecipient marked the email as spam
contact.createdA contact was created
contact.updatedA contact's properties were updated
contact.deletedA contact was deleted
domain.createdA sending domain was created
domain.updatedA sending domain's DNS state or config changed
domain.deletedA sending domain was deleted
automation.run.startedAn automation run transitioned from pending to in_progress
automation.run.completedAn automation run finished successfully
automation.run.failedAn automation run ended in error

Recipient attribution

email.sent, email.delivered, email.bounced and email.complained fire once per address, so a message sent to three people produces three of each, and to names the individual address.

email.opened and email.clicked are also per recipient. A message addressed to several people is delivered as one copy per recipient, each carrying that recipient's own tracking pixel and tracked links, while the To and Cc headers still list everyone, so recipients see exactly what they would have seen from a single send. An open therefore names the person who actually opened it.

The one exception is a message sent with open and click tracking turned off for the domain, where a single copy goes to everyone. No open or click event can be produced in that case. Events recorded before per-recipient delivery shipped may carry to: "*", meaning the message as a whole rather than one recipient.

Response

{
  "id": "wh_abc123",
  "url": "https://api.acme.com/webhooks/sendry",
  "events": ["email.delivered", "email.bounced", "email.opened", "email.clicked"],
  "secret": "whsec_abc123xyz...",
  "active": true,
  "created_at": "2025-03-12T09:00:00Z",
  "updated_at": "2025-03-12T09:00:00Z"
}

Save the secret immediately — it is only returned at creation time and is used to verify webhook signatures. If lost, delete and recreate the webhook.


Get webhook

GET /v1/webhooks/:id

Requires read_only scope. The signing secret is not included in get/list responses.


List webhooks

GET /v1/webhooks

Requires read_only scope.

Response

{
  "data": [
    {
      "id": "wh_abc123",
      "url": "https://api.acme.com/webhooks/sendry",
      "events": ["email.delivered", "email.bounced"],
      "active": true,
      "created_at": "2025-03-12T09:00:00Z",
      "updated_at": "2025-03-12T09:00:00Z"
    }
  ],
  "has_more": false,
  "next_cursor": null
}

Update webhook

PUT /v1/webhooks/:id

Requires full_access scope.

Request body

All fields optional:

{
  "url": "https://api.acme.com/webhooks/sendry-v2",
  "events": ["email.delivered", "email.bounced", "email.opened"],
  "active": false
}

Set active: false to pause delivery without deleting the webhook.


Delete webhook

DELETE /v1/webhooks/:id

Requires full_access scope.


Webhook payload

When an event fires, Sendry sends a POST request to your endpoint:

{
  "type": "email.delivered",
  "created_at": "2025-03-12T09:00:05Z",
  "data": {
    "email_id": "email_abc123",
    "to": "user@example.com",
    "timestamp": "2025-03-12T09:00:05Z"
  }
}

The event type is type. The recipient is to, and is a single address: a message to several people is delivered per recipient, so each event names one of them.

Bounce payloads include additional fields:

{
  "type": "email.bounced",
  "created_at": "2025-03-12T09:00:05Z",
  "data": {
    "email_id": "email_abc123",
    "to": "user@example.com",
    "bounce_type": "permanent",
    "timestamp": "2025-03-12T09:00:05Z"
  }
}

bounce_type is permanent or transient. Suppress the address on permanent; a transient bounce is a full mailbox or a temporary failure and the address is still good. The fuller classification (bounce_reason, bounce_description, bounce_action, retryable) is stored on the email event and returned by GET /v1/emails/:id, not carried in the webhook payload.

Automation run payloads

Automation lifecycle events share the same envelope and identify the run, parent automation, and contact:

{
  "type": "automation.run.started",
  "created_at": "2026-05-14T09:00:00Z",
  "data": {
    "run_id": "arun_abc123",
    "automation_id": "auto_xyz789",
    "contact_id": "cnt_456",
    "contact_email": "user@example.com",
    "trigger_event_id": "evt_111",
    "started_at": "2026-05-14T09:00:00Z"
  }
}

automation.run.completed and automation.run.failed include timing and (on failure) the reason:

{
  "type": "automation.run.completed",
  "created_at": "2026-05-14T09:05:12Z",
  "data": {
    "run_id": "arun_abc123",
    "automation_id": "auto_xyz789",
    "contact_id": "cnt_456",
    "contact_email": "user@example.com",
    "status": "completed",
    "started_at": "2026-05-14T09:00:00Z",
    "completed_at": "2026-05-14T09:05:12Z",
    "failed_at": null,
    "failure_reason": null
  }
}
{
  "type": "automation.run.failed",
  "created_at": "2026-05-14T09:03:44Z",
  "data": {
    "run_id": "arun_abc123",
    "automation_id": "auto_xyz789",
    "contact_id": "cnt_456",
    "contact_email": "user@example.com",
    "status": "failed",
    "started_at": "2026-05-14T09:00:00Z",
    "completed_at": null,
    "failed_at": "2026-05-14T09:03:44Z",
    "failure_reason": "send_email step failed: SES throttled"
  }
}

Signature verification

Every webhook request carries X-Sendry-Signature (a bare lowercase hex digest, no sha256= prefix), X-Sendry-Timestamp (unix seconds) and X-Sendry-Event. The signature is HMAC-SHA256 over "<timestamp>.<raw body>", so the timestamp is covered too and can be trusted for a replay window check.

See the Webhook Verification guide for full examples in TypeScript, Python, Go and PHP, including the timestamp tolerance check the snippet below leaves out.

Quick example:

import { createHmac, timingSafeEqual } from "node:crypto";

function verifyWebhook(
  rawBody: string,
  signature: string,
  timestamp: string,
  secret: string
): boolean {
  const hmac = createHmac("sha256", secret);
  hmac.update(`${timestamp}.${rawBody}`);
  const expected = hmac.digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  if (a.length !== b.length) return false;
  return timingSafeEqual(a, b);
}

// In your route handler:
const rawBody = await request.text();
const signature = request.headers.get("x-sendry-signature") ?? "";
const timestamp = request.headers.get("x-sendry-timestamp") ?? "";
if (!verifyWebhook(rawBody, signature, timestamp, process.env.SENDRY_WEBHOOK_SECRET!)) {
  return new Response("Forbidden", { status: 403 });
}

Retries

If your endpoint returns a non-2xx status, times out (10 seconds), or answers with a redirect, the delivery is retried. A delivery is attempted 5 times in total, with exponential backoff starting at 60 seconds: roughly 1, 2, 4 and 8 minutes after the first attempt. After the fifth failure the delivery goes to a dead-letter queue rather than being retried further.

Redirects are never followed, so a 3xx counts as a failure: register the final URL.

Every attempt is recorded and readable through List webhook deliveries, with the status code and attempt count. Replay a webhook delivery re-sends one.

Return 200 OK as quickly as possible, and do heavy processing asynchronously.


List webhook deliveries

GET /v1/webhooks/deliveries

Requires read_only scope. Returns delivery attempts for your webhooks, newest first.

Query parameters

ParameterTypeDefaultDescription
webhook_idstringFilter to a single webhook. Returns 404 not_found if it is not in the organization.
limitnumber50Results per page (max 100).
cursorstringPagination cursor from the previous response's next_cursor.

Response

{
  "data": [
    {
      "id": "whd_abc123",
      "webhook_id": "wh_abc123",
      "payload": {
        "type": "email.delivered",
        "data": {"email_id": "email_abc123", "to": "user@example.com"}
      },
      "status_code": 200,
      "attempts": 1,
      "delivered_at": "2025-03-12T09:00:05Z",
      "created_at": "2025-03-12T09:00:05Z"
    }
  ],
  "has_more": false,
  "next_cursor": null
}

status_code and delivered_at are null for deliveries that have not succeeded yet. attempts is the number of attempts made so far. Pagination is cursor-based; pass next_cursor as the cursor parameter to get the next page.


Replay a webhook delivery

POST /v1/webhooks/deliveries/:id/replay

Requires full_access scope. Creates a new delivery record and re-enqueues the original payload for delivery.

Response

{
  "id": "whd_abc124",
  "queued": true
}

id is the new delivery record, and queued reports whether it was successfully enqueued.


Plan limits

PlanWebhooks
Free2
Pro10
Business50
EnterpriseUnlimited

On this page