Webhooks
Coming soon. The events Maylee will deliver to your endpoint, their payloads, and how to verify their signature.
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 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.receivedmeans 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.appliedfires once per email and label, only when the label was actually added.mailbox.sync_failedfires on every failed attempt, each counted infailCount.
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.
{
"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}: 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:
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.
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)
})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
httpson 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.254and 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,
localhostand private tunnels cannot receive deliveries: use a public receiver or anhttpstunnel.
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
200as 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
2xxlost on the way causes a retry, so the same event can reach you twice. Deduplicate on the event, itscreatedAtand itsdataids. - Do not rely on the order. Retries shift deliveries: an
email.updatedcan arrive before the matchingemail.received. Read the current state from the API rather than rebuilding it from the sequence of events.