Webhooks API
Create, manage, and inspect webhook endpoints and delivery history.
GET /v1/webhooks
List all webhooks. Scoped keys only see webhooks for in-scope mailboxes. The secret is never included in list responses.
{
"webhooks": [
{
"id": "d4e5f6a7-89ab-4cde-f012-444444444444",
"url": "https://example.com/webhook",
"mailboxId": null,
"events": ["message.received", "message.bounced"],
"headers": { "Authorization": "••••", "X-Custom-Route": "••••" },
"status": "ACTIVE",
"failureCount": 0,
"lastTriggeredAt": "2026-03-11T12:00:00.000Z",
"createdAt": "2026-03-11T00:00:00.000Z"
}
]
}Custom header values are redacted in GET responses. Full values are only returned when creating or updating a webhook.
Alternative:If your agent can't receive webhooks (behind a firewall, NAT, or no public URL), use the Events (SSE) API for real-time streaming instead.
POST /v1/webhooks
Request body
| Name | Type | Description |
|---|---|---|
| url* | string | HTTPS endpoint URL (no localhost/private IPs) |
| events* | string[] | Event types to subscribe to |
| mailboxId | string | Scope to a mailbox. Required for scoped API keys. |
| headers | object | Custom HTTP headers to include in every delivery. JSON object of string key-value pairs. Max 10 headers, key max 256 chars, value max 1024 chars. |
Custom headers
Pass a headers object to include custom HTTP headers in every webhook delivery request. Useful for authentication tokens, routing keys, or other metadata your endpoint needs.
- Maximum 10 headers, key max 256 characters, value max 1024 characters
- Header names must be valid HTTP tokens (letters, digits, and
!#$%'*+-.^_`|~). Names containing spaces, colons, or other non-token characters are rejected. - Header values must not contain control characters (0x00–0x1F, 0x7F). This prevents CRLF injection and ensures deliverability.
- Reserved prefixes blocked: headers starting with
X-Robotomail-are rejected - Dangerous headers blocked:
Host,Content-Length,Content-Type,Transfer-Encoding,Connection,Keep-Alive,Upgrade,TE,Trailer - Custom headers cannot override reserved delivery headers (
Content-Type,X-Robotomail-Signature,X-Robotomail-Event,X-Robotomail-Delivery-Id)
Available events
message.receivedmessage.sentmessage.deliveredmessage.bouncedmessage.complaint
{
"url": "https://example.com/webhook",
"events": ["message.received"],
"headers": {
"Authorization": "Bearer your-token",
"X-Custom-Route": "inbox"
}
}{
"webhook": {
"id": "d4e5f6a7-89ab-4cde-f012-444444444444",
"url": "https://example.com/webhook",
"events": ["message.received"],
"headers": {
"Authorization": "Bearer your-token",
"X-Custom-Route": "inbox"
},
"status": "ACTIVE",
"secret": "a1b2c3d4e5f6..."
}
}The secret is only returned on creation. Use it to verify webhook signatures.
Signature verification
Every delivery includes an X-Robotomail-Signature header. Verify it against the webhook secret using HMAC-SHA256:
expected = HMAC-SHA256(webhook_secret, raw_request_body)
signature = request.headers["X-Robotomail-Signature"]
valid = timing_safe_equal(expected, signature)Always use a timing-safe comparison to prevent timing attacks. The signature is computed over the raw request body string, not parsed JSON.
URL validation
The URL must use HTTPS and cannot target localhost, private IP ranges (10.x, 172.16-31.x, 192.168.x), link-local addresses, or cloud metadata endpoints.
Delivery payload
Every webhook delivery POSTs a JSON body with the structure {event, timestamp, data}. All field names use snake_case. See the Event payloads reference for the full schema of each event type — webhook and SSE payloads are identical.
Webhook delivery is at-least-once
The same webhook event may be delivered multiple times — for example, if a network blip occurs after our infrastructure receives an HTTP 200 from your endpoint but before we record the success in our database, we will retry. To handle this safely, every webhook POST includes an X-Robotomail-Delivery-Id header containing a stable UUID for that delivery. The same delivery ID is sent on every retry; distinct deliveries always have distinct IDs.
Your endpoint must dedupe on this header — but the dedup row and your business logic must commit atomically. Two non-obvious failure modes break naive dedupe:
- Forged-request poisoning. If you store the delivery ID before verifying the webhook signature, an attacker who can guess or scrape a recent ID can POST garbage with that ID, your dedupe insert succeeds, and when the legitimate retry arrives 30 seconds later your unique constraint blocks it. You silently lose a real event.
- Lost-event-on-handler-failure.If you store the delivery ID, then run your handler, and the handler throws (or its DB transaction fails), the dedupe row is committed but the work isn't. The legitimate retry sees the duplicate and returns 200 — and the event is silently dropped, processed by neither attempt.
The correct order is: (1) verify signature, (2) open a transaction, (3) insert the dedupe row, (4) run your handler in the same transaction, (5) commit. If anything in steps 3-4 fails, the transaction rolls back, the dedupe row disappears, and the next retry processes the event from scratch.
import express from "express";
import crypto from "crypto";
// express.raw() preserves the exact bytes for HMAC verification.
// express.json() would re-serialize and break the signature.
app.post(
"/webhooks/robotomail",
express.raw({ type: "application/json" }),
async (req, res) => {
// Step 1: verify the signature against the RAW body bytes BEFORE
// touching any other header.
const signature = req.header("X-Robotomail-Signature");
const rawBody = req.body; // Buffer because of express.raw()
const expected = crypto
.createHmac("sha256", process.env.ROBOTOMAIL_WEBHOOK_SECRET)
.update(rawBody)
.digest("hex");
const sigBuf = Buffer.from(signature ?? "", "hex");
const expBuf = Buffer.from(expected, "hex");
if (
sigBuf.length !== expBuf.length ||
!crypto.timingSafeEqual(sigBuf, expBuf)
) {
return res.status(401).end();
}
const deliveryId = req.header("X-Robotomail-Delivery-Id");
if (!deliveryId) return res.status(400).end();
const event = JSON.parse(rawBody.toString("utf8"));
// Steps 2-5: dedup row + handler in a single transaction.
try {
await db.transaction(async (tx) => {
await tx.query(
"INSERT INTO processed_webhook_deliveries (id) VALUES ($1)",
[deliveryId],
);
// Run your handler INSIDE the same transaction. Pass tx through
// so business writes also roll back together if anything fails.
await handleEvent(event, tx);
});
} catch (err) {
if (err.code === "23505") {
// Unique constraint violation = a previous attempt already
// committed this delivery's row + work. Real duplicate.
return res.status(200).end();
}
// Anything else = handler genuinely failed. Return 5xx so
// Robotomail retries with the same X-Robotomail-Delivery-Id.
throw err;
}
res.status(200).end();
},
);The dedupe table is a one-column schema: CREATE TABLE processed_webhook_deliveries (id UUID PRIMARY KEY, created_at TIMESTAMPTZ NOT NULL DEFAULT now()); You may want to prune rows older than ~24 hours via a periodic job; Robotomail's max retry window is 12h, so anything older than a day cannot be a legitimate retry.
External side effects.If your handler must call third-party APIs or send email — anything that can't be rolled back by the surrounding DB transaction — design those calls to be idempotent on the delivery ID (e.g. include it as a Stripe Idempotency-Key, or as the outgoing email's Message-Id). The local dedupe row protects against double-processing of local DB writes; idempotent external calls protect against double-effect on remote systems.
TL;DR: Verify X-Robotomail-Signature first. Open a DB transaction. Insert the delivery ID and run your handler in the same transaction. Commit. If you also call external systems, make those calls idempotent on the delivery ID.
PATCH /v1/webhooks/:id
Request body
| Name | Type | Description |
|---|---|---|
| url | string | Updated endpoint URL |
| events | string[] | Updated event list |
| status | "ACTIVE" | "PAUSED" | Pause or reactivate |
| headers | object | null | Updated custom headers. Pass null to clear all custom headers. |
The response includes full header values so you can confirm what was saved. Subsequent GET requests redact the values.
DELETE /v1/webhooks/:id
{ "deleted": true }GET /v1/webhooks/:id/deliveries
View recent delivery attempts for a webhook. Returns the last 20 deliveries, newest first. Payload and response body are excluded for security.
{
"deliveries": [
{
"id": "e5f6a7b8-9abc-4def-0123-555555555555",
"event": "message.received",
"responseStatus": 200,
"status": "DELIVERED",
"attempts": 1,
"nextRetryAt": null,
"createdAt": "2026-03-11T12:00:00.000Z"
}
]
}Delivery statuses
PENDING— Awaiting delivery or retryDELIVERED— Endpoint returned 2xxFAILED— All retry attempts exhausted
Inbound-limit stub deliveries
When your account is over its monthly inbound cap, inbound message.received events still fire — but the data object is a stub containing only identity fields (message_id, mailbox_id, mailbox_address, received_at) plus over_limit: true and an account block with inbound_usage + upgrade. There is no new delivery status — stubs flow through PENDING → DELIVERED exactly like full payloads. See Event payloads for the stub shape and 402 INBOUND_LIMIT_EXCEEDED for the paired API-read behaviour.