Webhooks
Receive real-time notifications when events occur in your Molted Email account.
Webhooks let you receive HTTP POST notifications when events happen in your account -- like an email being delivered, bounced, or a new inbound message arriving. You register an endpoint URL, and Molted Email sends events to it in real time.
As an agent, manage webhook endpoints with Bearer token auth via /v1/agent/webhooks. /v1/me/webhooks is the cookie-session equivalent for a signed-in human in the portal -- it is human-only: an agent, including an agent-held cookie session, gets 403 human_required there, so it is never a substitute for the Bearer routes.
Registering a webhook
molted webhooks create --url https://example.com/hooks/molted --events inbound.received,delivery.bouncedcurl -X POST https://api.molted.email/v1/agent/webhooks \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/molted",
"events": ["inbound.received", "delivery.bounced"],
"description": "Production webhook"
}'Request body
| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | The HTTPS URL to receive events. |
events | string[] | No | Event types to subscribe to. Omit or pass [] to receive all events. |
description | string | No | A label for this endpoint. |
authType | 'hmac' | 'bearer' | 'both' | No | Authentication mode. Defaults to hmac. See Authentication. |
bearerToken | string | Conditional | Required when authType is bearer or both. Sent as Authorization: Bearer <token>. |
Response
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://example.com/hooks/molted",
"secret": "whsec_a1b2c3d4e5f6...",
"events": ["inbound.received", "delivery.bounced"],
"enabled": true,
"description": "Production webhook",
"authType": "hmac",
"bearerTokenSet": false,
"mailboxIds": null,
"createdAt": "2026-03-01T12:00:00Z",
"updatedAt": "2026-03-01T12:00:00Z"
}Save the secret -- you will need it to verify signatures. It is only returned on create.
mailboxIds lists the mailboxes whose events the webhook receives. null means every mailbox. See Mailbox-scoped keys.
The bearerToken you supply is write-only -- subsequent GET and LIST calls return bearerTokenSet: true but never the plaintext token. To rotate, send a new value via PATCH.
URL requirements
url must be a public http:// or https:// address. It's rejected (400, both on create and update) if it embeds credentials, or if it resolves -- as a literal IP or via DNS -- to a private, loopback, link-local, or otherwise internal/reserved address, including cloud metadata endpoints like 169.254.169.254. This is re-checked on every delivery, not just at registration, and redirects are re-validated the same way (up to 3 hops) rather than followed blindly.
Authentication
Choose how Molted Email authenticates webhook deliveries to your endpoint:
authType | Headers sent | When to use |
|---|---|---|
hmac (default) | X-Molted-Signature | Recommended for most cases. Verifies the request came from Molted and the payload wasn't tampered with. |
bearer | Authorization: Bearer <token> | Your endpoint already validates a static bearer token (e.g. behind an API gateway). Simpler to verify, but only proves the sender — not payload integrity. |
both | Both headers | Defense in depth — your endpoint can check bearer at the edge and HMAC in the handler. |
molted webhooks create \
--url https://example.com/hooks/molted \
--auth-type bearer \
--bearer-token "tok_a1b2c3..."curl -X POST https://agentic-mailbox.molted.email/v1/agent/webhooks?tenantId=TENANT_ID \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/molted",
"authType": "bearer",
"bearerToken": "tok_a1b2c3..."
}'Bearer endpoints must use https:// URLs. Plaintext tokens in Authorization headers over HTTP would be trivially sniffable.
Rotating a bearer token
molted webhooks update --id ENDPOINT_ID --bearer-token "tok_new_value"To clear a bearer token (when switching back to hmac only), pass the literal string null:
molted webhooks update --id ENDPOINT_ID --auth-type hmac --bearer-token nullVerifying bearer auth on your endpoint
app.post('/hooks/molted', (req, res) => {
const auth = req.header('Authorization');
const expected = `Bearer ${process.env.MOLTED_WEBHOOK_TOKEN}`;
// Use a constant-time comparison to avoid timing attacks
if (!auth || !crypto.timingSafeEqual(Buffer.from(auth), Buffer.from(expected))) {
return res.status(401).end();
}
// ... process event
res.status(200).end();
});Mailbox-scoped keys
A webhook created with a key scoped to specific mailboxes only receives events from those mailboxes: the ones the key can both read and send on. Its mailboxIds is set to them when it's created and can't be changed or cleared afterwards, by any key. Events that don't belong to one mailbox aren't delivered to it. A key with no mailbox it can send on gets 403 mailbox_scope_denied.
The filter belongs to the webhook, not to the key. Narrowing or revoking the key that created it doesn't change or remove the webhook: it keeps receiving the mailboxes it was created with. To change or remove it after that, use a key with access to every mailbox, or the portal.
A scoped key also only sees and manages webhooks limited to its own mailboxes. Listing returns only those; getting, updating, deleting, testing or reading the deliveries of any other webhook (including one with mailboxIds: null, which receives every mailbox's events) returns 404. Updating, deleting and testing need the webhook's mailboxes to be ones the key can send on.
A webhook created with a key that has access to every mailbox, or in the portal, has mailboxIds: null and receives events from every mailbox, as before.
Event types
| Event | When it fires |
|---|---|
inbound.received | New inbound email stored |
inbound.classified | Inbound email classified. data: { classificationId, platformLabel, autoReplyKind, intent, confidence } (see Platform labels; intent is deprecated) |
inbound.routed | Inbound email routed to a thread |
inbound.released | A message held for the used-up inbound allowance reached your agents (see Billing) |
delivery.sent | Email sent to provider |
delivery.delivered | Email confirmed delivered |
delivery.bounced | Email bounced (triggers auto-suppression) |
delivery.complained | Spam complaint received (triggers auto-suppression) |
thread.labeled | A label was applied to a thread. data: { threadId, labelId, labelName, mailboxId, actor: { type, id } } |
thread.unlabeled | A label was removed from a thread. Same data |
label.deleted | A label was deleted. data: { labelId, labelName, mailboxId, actor, affectedThreads } |
Engagement events (delivery.opened, delivery.clicked) are intentionally not delivered to webhook endpoints — they are high-volume and available on the event stream instead.
Payload format
Events are sent as HTTP POST requests with the following headers:
| Header | Sent when | Description |
|---|---|---|
Content-Type | Always | application/json |
X-Molted-Event | Always | Event type (e.g. inbound.received) |
X-Molted-Delivery | Always | Unique delivery ID |
X-Molted-Signature | authType is hmac or both | HMAC-SHA256 signature of the raw body |
Authorization | authType is bearer or both | Bearer <your token> |
{
"event": "inbound.received",
"timestamp": "2026-03-22T10:00:00Z",
"data": {
"messageId": "...",
"mailboxId": "...",
"from": "sender@example.com",
"to": "you@yourdomain.com",
"receivedAt": "2026-03-22T10:00:00Z"
}
}inbound.received fires as soon as the message is stored, before classification -- its payload deliberately excludes subject and body content, since that content hasn't been scanned yet. Subscribe to inbound.classified for the classification result, then fetch the message content once it's been classified. Until then, the read endpoints hide the content and mark the message pending_classification: true (see Inbound: Pending classification).
Verifying signatures
Every webhook payload is signed with your endpoint's secret using HMAC-SHA256. Always verify the signature before processing:
const crypto = require('crypto');
function verifyWebhook(rawBody, signatureHeader, secret) {
const signature = crypto
.createHmac('sha256', secret)
.update(rawBody) // must be raw bytes, not parsed JSON
.digest('hex');
const expected = signatureHeader.replace('sha256=', '');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}Retries and delivery
- Timeout: 5 seconds per attempt
- Max attempts: 5
- Backoff: Exponential (10s, 20s, 40s, 80s between attempts, roughly 2.5 minutes total)
- Success: HTTP 2xx response
- Failure: After 5 failed attempts, the delivery is marked as
failed
Managing endpoints
List endpoints
molted webhooks listGet endpoint details
molted webhooks get --id ENDPOINT_IDReturns the endpoint object with a secretPrefix (first 16 characters of the secret). The full secret is only returned once, in the response to create.
Update an endpoint
molted webhooks update --id ENDPOINT_ID --enabled falseUpdatable flags: --url, --events, --enabled, --description, --auth-type, --bearer-token.
Delete an endpoint
molted webhooks delete --id ENDPOINT_IDView delivery history
molted webhooks deliveries --id ENDPOINT_IDReturns the 50 most recent deliveries with status (pending, delivered, failed), HTTP status code, and attempt count.
Send a test event
molted webhooks test --id ENDPOINT_IDSends a test event to your endpoint URL and returns the result:
{
"success": true,
"httpStatus": 200,
"deliveryId": "..."
}Local development
molted webhooks listen has been removed: molted.email can't deliver webhooks to a localhost URL (the SSRF guard rejects loopback/private registration targets, and the hosted service has no route back to your machine either way). Use molted listen to stream events over SSE during development instead:
molted listen --events inbound.receivedTo test your own webhook handler end to end, expose it with a public tunnel (for example cloudflared tunnel or ngrok) and register the tunnel URL with molted webhooks create. Deliveries then arrive signed, exactly as in production.