---
title: "Webhooks"
description: "Receive signed event notifications at your own endpoint."
url: https://www.unitpost.com/docs/webhooks
section: Docs
updated: 2026-08-31
---
# 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 { verifyWebhook } from "unitpost";

// Pass the RAW request body — re-serializing breaks the signature.
const event = verifyWebhook({
  payload: rawBody,
  secret: process.env.UNITPOST_WEBHOOK_SECRET, // whsec_…
  headers: {
    "svix-id": headers["svix-id"],
    "svix-timestamp": headers["svix-timestamp"],
    "svix-signature": headers["svix-signature"],
  },
});

if (event.type === "email.bounced") {
  // …handle the bounce
}
```

**Python**

```python
from unitpost import verify_webhook

# Pass the RAW body — re-serializing breaks the signature.
event = verify_webhook(
    payload=raw_body,
    secret=os.environ["UNITPOST_WEBHOOK_SECRET"],  # whsec_…
    headers={
        "svix-id": headers["svix-id"],
        "svix-timestamp": headers["svix-timestamp"],
        "svix-signature": headers["svix-signature"],
    },
)

if event["type"] == "email.bounced":
    ...  # handle the bounce
```

**Ruby**

```ruby
require "unitpost"

# Pass the RAW body — re-serializing breaks the signature.
event = Unitpost::Webhooks.verify(
  payload: request.raw_post,
  secret: ENV["UNITPOST_WEBHOOK_SECRET"], # whsec_…
  headers: {
    "svix-id" => request.headers["svix-id"],
    "svix-timestamp" => request.headers["svix-timestamp"],
    "svix-signature" => request.headers["svix-signature"],
  }
)

handle_bounce(event) if event["type"] == "email.bounced"
```

**PHP**

```php
require 'vendor/autoload.php';

use Unitpost\WebhookVerificationError;
use function Unitpost\verifyWebhook;

// Pass the RAW body — re-serializing breaks the signature.
try {
    $event = verifyWebhook(
        $payload,
        getenv('UNITPOST_WEBHOOK_SECRET'), // whsec_…
        [
            'svix-id' => $_SERVER['HTTP_SVIX_ID'] ?? null,
            'svix-timestamp' => $_SERVER['HTTP_SVIX_TIMESTAMP'] ?? null,
            'svix-signature' => $_SERVER['HTTP_SVIX_SIGNATURE'] ?? null,
        ],
    );
} catch (WebhookVerificationError $e) {
    http_response_code(401);
}
```

**Laravel**

```php
use Illuminate\Http\Request;
use Unitpost\WebhookVerificationError;
use function Unitpost\verifyWebhook;

public function __invoke(Request $request)
{
    // Pass the RAW body — re-serializing breaks the signature.
    try {
        $event = verifyWebhook(
            $request->getContent(),
            config('services.unitpost.webhook_secret'), // whsec_…
            [
                'svix-id' => $request->header('svix-id'),
                'svix-timestamp' => $request->header('svix-timestamp'),
                'svix-signature' => $request->header('svix-signature'),
            ],
        );
    } catch (WebhookVerificationError $e) {
        abort(401);
    }
}
```

**Go**

```go
import (
    "github.com/unitpostcom/unitpost-go"
)

// Pass the RAW body — re-serializing breaks the signature.
event, err := unitpost.VerifyWebhook(string(rawBody), secret, r.Header, 300)
if err != nil {
    http.Error(w, "invalid signature", http.StatusUnauthorized)
    return
}

if event["type"] == "email.bounced" {
    // …handle the bounce
}
```

**Java**

```java
import com.unitpost.WebhookVerificationError;
import com.unitpost.WebhooksVerify;

// Pass the RAW body — re-serializing breaks the signature.
try {
    Object event = WebhooksVerify.verifyWebhook(
        payload, // the raw request body string
        secret,  // whsec_…
        headers  // Map<String, String> of svix-id / svix-timestamp / svix-signature
    );
} catch (WebhookVerificationError e) {
    // 401 — bad or stale signature
}
```

**Rust**

```rust
use std::collections::HashMap;
use unitpost::{verify_webhook, WebhookVerificationError};

// Pass the RAW body — re-serializing breaks the signature.
match verify_webhook(&payload, &secret, &headers, 300) {
    Ok(event) => {
        if event["type"] == "email.bounced" {
            // …handle the bounce
        }
    }
    Err(WebhookVerificationError { .. }) => {
        // 401 — bad or stale signature
    }
}
```

**.NET**

```csharp
using Unitpost;

// Pass the RAW body — re-serializing breaks the signature.
try
{
    var evt = WebhooksVerify.Verify(
        payload, // the raw request body string
        secret,  // whsec_…
        headers  // Dictionary<string, string> of svix-id / svix-timestamp / svix-signature
    );
}
catch (WebhookVerificationException)
{
    // 401 — bad or stale signature
}
```

> **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 SDK for your language — or call the API directly.
- [Quickstart](https://www.unitpost.com/docs/quickstart): Create a key, verify a domain, and send your first email.
