---
title: "Webhooks"
description: "Receive signed event notifications at your own endpoint."
url: https://www.unitpost.com/docs/webhooks
section: Docs
updated: 2026-07-23
---
# Webhooks

## Webhooks

> Receive signed event notifications at your own endpoint.

Webhooks let your application react to events as they happen — update a record on delivery, or sync a contact. Register an endpoint under Developers → Webhooks (or via the API), choose events, and we POST a signed JSON payload to your URL.

> **Endpoint URLs must be public HTTPS:** We only deliver to https:// URLs that resolve to a public address. http:// endpoints and URLs that resolve to private, loopback, or link-local ranges (localhost, 127.0.0.1, 10.0.0.0/8, 169.254.0.0/16, etc.) are rejected when you save the endpoint — a deliberate SSRF guard. Develop against a tunnel (ngrok, Cloudflare Tunnel) that gives you a public HTTPS URL.

### Event types

Events are namespaced by category as category.event. Subscribe to individual events, or to a whole category with a wildcard like email.* — handy when you want everything in a category without re-editing the endpoint as we add events.

- email.sent, email.delivered, email.delivery_delayed, email.bounced, email.complained, email.opened, email.clicked, email.failed, email.scheduled, email.suppressed
- domain.created, domain.updated, domain.deleted
- contact.created, contact.updated, contact.deleted
- segment.joined, segment.left
- topic.subscribed, topic.unsubscribed

### Payload

