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

Webhooks

Register an endpoint, choose events, route them by address, and understand what happens when a delivery fails.

Webhooks are how anything that happens after a send reaches your code — a reply arriving, a bounce, a complaint, a mailbox losing its authorization. Everything asynchronous shows up here.

Creating an endpoint

const endpoint = await aiinbx.webhookEndpoints.create({
  url: "https://app.example.com/webhooks/aiinbx",
  subscriptions: [
    "email.received",
    "email.bounced",
    "email.complained",
    "email.unsubscribed",
  ],
})

console.log(endpoint.secret) // returned once — store it now
endpoint = client.webhook_endpoints.create(
    url="https://app.example.com/webhooks/aiinbx",
    subscriptions=["email.received", "email.bounced", "email.complained"],
)

print(endpoint["secret"])
curl https://api.aiinbx.com/api/v2/webhook-endpoints \
  -H "Authorization: Bearer $AI_INBX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://app.example.com/webhooks/aiinbx",
    "subscriptions": ["email.received", "email.bounced", "email.complained"]
  }'

subscriptions is required and must name at least one event type — an endpoint subscribed to nothing would never fire. Subscribe to what you handle; you can always widen later with update. max_concurrency sets how many deliveries may be in flight at once; see Throughput. payload decides whether a message event brings the email with it; see Full payloads.

Managing endpoints requires a full scope key.

The request

Every delivery is a POST with a JSON envelope:

{
  "id": "evt_...",
  "type": "email.received",
  "created_at": "2026-09-01T10:31:04Z",
  "space_id": null,
  "data": {}
}
PropType
idstring

evt_… — stable across retries and replays.

Typestring
typestring

An event type listed in the event reference.

Typestring
created_atstring

When the event happened, RFC 3339.

Typestring
space_idstring | null

The space the resource is in — the email's, thread's, domain's or mailbox's. null is the workspace itself, and all a workspace without spaces ever sees.

Typestring | null
dataobject

Varies by type.

Typeobject
Header Value
Content-Type application/json
AIInbx-Signature t=<unix seconds>,v1=<hmac> — see Verifying
AIInbx-Event-ID The event ID, also id in the body
User-Agent aiinbx-webhooks/1

id is stable across retries of the same event, which makes it the right idempotency key on your side.

Responding

Answer 2xx and answer fast. Anything else — or no answer within 10 seconds — counts as a failure and is retried.

export async function POST(request: Request) {
  const event = await verifyWebhookRequest(request, secret)

  await enqueueOnce(event.id, event) // durable, atomic insert keyed by event ID

  return new Response(null, { status: 204 })
}

enqueueOnce is an application-defined operation: it must durably store the event and atomically deduplicate by event.id. Return 2xx only after storage succeeds. If storage fails, return a non-2xx response so delivery can retry. Process the event in a worker with its own retry policy. See the production guide.

Full payloads

By default a message event is a summary — addresses, subject, snippet, attachment metadata — and the handler fetches the email when it needs the body. An endpoint created with payload: "full" is sent the email along with it, so there is no second call to make:

await aiinbx.webhookEndpoints.create({
  url: "https://app.example.com/webhooks/aiinbx",
  subscriptions: ["email.received"],
  payload: "full",
})
client.webhook_endpoints.create(
    url="https://app.example.com/webhooks/aiinbx",
    subscriptions=["email.received"],
    payload="full",
)
curl https://api.aiinbx.com/api/v2/webhook-endpoints \
  -H "Authorization: Bearer $AI_INBX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://app.example.com/webhooks/aiinbx",
    "subscriptions": ["email.received"],
    "payload": "full"
  }'

email.received and email.sent then carry data.email: exactly the object GET /emails/{id}?include=attachment_content returns — both bodies and their stripped forms, segments, headers, every attachment with its signed download_url, and the prepared text of each document inline as preparation.text. The summary fields stay where they are, so one handler reads both kinds of endpoint. Every other event is the same either way. update switches an existing endpoint, and the change applies from the next attempt — retries of earlier events included.

if (event.type === "email.received") {
  const email =
    event.data.email ?? (await aiinbx.emails.retrieve(event.data.email_id))
  await queueReply(event.data.thread_id, email.stripped_text)
}

Keep that fallback. data.email is null when the email could not be included:

  • The message is gone by the time the delivery is sent — its workspace or space is being deleted. The retrieve answers 404 too.
  • The body would pass 4 MB. No delivery is larger than that, so a server that accepts 4 MB accepts every one; an email too big for it is left out whole rather than cut, and the retrieve returns it. Attachment bytes are never inlined, only their prepared text, so this takes a very long message or a very long document. Check your framework’s limit: Express parses 100 KB by default, and serverless platforms cap a request at around 4.5–6 MB.

The email is read when each attempt is sent, not stored with the event. A retry an hour later therefore carries freshly signed URLs — they are good for an hour from the attempt — and the email as it is then, status and delivery events included. It also means the email is not part of the record: a delivery’s body under Inspecting deliveries and an event read from GET /events show the summary.

