# 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](https://robotomail.com/docs/api/events) for real-time streaming instead.


## POST /v1/webhooks

**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.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](https://robotomail.com/docs/concepts/webhooks).

### 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](https://robotomail.com/docs/api/events#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**

| 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

**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](https://robotomail.com/docs/api/events#payloads) for the stub shape and [402 INBOUND_LIMIT_EXCEEDED](https://robotomail.com/docs/api/errors#inbound-limit-exceeded) for the paired API-read behaviour.


---

Previous: [Threads](https://robotomail.com/docs/api/threads.md) | Next: [Events (SSE)](https://robotomail.com/docs/api/events.md)
