Quick answer

Register a webhook from the Webhooks page in the app 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:

In the app. Click Webhooks in the sidebar, 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.
email.received A new message arrived in one of your mailboxes. Warming traffic is excluded. The payload has the sender, recipient, subject and threading headers but not the body; fetch that through the inbox API.
email.bounced A message relayed through an upstream email provider bounced. The payload includes the recipient, bounce type and diagnostic.
email.complained A recipient marked a message relayed through an upstream email provider as spam, and their mail system reported it.
domain.created A domain was added to your account and provisioning began.
domain.ready A domain finished provisioning and is ready to send.
domain.dns_failed A domain's DNS health check started failing.
email_user.created A mailbox finished provisioning.
email_user.deleted A mailbox was deleted.
test.ping You called the test endpoint. Delivered even to disabled endpoints.

Subscribing to email.received (or *) switches on inbox syncing for your account if it isn't on already, since that's what produces these events. Re-enabling a disabled endpoint that subscribes to it does the same. While an enabled endpoint subscribes to email.received, the universal inbox can't be switched off (Settings → Account → Universal Inbox, or POST /v1/account/universal-inbox returns 409).

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@mail.provider.example>",
    "provider": "<provider id>",
    "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.

In message.relayed events, provider is a short identifier for the upstream provider that relayed the message. Treat it as an opaque label. The data fields differ by event type, and the full schema for each is in the OpenAPI spec.

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

    The Webhooks page in the app, 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 Webhooks page 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