MOLTED EMAIL

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

CLI (recommended)
molted webhooks create --url https://example.com/hooks/molted --events inbound.received,delivery.bounced
curl
curl -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

FieldTypeRequiredDescription
urlstringYesThe HTTPS URL to receive events.
eventsstring[]NoEvent types to subscribe to. Omit or pass [] to receive all events.
descriptionstringNoA label for this endpoint.
authType'hmac' | 'bearer' | 'both'NoAuthentication mode. Defaults to hmac. See Authentication.
bearerTokenstringConditionalRequired when authType is bearer or both. Sent as Authorization: Bearer <token>.

Response

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:

authTypeHeaders sentWhen to use
hmac (default)X-Molted-SignatureRecommended for most cases. Verifies the request came from Molted and the payload wasn't tampered with.
bearerAuthorization: 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.
bothBoth headersDefense in depth — your endpoint can check bearer at the edge and HMAC in the handler.
CLI — create with bearer auth
molted webhooks create \
  --url https://example.com/hooks/molted \
  --auth-type bearer \
  --bearer-token "tok_a1b2c3..."
curl — create with bearer auth
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

CLI
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:

CLI
molted webhooks update --id ENDPOINT_ID --auth-type hmac --bearer-token null

Verifying bearer auth on your endpoint

Node.js / Express
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

EventWhen it fires
inbound.receivedNew inbound email stored
inbound.classifiedInbound email classified. data: { classificationId, platformLabel, autoReplyKind, intent, confidence } (see Platform labels; intent is deprecated)
inbound.routedInbound email routed to a thread
inbound.releasedA message held for the used-up inbound allowance reached your agents (see Billing)
delivery.sentEmail sent to provider
delivery.deliveredEmail confirmed delivered
delivery.bouncedEmail bounced (triggers auto-suppression)
delivery.complainedSpam complaint received (triggers auto-suppression)
thread.labeledA label was applied to a thread. data: { threadId, labelId, labelName, mailboxId, actor: { type, id } }
thread.unlabeledA label was removed from a thread. Same data
label.deletedA 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:

HeaderSent whenDescription
Content-TypeAlwaysapplication/json
X-Molted-EventAlwaysEvent type (e.g. inbound.received)
X-Molted-DeliveryAlwaysUnique delivery ID
X-Molted-SignatureauthType is hmac or bothHMAC-SHA256 signature of the raw body
AuthorizationauthType is bearer or bothBearer <your token>
Payload body
{
  "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:

Node.js
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

CLI
molted webhooks list

Get endpoint details

CLI
molted webhooks get --id ENDPOINT_ID

Returns 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

CLI
molted webhooks update --id ENDPOINT_ID --enabled false

Updatable flags: --url, --events, --enabled, --description, --auth-type, --bearer-token.

Delete an endpoint

CLI
molted webhooks delete --id ENDPOINT_ID

View delivery history

CLI
molted webhooks deliveries --id ENDPOINT_ID

Returns the 50 most recent deliveries with status (pending, delivered, failed), HTTP status code, and attempt count.

Send a test event

CLI
molted webhooks test --id ENDPOINT_ID

Sends a test event to your endpoint URL and returns the result:

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

CLI
molted listen --events inbound.received

To 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.