# Guide to useSend Webhooks: Features and Best Practices

useSend webhooks tell your application what happened to the email it sent: whether a message was delivered, bounced or marked as spam, and whether the recipient opened it or clicked a link. useSend, the open-source email platform that used to be called Unsend, also sends events when contacts and sending domains change.

This guide covers how useSend webhooks are created and signed, the events they carry, how to verify a delivery with or without the SDK, and the practices that keep a useSend receiver reliable.

## What are useSend webhooks?

A useSend webhook is an endpoint URL you add in the dashboard, with the events you want it to receive. When one of those events happens, useSend sends an HTTP `POST` with a JSON body. Your endpoint has 10 seconds to return a 2xx.

Each webhook has a signing secret that starts with `whsec_`. That prefix looks like Standard Webhooks, but the scheme is different. useSend uses the whole secret string, `whsec_` included, as a plain HMAC key, and signs the `X-UseSend-Timestamp` value, a dot and the raw body. The result goes in `X-UseSend-Signature` as `v1=` followed by lowercase hex. The timestamp is in milliseconds.

## useSend webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard: **Webhooks**, then create a webhook with a URL and events |
| Event selection | Per webhook |
| Event types | 13 email, 3 contact and 4 domain events, plus `webhook.test` |
| Signature header | `X-UseSend-Signature`, `v1=<hex>` |
| Timestamp header | `X-UseSend-Timestamp`, Unix milliseconds |
| Other headers | `X-UseSend-Event`, `X-UseSend-Call`, `X-UseSend-Retry` (none are signed) |
| Scheme | HMAC-SHA256, lowercase hex |
| Signed content | `<X-UseSend-Timestamp>.<raw body>` |
| Secret | `whsec_...`, used whole as the key. Not Standard Webhooks |
| Timeout | 10 seconds |
| Retries | 6 attempts in all: immediately, then after about 5, 10, 20, 40 and 80 seconds |
| Auto-disable | After 30 consecutive failed calls |
| Deduplication | The body's `id`, the webhook call ID |

## Common events

| Event | Fires when |
| --- | --- |
| `email.queued` / `email.sent` | An email is queued, then handed to the recipient's mail server |
| `email.delivered` | An email is delivered |
| `email.delivery_delayed` | Delivery is being retried |
| `email.bounced` | An email bounces. `data.bounce` has the type, sub-type and message |
| `email.complained` | The recipient marks the email as spam |
| `email.rejected` / `email.failed` | An email is rejected, or fails to send. `data.failed.reason` explains a failure |
| `email.suppressed` | The recipient is on the suppression list. `data.suppression` says why |
| `email.opened` / `email.clicked` | The recipient opens the email or clicks a link. `data.click.url` holds the link |
| `contact.created` / `contact.updated` / `contact.deleted` | A contact changes |
| `domain.created` / `domain.verified` / `domain.updated` / `domain.deleted` | A sending domain changes |
| `webhook.test` | You click **Send Test** on the webhook in the dashboard |

useSend also documents `email.rendering_failure` and `email.cancelled`. Every event uses the same envelope:

```json
{
  "id": "call_abc123",
  "type": "email.delivered",
  "version": "2026-01-18",
  "createdAt": "2024-01-15T10:30:00.000Z",
  "teamId": 123,
  "data": {
    "id": "email_123",
    "status": "DELIVERED",
    "from": "sender@example.com",
    "to": ["recipient@example.com"],
    "subject": "Welcome!",
    "occurredAt": "2024-01-15T10:30:00Z"
  },
  "attempt": 1
}

```

`id` identifies the webhook call, `version` is the payload format, and `attempt` counts from 1 to 6. Email events can also carry `campaignId`, `contactId`, `templateId` and the `metadata` you attached when sending, which is the simplest way to tie an event back to a record in your own system.

## Setting up useSend webhooks

