Webhooks API

Create, manage, and inspect webhook endpoints and delivery history.

GET /v1/webhooks

GET/v1/webhooks

List all webhooks. Scoped keys only see webhooks for in-scope mailboxes. The secret is never included in list responses.

response — 200 OK
{
  "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

POST/v1/webhooks

Request body

NameTypeDescription
url*stringHTTPS endpoint URL (no localhost/private IPs)
events*string[]Event types to subscribe to
mailboxIdstringScope to a mailbox. Required for scoped API keys.
headersobjectCustom 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.received
  • message.sent
  • message.delivered
  • message.bounced
  • message.complaint
request with custom headers
{
  "url": "https://example.com/webhook",
  "events": ["message.received"],
  "headers": {
    "Authorization": "Bearer your-token",
    "X-Custom-Route": "inbox"
  }
}
response — 201 Created
{
  "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:

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

  1. 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.
  2. 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.

Express + Postgres example
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

PATCH/v1/webhooks/:id

Request body

NameTypeDescription
urlstringUpdated endpoint URL
eventsstring[]Updated event list
status"ACTIVE" | "PAUSED"Pause or reactivate
headersobject | nullUpdated 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

DELETE/v1/webhooks/:id
response — 200 OK
{ "deleted": true }

GET /v1/webhooks/:id/deliveries

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.

response — 200 OK
{
  "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 retry
  • DELIVERED — Endpoint returned 2xx
  • FAILED — 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.