Webhooks
Subscribe to event types and wemail will POST a signed JSON payload to your endpoint.
Subscribe to event types and wemail will POST a signed JSON payload to your endpoint. Webhooks are delivered within 100 ms of state change with at-least-once semantics. This endpoint lists your configured webhook endpoints.
GET /v3/webhooks
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
page | integer | Optional | Page number, starting at 1. Default 1. |
per_page | integer | Optional | Webhooks per page, 1–100. Default 25. |
Event types
- queued — accepted by the API.
- sent — handed off to the recipient's MX.
- delivered — recipient's server accepted the message.
- opened — recipient opened the message.
- clicked — recipient clicked a tracked link.
- bounced — soft or hard bounce. Includes
reasonandsmtp_response. - complained — recipient marked as spam.
- unsubscribed — recipient hit the one-click unsubscribe header. Added to your suppression list automatically.
- delayed — temporary failure; wemail will retry. Carries
retry_countandnext_retry_at. - rejected — dropped before sending, so it was never delivered. Reasons (
data.reason) includeall_recipients_suppressed(recipient is on your suppression list),failed_verification(verify-before-send found the address undeliverable),sandbox_recipient_not_authorized, anddomain_not_verified. The API request still returned202. - failed — permanent delivery failure after all retries were exhausted.
Payload
Every payload echoes what you attached to the message at submission — tags, variables and custom X-* headers (API tags/vars/headers fields, or X-Wemail-Tag / X-Wemail-Variables / any X-* MIME header over SMTP) — so you can correlate events with your own records without a lookup. data carries the event-specific details (bounce reason, clicked link, geo, user agent, …).
Per-message webhooks — pass webhook_url on a send, batch or scheduled send (or the X-Wemail-Webhook SMTP header) and every event for that message is also POSTed to that URL, in addition to the endpoints configured here. Per-message deliveries are signed with Wemail-Signature-Ed25519 only (they have no endpoint secret, so no legacy HMAC headers).
{
"event": "delivered",
"message_id": "msg_9f2c1a7e",
"recipient": "alex@example.com",
"timestamp": "2026-08-13T10:00:00.000Z",
"tags": [
"order-confirmation",
"premium"
],
"headers": {
"x-reference-id": "order-42"
},
"variables": {
"customer_name": "John",
"order_id": "12345"
},
"data": {}
}Simulated deliveries — data.test is true on every event of a message that was simulated instead of sent: a send with one of your test keys, or a message wemail support routed to the Sandbox for you. data.routed is an optional boolean: present and true when the message was simulated because wemail support routed this account or API key to the Sandbox; such events also carry test: true. Absent for real deliveries and for your own test-key sends.
{
"event": "delivered",
"message_id": "msg_4b8e2d10",
"recipient": "alex@example.com",
"timestamp": "2026-09-02T10:00:00.000Z",
"tags": [],
"headers": {},
"variables": {},
"data": {
"test": true,
"routed": true
}
}Signature verification
Each webhook carries an Wemail-Signature-Ed25519 header with format t={timestamp},v1={signature},kid={key_id}. Verify the signature against our public key published at /.well-known/wemail/keys. The signed content is {timestamp}.{raw_body} — verify with crypto.verify(null, signedPayload, publicKey, sig).
Authenticating deliveries at your server
The signature proves a payload came from wemail. If your receiver also sits behind an auth layer (an API gateway, a framework auth middleware, an identity provider), configure receiver-side authentication on the endpoint with the auth field on create/update — wemail then presents credentials on every delivery, exactly like any other client of your API.
- HMAC signature (recommended) —
hmac: a server-generatedwhsec_signing secret. Every delivery carries the signature header (defaultX-Wemail-Signature, formatt={unix_ts},v1={hex hmac-sha256}), plusX-Wemail-TimestampandX-Wemail-Key-Id: whsec_…{last4}so you know which secret signed it. The signed content is{t}.{raw_body}. Reject timestamps older than 5 minutes to block replays. Rotating the secret keeps the old secret valid for 24 h, so both produce a validv1while you roll over. - Fixed credentials —
bearer(anAuthorization: Bearer <token>header),basic(anAuthorization: Basic <base64>header) orcustom_headers(up to 5 static headers, e.g. anX-Api-Key). Set once; sent on every delivery. - OAuth 2.0 client credentials —
oauth2: wemail fetches a token from yourtoken_url(form-urlencodedgrant_type=client_credentials, plusscope/audiencewhen set), caches it per endpoint until expiry, refreshes it automatically, and retries once with a fresh token on a401/403response. A delivery is never attempted with a missing or invalid token — a failed token fetch fails the attempt into the normal retry schedule. - Mutual TLS —
mtls: upload a PEM client certificate + private key (16 KB each); wemail presents the certificate during the TLS handshake with your server. The certificate is validated (and checked to match the key) at save time; reads echo its SHA-256 fingerprint, subject and expiry so you can pin it (e.g. nginxssl_verify_client on). The private key is write-only — stored encrypted and never downloadable.
// X-Wemail-Signature: t=1755663456,v1=9f2c41ad…
const [, ts, v1] = header.match(/t=(\d+),v1=([0-9a-f]+)/);
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) throw new Error('stale'); // 5-min tolerance
const expected = crypto.createHmac('sha256', process.env.WEMAIL_SECRET)
.update(`${ts}.${rawBody}`).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))) throw new Error('bad signature');
// During a rotation, also try the previous secret — it stays valid for 24 h.Automatic retries
A delivery succeeds on any 2xx response within the 30-second per-attempt timeout. Anything else is retried on a fixed backoff schedule of up to 7 attempts:
| Parameter | Type | Required | Description |
|---|---|---|---|
Attempt 1 | immediate | Optional | Within ~100 ms of the state change. |
Attempt 2 | +30 seconds | Optional | First retry. |
Attempt 3 | +2 minutes | Optional | After attempt 2 fails. |
Attempt 4 | +10 minutes | Optional | |
Attempt 5 | +1 hour | Optional | |
Attempt 6 | +6 hours | Optional | |
Attempt 7 | +12 hours | Optional | Final retry. A last +24 h step applies when an attempt was cut short, so a delivery may arrive up to ~44 hours after the event. |
Retries stop early when your endpoint returns a permanent client error (4xx other than 429) — that response is treated as a final refusal. 429 and all 5xx/network/timeout failures keep retrying until the schedule is exhausted. Every attempt carries an X-Webhook-Attempt: <n> header so your receiver can tell a retry from a first delivery.
After the final failure the delivery is recorded as failed in your delivery history — nothing is silently dropped. You can re-deliver it any time within 90 days, individually or in bulk (optionally to a different endpoint), with Resend deliveries — POST /v3/webhooks/deliveries/resend — or from Console → Webhooks → Activity. Manual resends are a single attempt and never re-enter this retry schedule.
Automatic endpoint disabling
An endpoint that keeps failing is eventually disabled automatically: after 20 consecutive fully-failed deliveries (every retry of each delivery exhausted, with no success in between) whose failure streak spans at least 72 hours, wemail sets the endpoint to enabled: false, emails the account owner, and writes a webhook.auto_disable entry to your audit trail (GET /v3/audit). A single successful delivery of any kind — including a successful manual resend — resets the counter; failed manual resends never count toward it. This protects both sides: your dead receiver stops accumulating pointless retries, and our delivery fleet stops burning attempts on it.
| Parameter | Type | Required | Description |
|---|---|---|---|
status | string | Optional | active while enabled: true, disabled otherwise — a derived convenience mirror of enabled. |
disabled_reason | string or null | Optional | auto_failures when wemail disabled the endpoint for consecutive failures; manual when you paused it with enabled: false; null while active. |
disabled_at | string (ISO 8601) or null | Optional | When the endpoint was disabled. null while active. |
Re-enable the endpoint the same way you pause it — PUT /v3/webhooks/{id} with {"enabled": true} (or the toggle in Console → Webhooks). Re-enabling clears disabled_reason/disabled_at and resets the failure counter, so the endpoint gets a fresh 20-failure budget. Events that occurred while the endpoint was disabled are not queued — replay what you missed with Resend deliveries (failed deliveries stay resendable for 90 days).
Delivery history & retention
Every attempt (organic and manual) is kept in your delivery history and shown in Console → Webhooks → Activity: response code, latency, attempt number and outcome. History follows your plan’s data retention — 30 days on Free, 12 months on paid plans — and resending can reach back up to 90 days within that window. The same retention applies to the events feed (GET /v3/events).
Responses
| Status | Description |
|---|---|
200 | OK. Your webhook endpoints with their events, target url and auth config — signing secrets are always redacted. |
401 | Missing or invalid API key. |
Code examples
curl https://api.wemail.io/v3/webhooks \
-H "Authorization: Bearer afn_live_…"const res = await fetch("https://api.wemail.io/v3/webhooks", {
headers: {
Authorization: `Bearer ${process.env.WEMAIL_API_KEY}`,
},
});
const data = await res.json();import os, requests
r = requests.get(
"https://api.wemail.io/v3/webhooks",
headers={"Authorization": f"Bearer {os.environ['WEMAIL_API_KEY']}"},
)
data = r.json()