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:
{
"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
- Create an endpoint that accepts a JSON
POSTand returns a 2xx within 10 seconds. - In the useSend dashboard, go to Webhooks and create a webhook. Enter your endpoint URL and select the events you want.
- Copy the signing secret and store it server-side, for example as
USESEND_WEBHOOK_SECRET. Keep the whole value, includingwhsec_. - Click the webhook and use Send Test to send a
webhook.testevent 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=.
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:
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 verifies the
X-UseSend-Signatureheader 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-jsSDK 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
toleranceto-1when a queue or gateway sits in front of your handler. - Deduplicate on
idto 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: trueand 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 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 asX-UseSend-Call) and skip repeats. - Make side effects such as suppressing an address safe to repeat.
How Hookdeck Can Help: Hookdeck's 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.
Acknowledge fast and process asynchronously
useSend waits 10 seconds. Verify, persist, return 200, and do the work afterwards. See why to process webhooks asynchronously.
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 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 for free and handle useSend webhooks reliably in minutes.