Signed URLs open without your API key, so a full body is as sensitive as the mail it carries — verify the signature before trusting it, and keep it out of request logs. An endpoint carried over from the previous aiinbx.com (format: "v1") cannot be full.

Routing

An endpoint can narrow what it receives by sender or recipient — useful when one workspace serves several products or tenants:

await aiinbx.webhookEndpoints.create({
  url: "https://app.example.com/webhooks/support",
  subscriptions: ["email.received"],
  routing: [
    { effect: "allow", field: "to", pattern: "support@example.com" },
    { effect: "block", field: "from", pattern: "*@spam-source.example" },
  ],
})

field is from, to, or either. A from rule matches the sender and any Reply-To address; a to rule matches every recipient — To, Cc and Bcc — so a pattern like *@acme.dev follows that company’s people whether they were addressed, copied or blind-copied. Two rules decide everything:

Block wins

If any block rule matches, the event isn’t delivered — regardless of what the allow rules say.

An empty allow list means no opinion

With no allow rules, everything not blocked comes through. Add one and the endpoint receives only what matches it.

Mailbox events use the mailbox address for both from and to, so address routing also applies to mailbox.* events. Domain events have no addresses to match and bypass address routing; the endpoint must still subscribe to their event type.

Up to 100 rules per endpoint.

Spaces

Every endpoint receives every space’s events, each stamped with its space_id on the envelope — null for the workspace itself. Branch on it to find the customer:

if (event.space_id) {
  const customer = await customerBySpace(event.space_id)
  await queueForCustomer(customer, event)
}

A customer who needs events delivered to a system of their own gets them from your handler, not from an endpoint of their own. Routing rules apply across spaces the same way.

Retries

A failed delivery is retried on a fixed schedule, with ±20% jitter, inside a 24-hour window:

Attempt Roughly after the previous
2 1 minute
3 5 minutes
4 30 minutes
5 2 hours
6 6 hours
7 24 hours, capped at the delivery deadline

Each next attempt is capped at 24 hours after the delivery was created, so the final interval may be shorter than the table shows. After the attempt budget is exhausted, or an attempt fails at or after that deadline, the delivery is marked failed. Inspect and replay failed deliveries after resolving the cause.

If your endpoint returns a Retry-After header, it’s honored instead of the standard backoff — the way to say “I’m degraded, come back in ten minutes” without being hammered.

When nothing sent to an endpoint has got through for 24 hours, the workspace’s owners and admins get an email saying so — once per outage, not per delivery, and only those who have not turned these mails off under Settings → Notifications. The first 2xx ends it.

Throughput

Up to max_concurrency deliveries are in flight to an endpoint at once — 16 by default, anywhere from 1 to 128. Throughput is that number divided by your handler’s latency: at 200 ms a default endpoint takes about 80 events a second, and one set to 128 about 640. A platform fronting thousands of tenants from one endpoint should raise it.

await aiinbx.webhookEndpoints.update("whk_...", { max_concurrency: 64 })

Nothing about order is promised at any setting, including 1 — a retry lands after events that came later, and two events about one thread can arrive in either order. Order by created_at on the envelope, or by the data’s own timestamps, and never by arrival. Setting it to 1 is a way to go easy on a small handler, not a way to sequence events.

Inspecting deliveries

const page = await aiinbx.webhookEndpoints.listDeliveries("whk_...", {
  state: "failed",
})

for (const delivery of page.data) {
  console.log(delivery.event_type, delivery.state, delivery.next_attempt_at)

  for (const attempt of delivery.attempts) {
    console.log(" ", attempt.attempted_at, attempt.status_code, attempt.error)
  }
}
page = client.webhook_endpoints.list_deliveries("whk_...", state="failed")

for delivery in page["data"]:
    print(delivery["event_type"], delivery["state"])
curl "https://api.aiinbx.com/api/v2/webhook-endpoints/whk_.../deliveries?state=failed" \
  -H "Authorization: Bearer $AI_INBX_API_KEY"

Each delivery carries the body the endpoint receives and every attempt — timestamp, status code, duration, error, and the first 2 KB of your response. That last field is usually what tells you why your handler rejected the request.

body is what is stored. For a full endpoint that is the envelope without data.email, which is read from the message each time a delivery is sent and not kept — the console rebuilds it for the delivery you open. body is null in two cases: for an endpoint carried over from the previous aiinbx.com (format: "v1"), whose legacy body is built from the message at send time rather than stored — the console rebuilds it for the delivery you open — and for a delivery nothing was sent for, which such an endpoint settles as failed with no attempts when the message the event was about no longer exists.

Filter by state: pending, delivered, or failed.

Replaying

After you’ve fixed the handler, push the failures back through:

const { data } = await aiinbx.webhookEndpoints.retryDeliveries("whk_...", {
  delivery_ids: failed.map((d) => d.id),
})

Up to 100 per call. A replay carries the same event id as the original, so a handler that deduplicates on it stays correct.

Catching up

