# Webhooks

> Coming soon. The events Maylee will deliver to your endpoint, their payloads, and how to verify their signature.

Source: https://docs.maylee.app/webhooks

> **Coming soon**
>
> Webhooks are not open yet: there is currently no way to create one, neither in the Maylee app nor through the API. This page describes how they work, so that you can prepare your endpoint. Until they open, poll the API with a [cursor](/pagination) at a reasonable interval.

A webhook lets Maylee tell your endpoint when something happens, instead of polling the API in a loop: cheaper in quota, and without delay.

## Events

| Event | Fired when | `data` |
| --- | --- | --- |
| `email.received` | An email arrives in an inbox. | `{ "emailId", "mailboxId", "folderType": "INBOX" }` |
| `email.updated` | An email is marked read or unread, starred or unstarred. | `{ "emailId", "changes": { "isRead": true } } or { "emailId", "changes": { "isStarred": true } }` |
| `email.deleted` | An email is moved to the trash. | `{ "emailId" }` |
| `draft.sent` | An email is sent through the API (`POST /drafts/send`) or the MCP server. | `{ "mailboxId", "messageId", "threadId", "to": ["client@acme.com"], "subject" }` |
| `label.applied` | A label is added to an email. | `{ "emailId", "tagId", "tagName", "isAiLabel" }` |
| `mailbox.sync_failed` | A synchronization attempt fails. `deactivated: true` means Maylee stopped synchronizing the mailbox until it is reconnected. | `{ "mailboxId", "error", "failCount", "deactivated" }` |

* `email.received` means a message **arrived**. It only fires for the inbox: your own sent messages and your drafts never fire it, so an agent does not react to what it just wrote.
* `label.applied` fires once per email and label, only when the label was actually added.
* `mailbox.sync_failed` fires on every failed attempt, each counted in `failCount`.

## Payload

Every delivery is a `POST` with a JSON body: the event name, when it was emitted, and a `data` object whose shape depends on the event.

```json
{
  "event": "email.received",
  "createdAt": "2026-08-25T14:32:11.000Z",
  "data": {
    "emailId": "cmemx1a2b3c4d5e6f7g8h9i0",
    "mailboxId": "cmbx1a2b3c4d5e6f7g8h9i0j",
    "folderType": "INBOX"
  }
}
```

Payloads carry identifiers, not the content of the email. Fetch the email with [`GET /emails/{emailId}`](/api-reference/emails/get-email): the content travels over an authenticated channel, and your endpoint does not become a store of personal data by accident.

Two headers come with every delivery:

```txt
X-Maylee-Event: email.received
X-Maylee-Signature: t=1787666985,v1=5257a869e7ec...
```

## Verify the signature

**Do it on every request.** Your endpoint is public: without verification, anyone can send it a fake event.

The signature is an HMAC-SHA256 of the timestamp **and** the raw body joined by a dot (`${t}.${rawBody}`), keyed with the webhook's signing secret (`whsec_...`, used whole, prefix included). Signing the timestamp with the body means a captured delivery cannot be replayed later: reject any timestamp more than 5 minutes away from your clock.

**Node.js**

```js  
import express from 'express'
import { createHmac, timingSafeEqual } from 'node:crypto'

const TOLERANCE_SECONDS = 300

export function verifyMayleeSignature(secret, rawBody, header) {
  const parts = new Map(
    header.split(',').map((part) => {
      const [key, ...rest] = part.trim().split('=')
      return [key, rest.join('=')]
    })
  )
  const timestamp = Number(parts.get('t'))
  const provided = parts.get('v1')
  if (!Number.isFinite(timestamp) || !provided) return false

  // Reject outside the window BEFORE comparing: an old but valid
  // signature is a replay.
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false

  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex')
  const a = Buffer.from(expected, 'hex')
  const b = Buffer.from(provided, 'hex')

  // Constant-time comparison: `===` would leak where the strings differ.
  return a.length === b.length && timingSafeEqual(a, b)
}

const app = express()

// The raw body: the signature is computed on the exact bytes received.
app.post('/webhooks/maylee', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.get('X-Maylee-Signature') ?? ''
  if (!verifyMayleeSignature(process.env.MAYLEE_WEBHOOK_SECRET, req.body, signature)) {
    return res.status(400).send('Invalid signature')
  }
  const event = JSON.parse(req.body.toString('utf8'))
  res.sendStatus(200) // answer fast, then process in the background
  handleEvent(event)
})
```

**Python**

```python
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300

def verify_maylee_signature(secret: str, raw_body: bytes, header: str) -> bool:
    parts = dict(
        part.strip().split("=", 1) for part in header.split(",") if "=" in part
    )
    try:
        timestamp = int(parts["t"])
        provided = parts["v1"]
    except (KeyError, ValueError):
        return False

    # Reject outside the window BEFORE comparing: an old but valid
    # signature is a replay.
    if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
        return False

    signed = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()

    # Constant-time comparison.
    return hmac.compare_digest(expected, provided)

# Flask example: request.get_data() returns the raw body.
# from flask import Flask, request
# app = Flask(__name__)
#
# @app.post("/webhooks/maylee")
# def maylee_webhook():
#     if not verify_maylee_signature(
#         os.environ["MAYLEE_WEBHOOK_SECRET"],
#         request.get_data(),
#         request.headers.get("X-Maylee-Signature", ""),
#     ):
#         return "Invalid signature", 400
#     handle_event(request.get_json())
#     return "", 200
```

Two classic pitfalls:

* **Sign the raw body**, not the result of parsing then serializing it again: the slightest difference in spacing or key order breaks the signature.
* **Never compare signatures with `==`.** Use a constant-time comparison.

## Your endpoint

* It must use `https` on port 443, carry no credentials in the URL, and resolve to a public address. Internal addresses (`127.0.0.1`, `10.0.0.0/8`, `169.254.169.254` and the like) are refused.
* The host name is resolved **at every delivery**, not once: a record that later points to an internal address is refused at that moment.
* In development, `localhost` and private tunnels cannot receive deliveries: use a public receiver or an `https` tunnel.

## Retries

A delivery succeeds when your endpoint answers **2xx within 10 seconds**. Anything else is retried, including a `4xx`: an endpoint that answers 404 during a deployment gets the delivery once it is back. Five attempts in total:

| Attempt | Delay after the previous failure |
| ------- | -------------------------------- |
| 1       | Immediate                        |
| 2       | 5 minutes                        |
| 3       | 30 minutes                       |
| 4       | 2 hours                          |
| 5       | 6 hours                          |
| Then    | The delivery is abandoned.       |

After **three days of continuous failure**, the webhook is disabled: an endpoint broken for three days rarely comes back on its own. Deliveries never count against your API quota.

## Good practices

* **Answer fast, process later.** Return `200` as soon as the request arrives and do the work in the background: taking more than 10 seconds turns the delivery into a failure, and you receive it again.
* **Be idempotent.** A `2xx` lost on the way causes a retry, so the same event can reach you twice. Deduplicate on the event, its `createdAt` and its `data` ids.
* **Do not rely on the order.** Retries shift deliveries: an `email.updated` can arrive before the matching `email.received`. Read the current state from the API rather than rebuilding it from the sequence of events.
