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

ParameterTypeRequiredDescription
pageintegerOptionalPage number, starting at 1. Default 1.
per_pageintegerOptionalWebhooks 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 reason and smtp_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_count and next_retry_at.
  • rejected — dropped before sending, so it was never delivered. Reasons (data.reason) include all_recipients_suppressed (recipient is on your suppression list), failed_verification (verify-before-send found the address undeliverable), sandbox_recipient_not_authorized, and domain_not_verified. The API request still returned 202.
  • 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).

jsonExample: delivered event
{
  "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 deliveriesdata.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.

jsonExample: `data` on a support-routed sandbox event
{
  "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-generated whsec_ signing secret. Every delivery carries the signature header (default X-Wemail-Signature, format t={unix_ts},v1={hex hmac-sha256}), plus X-Wemail-Timestamp and X-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 valid v1 while you roll over.
  • Fixed credentialsbearer (an Authorization: Bearer <token> header), basic (an Authorization: Basic <base64> header) or custom_headers (up to 5 static headers, e.g. an X-Api-Key). Set once; sent on every delivery.
  • OAuth 2.0 client credentialsoauth2: wemail fetches a token from your token_url (form-urlencoded grant_type=client_credentials, plus scope/audience when set), caches it per endpoint until expiry, refreshes it automatically, and retries once with a fresh token on a 401/403 response. A delivery is never attempted with a missing or invalid token — a failed token fetch fails the attempt into the normal retry schedule.
  • Mutual TLSmtls: 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. nginx ssl_verify_client on). The private key is write-only — stored encrypted and never downloadable.
javascriptVerify an hmac-signed delivery
// 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:

Retry schedule
ParameterTypeRequiredDescription
Attempt 1immediateOptionalWithin ~100 ms of the state change.
Attempt 2+30 secondsOptionalFirst retry.
Attempt 3+2 minutesOptionalAfter attempt 2 fails.
Attempt 4+10 minutesOptional
Attempt 5+1 hourOptional
Attempt 6+6 hoursOptional
Attempt 7+12 hoursOptionalFinal 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 deliveriesPOST /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.

Status fields (on every webhook read)
ParameterTypeRequiredDescription
statusstringOptionalactive while enabled: true, disabled otherwise — a derived convenience mirror of enabled.
disabled_reasonstring or nullOptionalauto_failures when wemail disabled the endpoint for consecutive failures; manual when you paused it with enabled: false; null while active.
disabled_atstring (ISO 8601) or nullOptionalWhen 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

StatusDescription
200OK. Your webhook endpoints with their events, target url and auth config — signing secrets are always redacted.
401Missing or invalid API key.

Code examples

cURL
curl https://api.wemail.io/v3/webhooks \
  -H "Authorization: Bearer afn_live_…"
Node.js
const res = await fetch("https://api.wemail.io/v3/webhooks", {
  headers: {
    Authorization: `Bearer ${process.env.WEMAIL_API_KEY}`,
  },
});
const data = await res.json();
Python
import os, requests

r = requests.get(
    "https://api.wemail.io/v3/webhooks",
    headers={"Authorization": f"Bearer {os.environ['WEMAIL_API_KEY']}"},
)
data = r.json()