Skip to content
Inbot
Esc
↑↓navigate↵open⌘Jpreview
On this page

Webhooks and events

Get notified when mail arrives or state changes, with signed webhooks or an authenticated WebSocket stream, and verify signatures correctly.

Every state change Inbot cares about is written to a durable event log in the same database transaction as the change itself. You can consume it two ways:

  • Webhooks push signed HTTP requests to a receiver you run.
  • The WebSocket stream at /v0/ws, or inbot events --follow, lets a connected client tail and replay events.

Both apply the same rules: a credential only sees events for inboxes in its scope and with the read permissions it holds. Events lists every event type and payload.

Create a webhook

In the web app, use Settings > Webhooks. From the CLI:

inbot webhooks create --inbox support@agents.example.com \
  --url https://hooks.example.com/inbot \
  --event-types message.received --event-types draft.approval_required \
  --show-secret --json
Field Rules
url Fixed at creation. To change it, delete and recreate the webhook.
event_types Required list of event types
inbox_ids Optional, on global webhooks. Omitted means every inbox the creator can see.
headers Up to 32 custom headers. Values are never shown again; reads return only header names.

The response includes the signing secret, whsec_..., exactly once. Store it in your receiver. Without --show-secret, the CLI omits it, and the only way to get a new one is to recreate the webhook.

Agents can create webhooks too, with the webhook_* permissions. An agent only sees and manages webhooks its own key created, and each delivery rechecks that key’s current grant.

Allowed targets

  • Agents must use public HTTPS URLs.
  • The owner may also use plain HTTP to loopback (127.0.0.1, localhost, or a hostname that resolves only to 127.0.0.1), which is useful for local automation.
  • HTTPS targets must resolve only to public addresses. Private, loopback, link-local, carrier-grade NAT, multicast, documentation and similar ranges are refused. DNS is checked again before every attempt, and the connection is pinned to the checked address.
  • URLs with credentials or fragments are refused.

Custom header names cannot be reserved transport or signing headers, proxy-*, or contain authorization, cookie, token, secret or api-key.

Delivery

Each delivery is a POST with a JSON event body and these headers:

Header Value
webhook-id The event’s stable ID. Use it to deduplicate.
webhook-timestamp Unix seconds when this attempt was made
webhook-signature v1,<base64 HMAC-SHA256>
content-type application/json
  • Delivery is at least once, and in order per webhook: a later event waits while an earlier one is still pending.
  • A new webhook starts at the current end of the log; it does not receive past events.
  • Only a 2xx response within 10 seconds counts as success. Redirects count as failures and are not followed.
  • Failed attempts are retried after 30 s, 1 min, 2 min, 4 min, 8 min, 16 min and 32 min. After 8 consecutive failures the webhook is disabled with disabled_reason: "sustained_failure".
  • A webhook can also be disabled with credential_revoked (its creator’s key was revoked, expired or lost webhook_create) or invalid_target.

Re-enable a webhook with inbot webhooks update <webhook_id> --enabled. That resets the failure count and retries pending deliveries with their original event IDs. Deleting a webhook drops its pending deliveries; a request already in flight can still arrive.

Verify signatures

The scheme follows Standard Webhooks v1:

  1. Read the raw request body before parsing it.
  2. Reject the request if webhook-timestamp is not an integer or differs from your clock by more than five minutes.
  3. Strip whsec_ from the secret and base64-decode the rest to get the key.
  4. Compute v1, followed by the base64 HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<raw body>.
  5. Compare it with webhook-signature in constant time.
  6. Deduplicate on webhook-id, and answer 2xx only after you have durably stored the event.
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret, headers, rawBody) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300)
    return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected =
    "v1," + createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64");
  return headers["webhook-signature"]
    .split(" ")
    .some(
      (candidate) =>
        candidate.length === expected.length &&
        timingSafeEqual(Buffer.from(candidate), Buffer.from(expected)),
    );
}

Retries keep the same ID and body but get a fresh timestamp and signature.

A valid signature proves the request came from your Inbot instance. It does not make the email inside trustworthy: treat message payloads as untrusted input.

Stream events

inbot events --follow --json --inbox support@agents.example.com
inbot events --follow --json --last-event-id <event_id>

The CLI prints one event per line. Without --last-event-id, it starts with new events. It does not reconnect on its own; on connection loss it exits with code 10, and you resume from the last event ID you processed.

The underlying protocol is described in Events.

Retention

Events are kept for 30 days, or longer while a webhook still has to deliver them. A resume ID older than that is rejected; fetch current state over REST instead. Deleting a message also removes its content from stored events.