Quick answer

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:

  1. You send a message with your own Message-ID (say from your sequencer or CRM).
  2. When the message is relayed through an upstream email provider, the provider assigns a new Message-ID at delivery. Everything else (References, Reply-To, custom headers) arrives intact.
  3. Within about 5 minutes, a message.relayed event fires with both IDs. A send to N recipients produces N events, one mapping per recipient.
  4. When the recipient replies, their reply's In-Reply-To references 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:

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

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

Was this article helpful? Yes · No