API Reference
API Reference
GitHub ↗
Webhooks and Events

Overview

Real-time webhooks for email events

A webhook is a URL of yours that Mailyard POSTs to when something happens to your mail. It is how you learn that a message was delivered or failed without polling for it.

Create one

POST /api/v1/webhooks
curl -X POST http://localhost:3000/api/v1/webhooks \
  -H "Authorization: Bearer myk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.example/hooks/mailyard",
    "events": ["email.sent", "email.failed"],
    "filters": ["*@billing.example.com"]
  }'
FieldNotes
urlRequired. Must be an http or https URL, up to 2048 characters
eventsRequired. Between 1 and 10 event types , or * for all
filtersOptional. Up to 20 sender addresses or domain patterns

Who may subscribe

A webhook sends a copy of what an event describes to a URL of your choosing, so subscribing to an event takes the permission to read it, on top of webhooks:write:

EventsAlso needs
email.*emails:read
inbound.receivedinbound:read
campaign.*campaigns:read
*emails:read, inbound:read, campaigns:read

A create or an edit naming an event the caller may not read is refused with 403. The check is made when the event list is written: a webhook created before it keeps the events it has.

The signing secret is returned once

Mailyard generates it and puts it in the create response only. Every later read omits it, and there is no route that will show it to you again — losing it means deleting the webhook and creating another.

It is stored sealed rather than hashed. A hash would leave nothing to sign with: the dispatcher needs the same value your receiver verifies against.

Narrowing by sender

filters restricts deliveries to messages from particular senders. An empty list means everything.

PatternMatches
billing@example.comThat address exactly
*@example.comAny address at that domain

Matching is case-insensitive and runs against the bare envelope address, so a From of Billing <billing@Example.com> matches both patterns above.

This is how one project runs separate receivers for separate concerns — transactional mail to one endpoint, campaign mail to another — without either having to filter the other’s traffic on arrival.

The payload

Every event uses the same envelope: event, timestamp, and the per-event fields under data.

{
  "event": "email.failed",
  "timestamp": "2026-01-01T00:00:01Z",
  "data": {
    "id": "0198f6a1-3c7e-7b21-9f4d-2a5c8e0b1d33",
    "project_id": "0198f6a0-9d12-7f33-a1b8-6c4e2f8a0d57",
    "sender": "noreply@example.com",
    "recipients": ["recipient@customer.example"],
    "subject": "Your receipt",
    "status": "failed",
    "error_message": "smtp dial failed: connection refused",
    "attempts": 3
  }
}

Read the recipient from data.recipients. data.id identifies the message inside Mailyard and means nothing to your application — if you need to correlate, put your own identifier in a custom header on the send and read it back from the email log .

See Event Types for the fields each event carries.

Verifying the signature

Every delivery carries a timestamp and an HMAC-SHA256 over the timestamp and the raw request body:

X-Mailyard-Timestamp: 1756230000
X-Mailyard-Signature: sha256=<hex>

The signed string is <timestamp> + "." + <body>. Note the sha256= prefix — it is part of the header value, not a description of it.

func valid(secret string, timestamp string, body []byte, header string) bool {
    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil || time.Since(time.Unix(ts, 0)).Abs() > 5*time.Minute {
        return false
    }

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(timestamp))
    mac.Write([]byte("."))
    mac.Write(body)
    want := "sha256=" + hex.EncodeToString(mac.Sum(nil))

    return hmac.Equal([]byte(want), []byte(header))
}

Three things that matter more than they look:

  • Check the timestamp. It is inside the signed string, so refusing one older than a few minutes refuses a replayed delivery with it. Five minutes leaves room for clock drift; Mailyard’s retries send a fresh timestamp each time.
  • Compare in constant time. hmac.Equal, or your language’s equivalent — a plain == returns as soon as two bytes differ, which leaks how much of a forged signature was right.
  • Sign the bytes you received, before any JSON parsing and re-encoding. Round-tripping through a decoder reorders keys and changes whitespace, and the signature will not match.

Reject anything that fails. A request without a valid signature is not from Mailyard.

Rotating the secret

POST /api/v1/webhooks/{id}/rotate-secret

Returns the webhook with a fresh secret, shown once like the original. Deliveries are signed with the new secret from the next one on, so have the receiver verify against both for the length of the changeover, then drop the old one.

Retries

Delivery is attempted webhook.max_attempts times (3 by default, including the first), with a fixed webhook.retry_delay between attempts (10s) and each request bounded by webhook.timeout (10s). Any 2xx is success. Redirects are not followed.

Each attempt reads the webhook as it is at that moment. A retry after the URL was edited or the secret rotated goes to the new URL with the new signature, and a delivery whose webhook was deleted, disabled or unsubscribed from the event in the meantime stops without another attempt. When several deliveries give up on the same endpoint, the webhook is disabled once, with one audit event and one notification.

The URL must be http:// or https://.

Every attempt is recorded — see Delivery Tracking , which also covers what happens when the attempts run out.

Manage

RouteDoes
GET /api/v1/webhooksEvery webhook in the project
GET /api/v1/webhooks/{id}One, without its secret
PATCH /api/v1/webhooks/{id}Change url, events or filters, each only when sent. filters: [] clears the list
POST /api/v1/webhooks/{id}/disableTake it out of rotation by hand, with an optional reason
POST /api/v1/webhooks/{id}/enablePut it back, after a manual disable or after the dispatcher gave up on it
POST /api/v1/webhooks/{id}/testPost a webhook.test event now and answer with the attempt - see Delivery Tracking
POST /api/v1/webhooks/{id}/rotate-secretA fresh secret, returned once
DELETE /api/v1/webhooks/{id}Remove it and its delivery log

An edit leaves the secret alone, so a receiver keeps verifying through it. If the endpoint changed hands, rotate the secret as a separate act.

Private network targets are refused

By default a webhook URL cannot resolve to loopback, RFC 1918, or other reserved address space. URLs are chosen by project members, and without that guard one of them could aim a delivery at your cloud metadata service and use this process as a proxy into your private network.

webhook.allow_private_targets lifts it, for receivers that genuinely are on the same network. Turning it on reopens that path to anyone who can create a webhook.