Skip to content
AI Inbx
Esc
navigateopen⌘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.

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.

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

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.

Rotating the secret

Rotation 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,
  })

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 expect a gap.

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, use a tunnel rather than localhost.

Last updated on September 12, 2026

Was this page helpful?