Gareth Wilson Gareth Wilson

Guide to useSend Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationDashboard: Webhooks, then create a webhook with a URL and events
Event selectionPer webhook
Event types13 email, 3 contact and 4 domain events, plus webhook.test
Signature headerX-UseSend-Signature, v1=<hex>
Timestamp headerX-UseSend-Timestamp, Unix milliseconds
Other headersX-UseSend-Event, X-UseSend-Call, X-UseSend-Retry (none are signed)
SchemeHMAC-SHA256, lowercase hex
Signed content<X-UseSend-Timestamp>.<raw body>
Secretwhsec_..., used whole as the key. Not Standard Webhooks
Timeout10 seconds
Retries6 attempts in all: immediately, then after about 5, 10, 20, 40 and 80 seconds
Auto-disableAfter 30 consecutive failed calls
DeduplicationThe body's id, the webhook call ID

Common events

EventFires when
email.queued / email.sentAn email is queued, then handed to the recipient's mail server
email.deliveredAn email is delivered
email.delivery_delayedDelivery is being retried
email.bouncedAn email bounces. data.bounce has the type, sub-type and message
email.complainedThe recipient marks the email as spam
email.rejected / email.failedAn email is rejected, or fails to send. data.failed.reason explains a failure
email.suppressedThe recipient is on the suppression list. data.suppression says why
email.opened / email.clickedThe recipient opens the email or clicks a link. data.click.url holds the link
contact.created / contact.updated / contact.deletedA contact changes
domain.created / domain.verified / domain.updated / domain.deletedA sending domain changes
webhook.testYou 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

  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=.

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-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 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 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.