Skip to main content
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.
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:
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.
curl "https://api.inboxbase.ai/v1/identities/alice.acme@inboxbase.ai/events?since=42&timeoutMs=25000" \
  -H "Authorization: Bearer sk_live_..."
Response:
{
  "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:
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:
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:
EventTypical action
email.sentUpdate your “in-flight” counter. Persist the convId.
email.receivedA cold inbound (not a reply). Route to a triage queue or agent.
email.repliedHalt the sequence; route to a human or model.
email.no_replyAdvance 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.