> For the complete documentation index, see [llms.txt](https://docs.yeymail.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.yeymail.com/advanced/webhooks.md).

# Webhooks

**Plus only.** Set a webhook URL in **Settings** and every message forwarded to your aliases is also sent to it as JSON.

Useful when mail is an input to something else (a ticketing system, a parser, an automation) rather than something a person reads.

## What we send

`POST` to your URL, `Content-Type: application/json`.

```json
{
  "alias": "orders@yourdomain.com",
  "to": "orders@yourdomain.com",
  "from": "Shop <noreply@shop.example>",
  "subject": "Your order has shipped",
  "messageId": "<20260727091402.A1B2C@shop.example>",
  "date": "Mon, 27 Jul 2026 09:14:02 +0000",
  "size": 18422,
  "raw": "<base64-encoded RFC 822 message>"
}
```

`from`, `subject`, `messageId` and `date` are the message's own headers, passed through unchanged, so `from` may include a display name, and `date` is the format email uses rather than ISO 8601. `size` is the size of the whole message in bytes.

`raw` is the complete original message, base64-encoded: headers, body, MIME parts and attachments. Decode it and hand it to any standard email parser rather than working from the convenience fields. Messages larger than 5 MB are truncated at that point, so check `size` if you expect big attachments.

## What to expect

* **It is fire-and-forget.** We do not retry. A webhook that was down when the message arrived does not get a second delivery. If the data matters, treat the forwarded email as the record and the webhook as a fast path.
* **Respond quickly.** Return a status code and get off the connection; do the work asynchronously on your side.
* **Delivery is independent of the webhook.** If your endpoint errors, times out or refuses the connection, the message is still forwarded to your inbox normally. A failing webhook never blocks or bounces your mail.
* **Order is not guaranteed.** Two messages arriving close together may reach you out of order.

## Verifying a request came from us

Every delivery is signed. Two extra headers ride along with it:

```http
X-YeyMail-Timestamp: 1785222533
X-YeyMail-Signature: sha256=e16b3482e8630acd304d52dedf325ecb1c0a97607c0a6d4f0f284c182bdd618e
```

The signature is an HMAC-SHA256 of `"<timestamp>." + <the raw request body>`, using your signing secret, hex-encoded.

Your secret is on the **Settings** page directly under the webhook URL. It appears the first time you save a URL. Reveal it, copy it, and store it wherever your endpoint keeps its configuration.

The timestamp is part of what gets signed rather than just travelling alongside it, so a request that someone captured cannot be replayed later with a fresh timestamp; changing it breaks the signature.

{% hint style="warning" %}
Sign the **raw bytes** of the body, exactly as they arrived. If you parse the JSON and re-serialise it, key order and spacing change and the signature will never match. Most frameworks need to be told explicitly to keep the raw body.
{% endhint %}

{% tabs %}
{% tab title="Node.js" %}

```js
const crypto = require('crypto');

function verify(rawBody, timestamp, signature, secret) {
  // Reject anything older than five minutes: a valid signature stays valid
  // forever otherwise, so a captured delivery could be replayed.
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > 300) return false;

  const mac = crypto
    .createHmac('sha256', secret)
    .update(timestamp + '.')
    .update(rawBody)
    .digest('hex');
  const expected = Buffer.from('sha256=' + mac);
  const got = Buffer.from(signature);
  return expected.length === got.length && crypto.timingSafeEqual(expected, got);
}

// Express keeps the raw body only if you ask it to:
// app.post('/inbound', express.raw({ type: 'application/json' }), (req, res) => {
//   const ok = verify(req.body, req.get('X-YeyMail-Timestamp'),
//                     req.get('X-YeyMail-Signature'), SECRET);
//   if (!ok) return res.sendStatus(401);
//   const payload = JSON.parse(req.body.toString('utf8'));
//   res.sendStatus(200);
// });
```

{% endtab %}

{% tab title="Python" %}

```python
import hmac, hashlib, time

def verify(raw_body: bytes, timestamp: str, signature: str, secret: str) -> bool:
    # Reject anything older than five minutes: a valid signature stays valid
    # forever otherwise, so a captured delivery could be replayed.
    try:
        if abs(time.time() - int(timestamp)) > 300:
            return False
    except (TypeError, ValueError):
        return False

    mac = hmac.new(secret.encode(), timestamp.encode() + b'.' + raw_body,
                   hashlib.sha256).hexdigest()
    return hmac.compare_digest('sha256=' + mac, signature)


# In Flask, request.get_data() gives you the raw bytes; request.json does not.
```

{% endtab %}

{% tab title="PHP" %}

```php
<?php
function verify(string $rawBody, string $timestamp, string $signature, string $secret): bool {
    // Reject anything older than five minutes: a valid signature stays valid
    // forever otherwise, so a captured delivery could be replayed.
    if (abs(time() - (int) $timestamp) > 300) {
        return false;
    }

    $mac = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    return hash_equals('sha256=' . $mac, $signature);
}

// $rawBody = file_get_contents('php://input');
```

{% endtab %}
{% endtabs %}

Compare the two signatures with a constant-time function: `timingSafeEqual`, `compare_digest` and `hash_equals` above. A plain `==` returns as soon as it finds a difference, which over many attempts leaks how much of the signature was correct.

### Rotating your secret

**Settings → Webhook routing → Rotate secret** generates a new one. It applies immediately and there is no overlap period, so change it on your endpoint at the same time. Otherwise deliveries will start failing your check.

Rotate if the secret is ever exposed: pasted into a chat, caught in a screenshot, or committed to a repository.

### If you would rather use the network

Requests always arrive from `mx1.yeymail.com`. You can allowlist that host instead of, or as well as, checking signatures.

## If you also use PGP

With both a PGP key and a webhook configured, the webhook receives the message as it arrived. PGP encryption is applied to the copy forwarded to your inbox, not to the webhook payload.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.yeymail.com/advanced/webhooks.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