A replay needs a delivery to replay. An endpoint that was paused, not yet subscribed to a type, or not yet created has none — but the event is on record all the same. Every event is recorded when it happens, whether or not anything was listening, and GET /events reads the record back: the same envelopes with the same id, for the whole workspace.

To catch up, walk forward from the last event you processed:

let after = await loadCheckpoint() // the last created_at you processed

for await (const event of aiinbx.events.list({
  order: "asc",
  after: overlap(after),
  type: ["email.delivered", "email.bounced", "email.complained", "email.failed"],
})) {
  await enqueueOnce(event.id, event) // the same dedupe your webhook handler uses
  after = event.created_at
}

await saveCheckpoint(after)
after = load_checkpoint()  # the last created_at you processed

for event in client.events.iter(
    order="asc",
    after=overlap(after),
    type=["email.delivered", "email.bounced", "email.complained", "email.failed"],
):
    enqueue_once(event["id"], event)
    after = event["created_at"]

save_checkpoint(after)
curl -G https://api.aiinbx.com/api/v2/events \
  -H "Authorization: Bearer $AI_INBX_API_KEY" \
  -d order=asc \
  --data-urlencode "after=2026-09-01T10:30:00Z" \
  -d type=email.bounced -d type=email.failed

order=asc lists oldest first, after leaves out everything up to and including that instant, and next_cursor continues the walk in the same order until it is null — you have caught up. before closes the window from the other side; the same three parameters sync the email list. type repeats for any of several; email_id narrows to what happened to one email — all but email.unsubscribed, which is about the address that left; space to one space.

overlap is yours to write, and it should step back a minute. created_at is when the event happened, and an event recorded inside a longer write — a send, an inbound message being filed — becomes readable when that write commits, which can be after a later event already was. Reading a minute you have already seen costs nothing when enqueueOnce deduplicates by event.id, and it is what makes the checkpoint safe.

Three things differ from a delivery:

  • data is always the summary. The data.email an endpoint with payload: "full" is sent is read when the delivery goes out and is not kept; retrieve the email by its email_id.
  • Routing rules belong to endpoints, so nothing is filtered by address here.
  • Test events are not listed: nothing happened.

One event by id is GET /events/{event_id} — the evt_… from the envelope, the AIInbx-Event-ID header, or a delivery’s event_id. Any key can read events; read scope is enough.

Used on its own, the same loop on a timer is a way to take events without exposing an endpoint at all.

Testing an endpoint

Fire a synthetic event without waiting for real mail — the fastest way to check a new handler or a tunnel:

const result = await aiinbx.webhookEndpoints.test("whk_...", {
  event_type: "email.received",
  resource_id: "eml_test",
  payload: { thread_id: "thr_test", subject: "Test", from: "test@example.com" },
})

console.log(result.state, result.status_code, result.error, result.response)
result = client.webhook_endpoints.test("whk_...", event_type="email.received")
print(result["state"], result["status_code"])
curl https://api.aiinbx.com/api/v2/webhook-endpoints/whk_.../test \
  -H "Authorization: Bearer $AI_INBX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "event_type": "email.received" }'

The response tells you what your endpoint actually did — status code, duration, and the body it returned. A test event is signed exactly like a real one, so it exercises your verification code too.

A test event is about no message. On a full endpoint, the data.email of an email.received or email.sent test is the email you put in payload, or a sample email when you put none — so a test shows the whole shape either way.

Rotating the secret

The secret is shown once, when the endpoint is created, and never read back. Rotation is how you get another — because it was lost, because it leaked, or on a schedule. It issues a new secret while the old one keeps verifying for a grace period, so you can deploy without dropping events:

const { secret, previous_secret_expires_at } =
  await aiinbx.webhookEndpoints.rotateSecret("whk_...", {
    grace_seconds: 86_400,
  })

The new secret is in the response, once, like the first. During the grace period every delivery is signed with both secrets — two v1= values in AIInbx-Signature — so a verifier holding either one passes, and the endpoint’s previous_secret_expires_at says when that ends. grace_seconds defaults to 24 hours and can be up to 7 days. Set it to 0 to cut over immediately — only when a secret has leaked and the gap matters less than the exposure.

Rotate with a grace period

Both secrets verify.

Deploy the new secret

Your handler switches over.

Let the grace period lapse

The old secret stops working on its own.

Pausing an endpoint

await aiinbx.webhookEndpoints.update("whk_...", { enabled: false })

A disabled endpoint receives nothing and accrues nothing — events that occur while it’s off are not queued for later. Prefer it to deleting when you’re taking a service down for maintenance, and catch up from the events API when it is back.

Endpoint URLs

Endpoint URLs must be publicly reachable over HTTPS. Requests to private and loopback addresses are refused — including via redirect — so a URL that resolves inward can’t be used to reach into infrastructure.

In development, run aiinbx listen instead. It forwards your workspace’s events to localhost, signed with the same scheme, with no endpoint to register and no tunnel.

Last updated on October 1, 2026

Was this page helpful?