Webhooks
Receive signed, real-time delivery events from useSend at your own HTTPS endpoint.
Webhooks let useSend push events to your application as they happen — email delivered, bounced, opened, a contact created, a domain verified — instead of you polling the API. useSend delivers each event as a signed HTTP POST to an endpoint you host.
Webhook endpoints are created and managed in the dashboard (https://mail.everjust.app), not through the public REST API. There is no POST /v1/webhooks endpoint — webhook CRUD is dashboard/tRPC only. Each endpoint belongs to your Team (tenant) and is issued a signing secret starting with whsec_.
Setup
In the dashboard, open your EverJust Team → Webhooks, and add an endpoint URL (must be publicly reachable HTTPS, e.g. https://app.everjust.app/webhooks/usesend).
Select which event types the endpoint should receive.
Copy the signing secret (whsec_…). Store it as a server-side secret — you need it to verify signatures. Treat it like an API key; never expose it client-side.
Use the dashboard's Send test action to deliver a webhook.test event and confirm your endpoint returns 2xx.
Event types
Delivery contract
- Delivery is an HTTP
POSTwith a JSON body. - Your endpoint must respond with a
2xxstatus within 10 seconds. Anything else (non-2xx, timeout, connection error) is treated as a failure. - Failed deliveries are retried up to 6 attempts. The
attemptfield in the payload and theX-UseSend-Retryheader let you detect and dedupe retries.
Return 2xx immediately, then process asynchronously. If you do heavy work (DB writes, downstream calls) inline and exceed the 10s window, useSend treats the delivery as failed and retries it — causing duplicate processing. Make your handler idempotent by keying on the envelope id.
Payload envelope
Every webhook body shares the same envelope. Event-specific fields live under data.
{
"id": "evt_9f2c1a7b",
"type": "email.delivered",
"version": "2026-01-18",
"createdAt": "2026-09-01T14:32:10.221Z",
"teamId": 1,
"attempt": 1,
"data": {
"emailId": "clx8s0f7e0000abcd1234wxyz",
"to": "success@simulator.amazonses.com",
"from": "EverJust <hello@send.everjust.app>",
"subject": "Welcome to EverJust",
"status": "DELIVERED"
}
}| Field | Type | Description |
|---|---|---|
id | string | Unique event id. Use it to dedupe retries. |
type | string | Event type (see list above). |
version | string | Payload schema version — currently "2026-01-18". |
createdAt | string | ISO-8601 timestamp of the event. |
teamId | number | The Team (tenant) the event belongs to. |
attempt | number | Delivery attempt number (1 on first try, up to 6). |
data | object | Event-specific payload. |
Request headers
Each delivery carries these headers:
| Header | Description |
|---|---|
X-UseSend-Signature | HMAC signature, formatted v1=<hex>. Verify before trusting the body. |
X-UseSend-Timestamp | Unix timestamp in milliseconds when the signature was generated. |
X-UseSend-Event | The event type (mirrors type in the body). |
X-UseSend-Call | Identifier for this delivery call. |
X-UseSend-Retry | Retry indicator for this attempt. |
Verifying signatures
useSend signs each request so you can confirm it genuinely came from your instance and was not tampered with.
The signature is computed as:
HMAC-SHA256(secret, "{timestamp}.{rawBody}")secret— your endpoint's signing secret (whsec_…).timestamp— the value of theX-UseSend-Timestampheader (milliseconds).rawBody— the exact raw request body bytes, before any JSON parsing.- The result is hex-encoded and compared against the
v1=<hex>value inX-UseSend-Signature.
Verify against the raw request body, not a re-serialized object. JSON.parse then JSON.stringify can reorder keys or change whitespace, which breaks the HMAC. Capture the raw body in your framework (e.g. express.raw(), request.body bytes) and hash that.
Reject the request if the signature does not match, or if X-UseSend-Timestamp is more than 5 minutes away from the current time (replay protection).
The SDK verifies and parses in one call. Pass the raw body and the request headers.
import { UseSend } from "usesend-js";
import express from "express";
const usesend = new UseSend(
process.env.USESEND_API_KEY!, // us_xxx
"https://mail.everjust.app/api/v1"
);
const app = express();
// Capture the RAW body — do not use express.json() on this route.
app.post(
"/webhooks/usesend",
express.raw({ type: "application/json" }),
(req, res) => {
try {
const event = usesend
.webhooks(process.env.USESEND_WEBHOOK_SECRET!) // whsec_xxx
.constructEvent(req.body, { headers: req.headers });
// Signature + timestamp verified. Ack fast, process async.
res.status(200).send("ok");
switch (event.type) {
case "email.bounced":
console.log("bounce", event.data.emailId, event.data.bounce);
break;
case "email.opened":
console.log("open", event.data.emailId, event.data.open);
break;
// …handle other types
}
} catch (err) {
// Invalid signature or stale timestamp.
res.status(400).send("invalid signature");
}
}
);from usesend import UseSend
from flask import Flask, request
usesend = UseSend(
"us_xxx",
"https://mail.everjust.app/api/v1",
)
app = Flask(__name__)
@app.post("/webhooks/usesend")
def usesend_webhook():
# request.get_data() returns the RAW body bytes.
raw_body = request.get_data()
try:
event = usesend.webhooks("whsec_xxx").construct_event(
raw_body,
headers=dict(request.headers),
)
except Exception:
return "invalid signature", 400
if event["type"] == "email.bounced":
print("bounce", event["data"]["emailId"], event["data"]["bounce"])
elif event["type"] == "email.opened":
print("open", event["data"]["emailId"], event["data"]["open"])
return "ok", 200There is no cURL step for verification — verification happens in your server code. This shows the manual HMAC computation your handler performs, expressed as shell for illustration:
# Given the incoming request:
TIMESTAMP="1756737130221" # X-UseSend-Timestamp (ms)
RAW_BODY='{"id":"evt_9f2c1a7b","type":"email.delivered",...}' # exact bytes
SECRET="whsec_xxx"
# Signed string is "{timestamp}.{rawBody}"
SIGNED="${TIMESTAMP}.${RAW_BODY}"
# Compute HMAC-SHA256, hex-encoded:
printf '%s' "$SIGNED" | \
openssl dgst -sha256 -hmac "$SECRET" -r | awk '{print "v1="$1}'
# Compare the output against the X-UseSend-Signature header
# using a constant-time comparison, and reject if the
# X-UseSend-Timestamp is more than 5 minutes old.Manual verification (no SDK)
If you are not using an SDK, implement the same check directly. This Node example uses only the standard library and a constant-time comparison:
import crypto from "node:crypto";
function verifyUseSendWebhook(
rawBody: Buffer | string,
headers: Record<string, string | string[] | undefined>,
secret: string // whsec_xxx
): boolean {
const signatureHeader = String(headers["x-usesend-signature"] ?? "");
const timestamp = String(headers["x-usesend-timestamp"] ?? "");
if (!signatureHeader.startsWith("v1=") || !timestamp) return false;
// 5-minute replay tolerance (timestamp is in milliseconds).
const ageMs = Math.abs(Date.now() - Number(timestamp));
if (!Number.isFinite(ageMs) || ageMs > 5 * 60 * 1000) return false;
const body = Buffer.isBuffer(rawBody) ? rawBody.toString("utf8") : rawBody;
const signedString = `${timestamp}.${body}`; // "{timestamp}.{rawBody}"
const expected = crypto
.createHmac("sha256", secret)
.update(signedString, "utf8")
.digest("hex");
const provided = signatureHeader.slice("v1=".length);
// Constant-time compare; guard against length mismatch.
const a = Buffer.from(expected, "hex");
const b = Buffer.from(provided, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Per-event data payloads
Most events carry the affected resource under data (e.g. emailId, to, from, subject, status for email events; contact/domain fields for those events). Certain email events add an extra object:
email.bounced adds a bounce object:
{
"type": "email.bounced",
"data": {
"emailId": "clx8s0f7e0000abcd1234wxyz",
"status": "BOUNCED",
"bounce": {
"type": "Permanent",
"subType": "General",
"message": "The email account that you tried to reach does not exist."
}
}
}| Field | Type | Description |
|---|---|---|
bounce.type | string | Bounce category (e.g. Permanent, Transient). |
bounce.subType | string | Finer classification (e.g. General, MailboxFull). |
bounce.message | string | Human-readable diagnostic. |
email.opened adds an open object:
{
"type": "email.opened",
"data": {
"emailId": "clx8s0f7e0000abcd1234wxyz",
"status": "OPENED",
"open": {
"timestamp": "2026-09-01T14:40:02.100Z",
"userAgent": "Mozilla/5.0 …",
"ip": "203.0.113.10"
}
}
}| Field | Type | Description |
|---|---|---|
open.timestamp | string | When the open was recorded. |
open.userAgent | string | Client user agent. |
open.ip | string | Recipient IP. |
email.clicked adds a click object:
{
"type": "email.clicked",
"data": {
"emailId": "clx8s0f7e0000abcd1234wxyz",
"status": "CLICKED",
"click": {
"timestamp": "2026-09-01T14:41:18.502Z",
"url": "https://everjust.app/welcome"
}
}
}| Field | Type | Description |
|---|---|---|
click.timestamp | string | When the click was recorded. |
click.url | string | The tracked link that was clicked. |
email.suppressed adds a suppression object:
{
"type": "email.suppressed",
"data": {
"emailId": "clx8s0f7e0000abcd1234wxyz",
"status": "SUPPRESSED",
"suppression": {
"type": "Bounce",
"reason": "Previous hard bounce for this recipient",
"source": "AUTO"
}
}
}| Field | Type | Description |
|---|---|---|
suppression.type | string | Why the recipient is suppressed (e.g. Bounce, Complaint). |
suppression.reason | string | Human-readable explanation. |
suppression.source | string | What added the suppression. |
Self-hosted note. This instance runs one AWS deployment with SES currently in sandbox — until production access is granted, you will only see delivery events for verified addresses or the SES mailbox simulator (success@, bounce@, complaint@simulator.amazonses.com). The simulator addresses are the easiest way to exercise email.delivered, email.bounced, and email.complained handlers end to end. Rate limiting is disabled on self-hosted useSend, so it does not affect webhook throughput.