Register a webhook from the dashboard Webhooks page or with POST /v1/webhooks (url + events). Winnr POSTs signed JSON events to your URL. Verify the X-Winnr-Signature header (HMAC-SHA256 of "timestamp.body" with your whsec_ secret), respond 2xx within 10 seconds. The message.relayed event and GET /v1/messages/lookup map your Message-ID to the one the upstream email provider assigns, so you can match replies to the emails you sent.
Webhooks let Winnr notify your systems the moment something happens on your account, instead of you polling the API. You register an HTTPS URL, pick the events you care about, and Winnr POSTs a signed JSON payload to that URL each time one occurs.
The headline use case is reply correlation. When your mail is relayed through an upstream email provider, that provider assigns its own Message-ID to the delivered message. Your recipient's mail client threads against the provider's ID, so when they reply, the In-Reply-To header references an ID your system never generated. The message.relayed event hands you the mapping as it happens, and GET /v1/messages/lookup lets you query it later in either direction.
Creating an endpoint
Two ways:
Dashboard. Open the Webhooks page, click Add Endpoint, enter your HTTPS URL and pick events.
API.
curl -X POST https://api.winnr.app/v1/webhooks \
-H "Authorization: Bearer wnr_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/winnr/webhook",
"events": ["message.relayed"],
"description": "Reply correlation pipeline"
}'
The response includes the endpoint plus its signing secret (starts with whsec_). Store it. You can retrieve it again later with GET /v1/webhooks/{id}/secret, and rotate it with POST /v1/webhooks/{id}/rotate-secret.
Rules: the URL must be HTTPS, events can be specific types or ["*"] for everything, and each account can have up to 10 endpoints.
Event catalog
| Event | When it fires |
|---|---|
message.relayed |
An outbound message was relayed through an upstream email provider and got a provider-assigned Message-ID. One event per recipient, with the ID mapping. |
test.ping |
You called the test endpoint. Delivered even to disabled endpoints. |
email.received |
A message arrived in one of your mailboxes. (rolling out) |
email.bounced |
An outbound message hard-bounced. (rolling out) |
email.complained |
A recipient marked your message as spam and their mail system reported it. (rolling out) |
domain.created |
A domain was added to your account and provisioning began. (rolling out) |
domain.ready |
A domain finished provisioning and is ready to send. (rolling out) |
domain.dns_failed |
A domain's DNS health check failed. (rolling out) |
email_user.created |
A mailbox finished provisioning. (rolling out) |
email_user.deleted |
A mailbox was deleted. (rolling out) |
Events marked "(rolling out)" are being enabled gradually. You can subscribe to them now, and deliveries begin as each one goes live.
The payload
Every delivery is a JSON envelope with the same shape:
{
"id": "evt_01J9XK4T8Q2M5N7P",
"object": "event",
"type": "message.relayed",
"api_version": "2026-08",
"created": "2026-08-10T17:03:22Z",
"account_id": "acct_abc123",
"data": {
"original_message_id": "<CAF9=abc123@yourapp.example.com>",
"provider_message_id": "<010001915ffabcde-1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d-000000@email.amazonses.com>",
"provider": "ses",
"recipient": "jane@prospect-company.com",
"sender": "sam@yourdomain.com",
"sending_domain": "yourdomain.com",
"relayed_at": "2026-08-10T17:03:20Z"
}
}
Two envelope fields matter for correctness: id is stable across retries, so use it as your dedupe key, and created is when the event actually happened, so order by it rather than by arrival time.
The message-id mapping story
Here is the full picture for reply correlation:
- You send a message with your own
Message-ID(say from your sequencer or CRM). - When the message is relayed through an upstream email provider, the provider assigns a new
Message-IDat delivery. Everything else (References,Reply-To, custom headers) arrives intact. - Within about 5 minutes, a
message.relayedevent fires with both IDs. A send to N recipients produces N events, one mapping per recipient. - When the recipient replies, their reply's
In-Reply-Toreferences the provider's ID, not yours. Look it up to get back your original:
# provider_message_id = the ID from the reply's In-Reply-To header
curl "https://api.winnr.app/v1/messages/lookup?provider_message_id=PROVIDER_ID" \
-H "Authorization: Bearer wnr_..."
The lookup works in both directions. provider_message_id returns the single original ID. original_message_id returns every provider ID minted for that send (one per recipient). IDs are accepted with or without angle brackets. Mappings appear roughly 5 minutes after the send and are kept for 90 days.
If a lookup comes back empty: the send may be less than 5 minutes old, the mail may have gone out directly (in which case your original ID survived and there is nothing to map), or the mapping has aged past 90 days.
Verifying signatures
Every delivery carries these headers:
X-Winnr-Event: the event typeX-Winnr-Event-Id: the event ID, same asidin the bodyX-Winnr-Timestamp: unix seconds when the delivery was signedX-Winnr-Signature:v1=<hex>, the HMAC-SHA256 of"{timestamp}.{raw_body}"keyed with yourwhsec_secret. During the 24-hour window after a secret rotation there are two comma-separatedv1=values; accept the delivery if any value matches.User-Agent:Winnr-Webhooks/1.0
Always verify against the raw request body bytes, before any JSON parsing or re-serialization, and reject deliveries whose timestamp is more than 5 minutes old.
Python
import hashlib
import hmac
import time
def verify_winnr_signature(raw_body: bytes, headers: dict, secret: str) -> bool:
"""Return True if the delivery is authentic. Pass the RAW body bytes."""
timestamp = headers.get("X-Winnr-Timestamp", "")
signature_header = headers.get("X-Winnr-Signature", "")
try:
if abs(time.time() - int(timestamp)) > 300:
return False # too old, possible replay
except ValueError:
return False
signed_payload = timestamp.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
# Two comma-separated v1= values during secret rotation; any match wins.
for part in signature_header.split(","):
value = part.strip()
if value.startswith("v1=") and hmac.compare_digest(value[3:], expected):
return True
return False
# Flask example:
# @app.post("/winnr/webhook")
# def receive():
# if not verify_winnr_signature(request.get_data(), request.headers, WEBHOOK_SECRET):
# return "", 401
# event = request.get_json()
# handle(event) # dedupe on event["id"]
# return "", 200
Node.js
const crypto = require("crypto");
function verifyWinnrSignature(rawBody, headers, secret) {
// rawBody must be the exact raw bytes (Buffer or string), not re-serialized JSON.
const timestamp = headers["x-winnr-timestamp"] || "";
const signatureHeader = headers["x-winnr-signature"] || "";
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!timestamp || Number.isNaN(age) || age > 300) return false; // too old
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest("hex");
// Two comma-separated v1= values during secret rotation; any match wins.
return signatureHeader.split(",").some((part) => {
const value = part.trim();
if (!value.startsWith("v1=")) return false;
const candidate = value.slice(3);
if (candidate.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(expected));
});
}
// Express example (note express.raw so the body is not parsed first):
// app.post("/winnr/webhook", express.raw({ type: "application/json" }), (req, res) => {
// if (!verifyWinnrSignature(req.body, req.headers, process.env.WEBHOOK_SECRET)) {
// return res.sendStatus(401);
// }
// const event = JSON.parse(req.body);
// handle(event); // dedupe on event.id
// res.sendStatus(200);
// });
Retries and auto-disable
- Respond with any 2xx status within 10 seconds. Do your heavy processing after acknowledging (queue the event, return 200, process async).
- Redirects are not followed. A 3xx counts as a failure, so point the webhook at its final URL.
- Failed events retry up to 6 attempts over roughly 51 minutes with increasing backoff.
- After 25 consecutive failed events the endpoint flips to
auto_disabledand deliveries stop. Re-enable from the dashboard or withPATCH /v1/webhooks/{id}and{"status": "enabled"}, which also resets the failure counter. - Delivery is at-least-once and ordering is not guaranteed. Dedupe on
id, order bycreated.
Testing and the delivery log
POST /v1/webhooks/{id}/test queues a test.ping event to your endpoint. It is delivered even while the endpoint is disabled, so you can verify your receiver (signature check included) before turning on real traffic.
GET /v1/webhooks/{id}/deliveries returns the attempt log for the last 30 days: event type, attempt number, outcome, the HTTP status your server returned, error detail, and round-trip time. If you missed events during an outage, POST /v1/webhooks/{id}/deliveries/{event_id}/redeliver re-sends any event still in the log.
What's next
Step-by-step
-
1. Create an endpoint
Dashboard Webhooks page, or POST /v1/webhooks with a JSON body of url (must be HTTPS) and events. Up to 10 endpoints per account.
-
2. Store the signing secret
The create response includes a secret starting with whsec_. Store it where your receiver can read it. Unlike API tokens, you can fetch it again later from GET /v1/webhooks/{id}/secret.
-
3. Verify signatures in your receiver
Compute HMAC-SHA256 over "timestamp.raw_body" with the secret and compare against each v1= value in X-Winnr-Signature. Reject deliveries older than 5 minutes. Copyable Python and Node.js snippets below.
-
4. Send a test event
POST /v1/webhooks/{id}/test queues a test.ping delivery so you can verify end to end before real traffic. Works even while the endpoint is disabled.
-
5. Check the delivery log
GET /v1/webhooks/{id}/deliveries shows every attempt with status code, error, and timing for the last 30 days.
Frequently asked questions
Why does the Message-ID my recipient sees differ from the one I sent?
Some of your mail is relayed through an upstream email provider for better inbox placement, and the provider assigns its own Message-ID at delivery. Replies thread against that ID. The message.relayed event and GET /v1/messages/lookup give you the mapping in both directions.
How fast do message.relayed events arrive?
Typically within about 5 minutes of the send. The same latency applies to GET /v1/messages/lookup. If a lookup returns empty right after sending, wait a few minutes and retry.
Do all sends produce a mapping?
No. Only mail relayed through an upstream provider gets its Message-ID rewritten. Mail sent directly keeps your original ID, so no mapping (or event) is needed.
What happens if my endpoint goes down?
Failed events retry up to 6 times over roughly 51 minutes. After 25 consecutive failed events the endpoint is auto-disabled. Re-enable it from the dashboard or with PATCH /v1/webhooks/{id} setting status to enabled, then replay missed events from the delivery log with the redeliver endpoint.
Are events delivered exactly once and in order?
Neither is guaranteed. Delivery is at-least-once, so dedupe on the event id, and events can arrive out of order, so order by the created field.
Can I rotate the signing secret without downtime?
Yes. POST /v1/webhooks/{id}/rotate-secret returns a new secret, and for 24 hours every delivery is signed with both the old and new secret (two comma-separated v1= values). Accept the delivery if any value matches.