Every delivery is the same JSON envelope: a type (the dotted event name), an ISO created_at timestamp, and a data object. The envelope and signing are identical for every event — only data varies by event. We send three headers you use to verify it (the scheme is compatible with Svix's, so existing Svix libraries work): svix-id, svix-timestamp, and svix-signature.

```bash
POST /your-endpoint HTTP/1.1
svix-id: msg_2abc...
svix-timestamp: 1735689600
svix-signature: v1,g0hM9SsE...
content-type: application/json

{
  "type": "email.delivered",
  "created_at": "2026-01-01T00:00:00.000Z",
  "data": { "email_id": "email_2Wxyz123Example", "to": "delivered@example.com" }
}
```

> **Field conventions:** Field names are snake_case. Email events identify the message as email_id; domain and contact events use the resource's own id. The type is only on the envelope — it is never duplicated inside data. Optional fields are always present (as null) rather than omitted, so you can rely on a stable shape.

### Email event data

email.sent, email.delivered, email.delivery_delayed, email.bounced, email.complained, email.opened, email.clicked and email.failed share the same shape: the message id and its recipient(s). email.scheduled and email.suppressed add a few fields (below).

```bash
// email.sent | email.delivered | email.bounced | email.opened | ...
{
  "type": "email.delivered",
  "created_at": "2026-01-01T00:00:00.000Z",
  "data": {
    "email_id": "email_2Wxyz123Example",
    "to": "delivered@example.com"
  }
}

// email.scheduled — adds the deferred-send context
{
  "type": "email.scheduled",
  "created_at": "2026-01-01T00:00:00.000Z",
  "data": {
    "email_id": "email_2Wxyz123Example",
    "to": "marie@example.com",
    "from": "hello@example.com",
    "subject": "Your weekly digest",
    "scheduled_at": "2026-01-01T09:00:00.000Z",
    "batch_id": null
  }
}

// email.suppressed — who was skipped and why
{
  "type": "email.suppressed",
  "created_at": "2026-01-01T00:00:00.000Z",
  "data": {
    "email_id": "email_2Wxyz123Example",
    "to": "bounced@example.com",
    "suppressed": ["bounced@example.com"],
    "reasons": [{ "email": "bounced@example.com", "reason": "BOUNCE" }],
    "full": true
  }
}
```

### Domain event data

domain.created, domain.updated and domain.deleted carry the domain id, name and status. previous_status is populated only on domain.updated (a status transition, e.g. PENDING → VERIFIED); it's null otherwise.

```bash
{
  "type": "domain.updated",
  "created_at": "2026-01-01T00:00:00.000Z",
  "data": {
    "id": "dom_2Wxyz123Example",
    "name": "example.com",
    "status": "VERIFIED",
    "previous_status": "PENDING"
  }
}
```

### Contact event data

contact.created, contact.updated and contact.deleted carry the contact id, email, name fields (null when unset) and the marketing unsubscribed flag.

```bash
{
  "type": "contact.updated",
  "created_at": "2026-01-01T00:00:00.000Z",
  "data": {
    "id": "con_2Wxyz123Example",
    "email": "marie@example.com",
    "first_name": "Marie",
    "last_name": "Curie",
    "unsubscribed": false
  }
}
```

### Verifying signatures

Every webhook is signed with a per-endpoint secret (whsec_...) shown once when you create the endpoint or rotate its secret. Verify the signature before trusting any payload. The signature is an HMAC-SHA256 over `${svix-id}.${svix-timestamp}.${raw_body}`, base64-encoded. Always use the RAW request body — re-serializing the parsed JSON changes the bytes and breaks the signature.

**Node.js**

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

// secret = "whsec_..." from the dashboard (shown once)
export function verify(rawBody, headers, secret) {
  const id = headers["svix-id"];
  const ts = headers["svix-timestamp"];
  const sigHeader = headers["svix-signature"]; // "v1,<base64> ..."

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64url");
  const expected = createHmac("sha256", key)
    .update(`${id}.${ts}.${rawBody}`)
    .digest("base64");

  return sigHeader.split(" ").some((part) => {
    const sig = part.split(",", 2)[1];
    if (!sig || sig.length !== expected.length) return false;
    return timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  });
}
```

**Python**

```python
import base64, hashlib, hmac

def verify(raw_body: bytes, headers, secret: str) -> bool:
    msg_id = headers["svix-id"]
    ts = headers["svix-timestamp"]
    sig_header = headers["svix-signature"]  # "v1,<base64> ..."

    key = base64.urlsafe_b64decode(secret.removeprefix("whsec_") + "==")
    signed = f"{msg_id}.{ts}.".encode() + raw_body
    expected = base64.b64encode(
        hmac.new(key, signed, hashlib.sha256).digest()
    ).decode()

    return any(
        hmac.compare_digest(part.split(",", 1)[1], expected)
        for part in sig_header.split(" ")
        if "," in part
    )
```

> **Reject stale timestamps:** The svix-timestamp is part of the signed content. After verifying the signature, reject deliveries whose timestamp is outside a tolerance window (e.g. ±5 minutes) to defeat replay attacks.

> **Test before you ship:** Use the Send test action on any endpoint (dashboard or API) to fire a sample signed event to confirm your verification works end to end. The test payload uses the real event shape.

### Rotating the signing secret

The signing secret is shown once. If you lose it, rotate the secret from the ⋯ menu to generate a fresh one. Rotation takes effect immediately.

### Retries & failures

If your endpoint doesn't return a 2xx, we retry with backoff up to 5 attempts. Acknowledge with a 2xx quickly, then do slower work asynchronously. Permanent errors (4xx) are not retried. Transient failures (5xx, timeouts) are retried.

Each endpoint shows a live status: Enabled, Failing, or Disabled. After sustained failures we disable an endpoint to stop sending to a dead URL. Re-enable it to resume delivery.

> **Event retention:** Delivery attempts and their payloads are retained for 30 days, then pruned. Persist anything you need to keep when you receive it.

> **Permissions:** Viewing webhooks requires the webhooks:read capability; creating, updating, and deleting require webhooks:manage. API keys can hold these like any other capability.

## Related

- [Inbound routing](https://www.unitpost.com/docs/inbound): Receive emails at your domain and get clean JSON POSTed to your webhook.
- [Install the SDK](https://www.unitpost.com/docs/install): Add the official Node, Python, or Ruby SDK — or call the API directly.
- [Quickstart](https://www.unitpost.com/docs/quickstart): Create a key, verify a domain, and send your first email.
