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

EventFired whendata
email.receivedAn email arrives in an inbox.{ "emailId", "mailboxId", "folderType": "INBOX" }
email.updatedAn email is marked read or unread, starred or unstarred.{ "emailId", "changes": { "isRead": true } } or { "emailId", "changes": { "isStarred": true } }
email.deletedAn email is moved to the trash.{ "emailId" }
draft.sentAn email is sent through the API (POST /drafts/send) or the MCP server.{ "mailboxId", "messageId", "threadId", "to": ["client@acme.com"], "subject" }
label.appliedA label is added to an email.{ "emailId", "tagId", "tagName", "isAiLabel" }
mailbox.sync_failedA 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.

{
  "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 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:

AttemptDelay after the previous failure
1Immediate
25 minutes
330 minutes
42 hours
56 hours
ThenThe 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.

On this page