> ## Documentation Index
> Fetch the complete documentation index at: https://docs.12m.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Reacting to replies

> Push (webhooks) and pull (events stream) — same data, two delivery modes. Pick one or use both.

Once you've sent a message, you want to know when something happens
back. 12m gives you two ways to listen, and they share the same log.

## Push: webhooks

Configure one URL per org under **Developers → Webhooks**. We POST to
that URL every time an event lands on any identity in your org. The
payload is the standard event shape; the headers carry the signature
you verify against your shared secret.

```http theme={null}
POST /your-receiver
Content-Type:        application/json
X-Inboxbase-Event-Type:    email.replied
X-Inboxbase-Event-Id:      evt_01HF...
X-Inboxbase-Signature:     t=1777278929,v1=<hex(hmac_sha256(secret, "<t>.<body>"))>

{ "id": "evt_01HF...", "seq": 43, "type": "email.replied", "data": { ... } }
```

A minimal Node verifier:

```js theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

function verifyInboxbase(sigHeader, body, secret) {
  const parts = Object.fromEntries(
    sigHeader.split(",").map((p) => p.split("=")),
  );
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
  const expected = createHmac("sha256", secret)
    .update(`${parts.t}.${body}`)
    .digest("hex");
  return timingSafeEqual(
    Buffer.from(expected, "hex"),
    Buffer.from(parts.v1, "hex"),
  );
}

app.post("/12m", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifyInboxbase(req.header("X-Inboxbase-Signature"), req.body, process.env.WHSEC)) {
    return res.status(401).end();
  }
  const event = JSON.parse(req.body.toString());
  // handle event.type ...
  res.status(200).end();
});
```

Three details that matter at scale:

* **Reject anything older than \~5 minutes.** The timestamp `t` in the
  signature is what protects you against replay; without a window
  check, a leaked old payload is a forever-valid request.
* **Use a constant-time compare.** `timingSafeEqual`, not `===`.
* **Always read the raw body.** If your framework auto-parses JSON,
  the signature won't verify because the bytes shifted under you.
  Express `raw()`, Fastify `addContentTypeParser` with `parseAs:
  'buffer'`, etc.

Retries: 3 attempts at `0s / 5s / 30s` after the trigger; per-attempt
timeout 10 seconds. Return any 2xx for success. Return `410 Gone` if
the endpoint is dead and you want us to pause delivery (you re-set
from the dashboard).

## Pull: the events stream

For environments where you can't expose a public URL — agents, MCP
servers, dev environments behind a firewall, anything running on a
laptop — you pull from the same log.

```bash theme={null}
curl "https://api.inboxbase.ai/v1/identities/alice.acme@inboxbase.ai/events?since=42&timeoutMs=25000" \
  -H "Authorization: Bearer sk_live_..."
```

Response:

```json theme={null}
{
  "identity":   "alice.acme@inboxbase.ai",
  "events":  [/* events with seq > 42 */],
  "cursor":  43,
  "hasMore": false
}
```

You pass `cursor` back as the next call's `since`. The same event is
never returned twice on the same connection.

`timeoutMs` is the long-poll window. Up to 25 seconds, default 0 (no
long-poll, return immediately whether or not anything was new). With
long-poll on, the call returns immediately once something appends to
the log; if nothing arrives within the window, you get an empty
response and you call again. A typical loop:

```js theme={null}
let cursor = 0;
while (running) {
  const r = await fetch(`...&since=${cursor}&timeoutMs=25000`);
  const { events, cursor: next } = await r.json();
  for (const ev of events) await handle(ev);
  cursor = next;
}
```

That loop is everything. No state machine. No backoff (we hold the
connection; you don't busy-wait).

## Using both

Webhooks for primary delivery, events stream for recovery. If your
receiver was down, you missed events. On startup, before resuming
normal operation, walk the stream from the last `seq` you persisted:

```js theme={null}
let cursor = await db.getLastWebhookSeq();
while (true) {
  const r = await fetch(`...&since=${cursor}&limit=200`);
  const { events, cursor: next, hasMore } = await r.json();
  for (const ev of events) await handle(ev);
  cursor = next;
  if (!hasMore) break;
}
await db.setLastWebhookSeq(cursor);
// resume normal webhook handling
```

Because both delivery modes carry the same `seq`, dedup is one line:
`if (seenSeqs.has(ev.seq)) continue;`

## Picking the right granularity

The four event types we emit map cleanly onto the actions a sender
typically takes:

| Event            | Typical action                                                  |
| ---------------- | --------------------------------------------------------------- |
| `email.sent`     | Update your "in-flight" counter. Persist the `convId`.          |
| `email.received` | A cold inbound (not a reply). Route to a triage queue or agent. |
| `email.replied`  | Halt the sequence; route to a human or model.                   |
| `email.no_reply` | Advance to the next step or mark exhausted.                     |

You almost always want all four. Filter only when you have a specific
reason — e.g. an agent that only cares about replies on its own conv.

## Idempotency and ordering

Events are delivered at-least-once via webhook (because retries) and
at-most-once per cursor advance via pull. In practice that means:

* **Dedup by `id` or `seq`**, store one of them with whatever side
  effect your handler did. The next time the same event lands, skip.
* **Order is monotonic per identity.** `seq` is strictly increasing. You
  will never see seq 44 before seq 43 on the same identity.
* **Order is not monotonic across identities.** If you handle multiple
  identities in one process, treat each `seq` as identity-local.