1. Create an endpoint that accepts a JSON `POST` and returns a 2xx within 10 seconds.
2. In the useSend dashboard, go to **Webhooks** and create a webhook. Enter your endpoint URL and select the events you want.
3. Copy the signing secret and store it server-side, for example as `USESEND_WEBHOOK_SECRET`. Keep the whole value, including `whsec_`.
4. Click the webhook and use **Send Test** to send a `webhook.test` event to your endpoint.

If you receive useSend webhooks through Hookdeck, create a source with the useSend type and use its URL as the webhook URL. Paste the whole secret, including the `whsec_` prefix. useSend uses it as a plain HMAC key. The source accepts `POST` only, which is the method useSend uses.

## Securing useSend webhooks

Read `X-UseSend-Signature` and `X-UseSend-Timestamp`. Build the signed bytes from the timestamp exactly as received, a literal `.`, and the raw request body. Compute an HMAC-SHA256 of them with the whole secret as a UTF-8 key, hex-encode it, and compare it in constant time with the value after `v1=`.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyUseSendSignature(rawBody, signatureHeader, timestampHeader, secret, toleranceSeconds = 0) {
  if (!signatureHeader || !timestampHeader || !secret) return false;

  const value = signatureHeader.trim();
  const signature = value.startsWith('v1=') ? value.slice(3) : value;
  if (!/^[0-9a-fA-F]{64}$/.test(signature)) return false; // check before Buffer.from(x, 'hex')

  // Optional replay window, off by default. The timestamp is in MILLISECONDS.
  if (toleranceSeconds > 0) {
    if (!/^\d+$/.test(timestampHeader)) return false;
    if (Math.abs(Date.now() - Number(timestampHeader)) > toleranceSeconds * 1000) return false;
  }

  // The whole secret, whsec_ included, is the key. Nothing is base64-decoded.
  const expected = crypto
    .createHmac('sha256', secret)
    .update(Buffer.concat([Buffer.from(`${timestampHeader}.`, 'utf8'), rawBody]))
    .digest();
  const received = Buffer.from(signature, 'hex');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

app.post('/webhooks/usesend', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.USESEND_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  const valid = verifyUseSendSignature(
    req.body,
    req.get('x-usesend-signature'),
    req.get('x-usesend-timestamp'),
    secret
  );
  if (!valid) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.id, queue the work, and acknowledge within 10 seconds.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import re
import time

def verify_usesend_signature(raw_body: bytes, signature_header, timestamp_header, secret: str,
                             tolerance_seconds: int = 0) -> bool:
    if not signature_header or not timestamp_header or not secret:
        return False
    value = signature_header.strip()
    signature = value[3:] if value.startswith("v1=") else value
    if not re.fullmatch(r"[0-9a-fA-F]{64}", signature):
        return False
    # Optional replay window, off by default. The timestamp is in milliseconds.
    if tolerance_seconds > 0:
        if not (timestamp_header.isascii() and timestamp_header.isdigit()):
            return False
        if abs(time.time() * 1000 - int(timestamp_header)) > tolerance_seconds * 1000:
            return False
    # The whole secret, whsec_ included, is the key.
    signed = timestamp_header.encode("utf-8") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature.lower().encode("utf-8"), expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_usesend_signature(raw_body, request.headers.get("x-usesend-signature"),
#                          request.headers.get("x-usesend-timestamp"),
#                          os.environ["USESEND_WEBHOOK_SECRET"])

```

The `usesend-js` SDK does the same with `webhooks.verify()` or `webhooks.constructEvent()`. It needs the raw body too, and it rejects any timestamp more than 5 minutes from the current time unless you change its `tolerance` option. That option is in milliseconds, and only a negative value turns the check off: `0` would reject almost every request.

The samples above leave the timestamp check off unless you pass a tolerance. Hookdeck applies no timestamp tolerance, and its retries and replays resend useSend's original `X-UseSend-Timestamp`, so a 5-minute window in your own code would reject a legitimate retry or replay. If you verify with the SDK behind Hookdeck, pass `tolerance: -1` and rely on deduplication instead.

> **Make useSend webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-UseSend-Signature` header over the timestamp and raw body, stores every email event, and delivers it to your service with retries you control.

## useSend webhook limitations and pain points

### A `whsec_` secret that isn't Standard Webhooks

**The Problem:** Verification fails on every request, even though the secret is right.

**Why It Happens:** Secrets starting with `whsec_` usually mean Standard Webhooks, where libraries strip the prefix and base64-decode the rest. useSend uses the whole string as the key, signs `<timestamp>.<body>` rather than `<id>.<timestamp>.<body>`, and hex-encodes the result. The `svix` and `standardwebhooks` libraries can't verify it.

**Workarounds:**

* Use the whole secret, prefix included, as a UTF-8 HMAC key.
* Verify with the `usesend-js` SDK or the code above, not a Standard Webhooks library.

**How Hookdeck Can Help:** Hookdeck's useSend source applies useSend's scheme to the secret you paste. Hookdeck answers useSend with a `200` either way, then records a request that fails the check as failed verification, so it never reaches your destination.

### The SDK's 5-minute window rejects late deliveries

**The Problem:** Requests that verified earlier start failing when they are retried or replayed.

**Why It Happens:** The SDK compares `X-UseSend-Timestamp` with the current time and rejects anything more than 5 minutes away. A queued, retried or replayed request keeps its original timestamp.

**Workarounds:**

* Set the SDK's `tolerance` to `-1` when a queue or gateway sits in front of your handler.
* Deduplicate on `id` to cover what the timestamp check would have caught.

**How Hookdeck Can Help:** Hookdeck verifies the signature when the request arrives and applies no timestamp tolerance, so a delayed delivery still reaches your service with its original, valid signature.

### Six attempts in under three minutes, then auto-disable

**The Problem:** A short outage loses events, and a longer one switches the webhook off.

**Why It Happens:** useSend makes 6 attempts over about 155 seconds, then marks the call as failed. After 30 consecutive failed calls it disables the webhook until you re-enable it in the dashboard.

**Workarounds:**

* Acknowledge fast and do the work asynchronously, so slow processing doesn't count as a failure.
* Alert on `X-UseSend-Retry: true` and check the webhook's status in the dashboard after an incident.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as soon as it arrives, so useSend's attempts succeed and the webhook stays enabled while your service is down. Hookdeck holds the events and delivers them with [retries](/docs/retries) on a schedule you configure.

### Duplicate deliveries

**The Problem:** The same event is processed twice.

**Why It Happens:** useSend says that "in rare cases, the same event may be delivered more than once".

**Workarounds:**

* Store each processed `id` (also sent as `X-UseSend-Call`) and skip repeats.
* Make side effects such as suppressing an address safe to repeat.

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/deduplication) can drop repeated deliveries before they reach your handler.

## Best practices

### Verify against the raw body

The signature covers the bytes useSend sent. In Express, use `express.raw({ type: '*/*' })`. In Next.js, use `await request.text()` or `Buffer.from(await request.arrayBuffer())`. In FastAPI, use `await request.body()`.

### Subscribe only to the events you handle

Opens and clicks can outnumber every other event. Select only the event types you act on when you create the webhook, and use separate webhooks if different services need different events.

### Act on bounces and complaints

`email.bounced` with a `Permanent` bounce type and `email.complained` both mean you should stop mailing that address. Update your own records from these events so your other systems don't keep sending.

### Make handlers idempotent

Deduplicate on the body's `id`. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

useSend waits 10 seconds. Verify, persist, return `200`, and do the work afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

useSend reports delivery, engagement, contact and domain changes through signed webhooks. Verify `X-UseSend-Signature` as a hex HMAC-SHA256 of the millisecond timestamp, a dot and the raw body, keyed with the whole `whsec_` secret, deduplicate on `id`, and acknowledge within 10 seconds.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies every useSend signature, and holds each event until your service has processed it, so useSend's short retry schedule doesn't cost you events.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle useSend webhooks reliably in minutes.