Inbin
BuildGalleryPulsePricingDocsGuidesResearchAbout
Sign in
☰
BuildGalleryPulsePricingDocsGuidesResearchAbout
Reference
AuthenticationResourcesArchitectureInboxesApplicationSchemasEventsIngestConfirmationsQueryWebhook deliveryMCPRestricted inboxesFailure modes

API reference

Minimal REST. A small, versioned surface. No GraphQL, no realtime. Base URL https://api.inbin.dev/v1

Authentication

Every request carries your application’s API key as a bearer token. Keys are issued at onboarding and can be rotated without downtime. Create a new key, migrate, revoke the old one.

curl https://api.inbin.dev/v1/inboxes \
  -H "Authorization: Bearer ink_live_8we46jinhibs365yhztpo..."

Or use the official SDK, which wraps every endpoint and webhook verification:

npm i @inbin/core

import { Inbin } from "@inbin/core";
const inbin = new Inbin({ apiKey: process.env.INBIN_API_KEY });
const inbox = await inbin.inboxes.create({ name: "flight-deals" });

Resources

Four objects. Nothing more.

ApplicationThe tenant. Owns inboxes, schemas, webhooks, API keys.
InboxA unique, permanent email address, <slug>@in.inbin.dev.
SchemaWhat to extract. Versioned, monotonic, one active per application.
EventThe output. One per email received; immutable once created.

Architecture

Two objects carry the whole system. An application is the unit of configuration: it owns the extraction schema (versioned), the webhook and its signing secret, your API keys, and its MCP identity. An inbox is the unit of source: many per application, each a permanent address where mail arrives, and the place where the per-stream switches live.

account
└─ application              the unit of configuration
   ├─ schema (versioned)    the shape extraction returns
   ├─ webhook + secret      where events are delivered
   ├─ api keys · mcp        how you and your agents read
   └─ inboxes               the unit of source
      ├─ a3f2c8@in.inbin.dev    webhook_enabled · agent_access
      └─ b7k2m1@in.inbin.dev

The usual pattern is one inbox per sender group (your invoices, your alerts) or one per end customer; more on that below.

The life of an email

email → inbox → event (pending) → extraction → guard
      → webhook delivery → immutable, queryable ledger

Mail to any inbox address becomes exactly one event (duplicate Message-IDs dedupe per inbox at ingest). Extraction runs asynchronously against the application’s schema, the hallucination guarddrops and counts anything the email doesn’t support, and the event is POSTed to the application’s webhook, HMAC-signed and retried on the exponential schedule. Every outcome is an explicit status; nothing fails silently. One deliberate exception to the flow: confirmation emails are detected at ingest, parsed for their code or link, and never extracted or delivered (status: skipped).

Webhook granularity

The webhook is configured at the application; delivery is decided at the inbox. One endpoint per app, and webhook_enabled per inbox turns delivery off for one stream without touching ingestion, extraction, or the ledger, for dashboard-only inboxes and MCP-only personal feeds. Delivery also auto-disables after ten consecutive failures, visibly, and re-enabling resets the streak. The same inbox-level granularity governs the reading side: agent_access is the sibling switch for what agents may see.

The per-customer pattern

app "acme-agent"
└─ inboxes
   ├─ customer-a@in.inbin.dev   agent_access: restricted
   ├─ customer-b@in.inbin.dev   webhook_enabled: false
   └─ customer-c@in.inbin.dev

Products that give each end customer an email capability provision one inbox per customer. Each customer’s stream then carries its own delivery switch and its own agent-access policy, and cross-app shares and views are the only doors out. That composes into per-tenant data governance without building any: the isolation is the architecture, not a feature.

The two read planes

The ledger has three writers, and every event carries its source: email (guard-verified), human (a recorded decision), agent(an unverified assertion). Provenance is structural, so a reader always knows how much to trust what it’s holding. The owner plane (API keys, the dashboard, the webhook) always sees full data; the agent plane (MCP) is governed: agent_accessmasks restricted inboxes, and moving between applications requires the owner’s consent (switch_app). The asymmetry is structural, not policy: the webhook can’t be restricted, and the agent plane can’t widen itself. Between applications, per-event shares and standing views are the only doors, both revocable, audited, and re-checked against agent_access at read time, so restricting an inbox instantly narrows every outstanding grant.

Inboxes

POST/inboxes
GET/inboxes
GET/inboxes/:id
PATCH/inboxes/:id
DELETE/inboxes/:id

Creating an inbox returns its address immediately. Slugs are six characters, globally unique (inboxes are the unit of source). Deleting an inbox orphans its mail flow. Inbin still accepts, then drops. PATCH accepts name and agent_access ("full" | "restricted", see Restricted inboxes).

POST /inboxes  {"name": "chicago-forwards"}

{
  "id": "ibx_a3f2c8",
  "address": "a3f2c8@in.inbin.dev",
  "application_id": "app_1234",
  "created_at": "2026-07-23T20:15:00Z"
}

Application

GET/apps
PATCH/apps
GET/health

The caller’s application: name, webhook URL, plan, usage this month. PATCH accepts name and webhook_url, so CI can point Inbin at a fresh endpoint in the same pipeline that deploys it. The webhook secret is never returned. /health is unauthenticated and checks the database: 200 healthy, 503 not. Point your uptime monitor at it.

Schemas

PUT/schemas
GET/schemas/current

JSON-Schema-like, deliberately constrained: no $ref, no oneOf, no recursion. A PUT replaces the schema and bumps the version. Invalid schemas are rejected at PUT time with a clear error. Old events are never re-parsed, the ledger is immutable. hallucination_guard (default on) drops any extracted value the email does not support: strings must appear verbatim (case-insensitive), numbers in their digits with separators ignored or via a magnitude suffix ($3.5B backs 3500000000; the mantissa is verbatim, the K/M/MM/B/bn/T suffix authorizes the expansion, and 3500000000 drops if the email says $3.6B). Booleans and enum fields are schema judgments: the model selects from the closed list you author, and off-list values still drop. pattern fields get no exemption; a pattern is not a closed set.

PUT /schemas
{
  "extract": {
    "listings": {
      "type": "array",
      "items": {
        "address": { "type": "string", "required": true },
        "city":    { "type": "string", "required": true },
        "state":   { "type": "string", "required": true, "pattern": "^[A-Z]{2}$" },
        "zip":     { "type": "string", "nullable": true, "pattern": "^\\d{5}$" }
      }
    }
  },
  "hallucination_guard": true
}

Events

GET/events?inbox_id&status&type&confirmation&since
GET/events/:id
GET/events/:id/raw?format=text|mime
POST/events/:id/redeliver
POST/events/:id/re-extract
POST/events/:id/shares
DELETE/events/:id/shares
GET/resolve/:id
POST/decisions
GET/decisions?topic
POST/views
GET/views
GET/views/:id/resolve
DELETE/views/:id

One event per email. Every event has a type, what the email IS: message, confirmation, test, and a status, where it is in the pipeline: pending, delivered, skipped, failed_delivery, dead_lettered, extract_failed. delivered always means your webhook received it; intentional non-delivery (confirmation emails, empty extractions, webhook-off inboxes) is skipped. Confirmation emails carry a parsed confirmation: {code, url, kind} and never reach your webhook.

Every event also carries source, its provenance: email (extracted from a real message, guard-verified), human (a decision recorded through the owner plane: your API key or the dashboard), or agent (written over MCP, unverified). Source derives from the channel, never from the claim: an agent passing decided_by: human over MCP records agent-reported attribution, but stays source: agent; no agent can mint the human tier. Recorded events (POST /decisions, or the record_decisionMCP tool) land in the app’s auto-created decisions inbox as type record: they have no raw email behind them (raw access says so honestly), can’t be re-extracted, and can never masquerade as guard-verified data. Filter any list or query by source; source=email means guard-verified only.

The raw email is kept 30 days behind a signed URL, and /raw?format=text returns the parsed plain text your extraction actually read (use it to investigate guard drops). Extracted events are retained 90 days on Free and Builder, 365 days on Growth and Scale. Duplicate Message-IDs dedupe at ingest and return the same event.

Redeliver replays the webhook (attempt number, response code, new status). Re-extract re-runs extraction from the stored raw email, for extract_failed events past the automatic retry cap, or after a schema change.

Sharing: email the pointer, not the payload. When the counterparty you’re emailing is also on Inbin, don’t re-send content: share the event to their inbox address and mail just the event id. The address doubles as discovery: if it isn’t a live Inbin inbox the share fails, and you send raw content as usual. Their agent resolves the id via GET /resolve/:id (or the resolve_eventMCP tool) into the verified, structured event with its extraction metadata and provenance, never your delivery history, raw email, or inbox address. It’s the claim-check pattern, the way Stripe sends thin webhooks, applied to agent email. Grants are per-event, revocable, audited, enforced at read time, and never available on restricted inboxes.

Views: grant a lens, not a list.A view is a STANDING grant carrying a saved query instead of an event id: “my Chicago listings under $200k, live.” The counterparty resolves it any time (GET /views/:id/resolve) and always sees the current verified slice, until you revoke. Because a lens keeps resolving, it is watched: every resolution is audited and rolled into resolve_count / last_resolved_at on the grant, so GET /viewsis your review surface (“who can still see my data, and do they use it?”) and every revocation is reviewable. Restricted inboxes never pass through a lens, so flipping an inbox to restricted narrows every outstanding view instantly.

POST /events/evt_9x8y7z/shares  {"with": "a3f2c8@in.inbin.dev"}

{ "share_id": "shr_…",
  "shared_with": { "application_id": "app_…", "application_name": "Acme Ops" },
  "message": "Event is now resolvable by \"Acme Ops\". Send them the id evt_9x8y7z…" }

If the email carried file attachments, the event also carries an attachments array. Each entry is metadata plus a short-lived, presigned download_url. The raw bytes are never inlined. The content type is sniffed from the file’s own bytes, not the sender’s claimed header, and the download is served as an attachment so a hostile .html or .svg can never execute. Up to 10 files, 25MB each.

{ "id": "evt_9x8y7z",
  "subject": "September invoice",
  "extracted": { … },
  "attachments": [
    { "id": "att_…",
      "filename": "invoice.pdf",
      "content_type": "application/pdf",   // sniffed, not the sender's claim
      "size_bytes": 48213,
      "sha256": "9f2c…",
      "download_url": "https://…s3…?X-Amz-Expires=900" } ] }

Ingest

POST/ingest/raw?inbox=<slug>
POST/backfill

Mail addressed to slug@in.inbin.dev arrives on its own. These two endpoints are for mail that arrives some other way. Both take the full raw RFC-822 message (up to 10MB), are idempotent on Message-Id (a re-POST returns the original event with deduped: true), and run the same extraction, guard, and field_trustpipeline as native mail. SPF/DKIM/DMARC verdicts are read from the message's own Authentication-Results header when the receiving transport stamped one.

POST /ingest/raw is LIVE mail carried over HTTPS instead of SMTP: the body IS the raw message (content-type: message/rfc822), the event is dated by receipt, and your webhook fires. This is the endpoint a Cloudflare Email Worker or an Agents SDK onEmail hook forwards to; see the Cloudflare guide.

curl -X POST "https://api.inbin.dev/v1/ingest/raw?inbox=<slug>" \
  -H "Authorization: Bearer <api-key>" \
  -H "Content-Type: message/rfc822" \
  --data-binary @message.eml

{ "event_id": "evt_...", "status": "pending", "deduped": false }

POST /backfill is HISTORICAL mail: a JSON body {inbox_slug, raw_email, occurred_at?}. The event is dated by its original Date: header (or your override) behind a plausibility gate, marked backfilled: true, and never fires the live webhook: a years-old email must not wake your agent as if it just arrived. Use it to import archives; the events land in the ledger, queryable and readable over MCP like everything else.

Confirmations

GET/confirmations/await?wait&since&inbox_id&expected_sender

Blocking wait for the next confirmation email, the verification flow for agents: submit a signup or forwarding form with your inbox address, then call this. The connection holds up to 90 seconds and resolves with the parsed code and link the moment the email lands; 408 on timeout. expected_sender (a glob like *@vercel.com) doubles as a prompt-injection guard: mail from anyone else cannot satisfy the wait.

GET /confirmations/await?wait=60&expected_sender=*@vercel.com

{ "status": "resolved",
  "confirmation": { "code": "483921", "url": "https://…", "kind": "link+code" },
  "from_address": "verify@vercel.com",
  "event_id": "evt_…" }

Query

POST/query

Your inbox as a table. Filter, sort, project, flatten array records, group and aggregate over everything your schemas extracted. One request, no database on your side. Also available to agents as the query_events MCP tool.

POST /query
{
  "flatten": "deals",
  "where": [{ "field": "price_usd", "op": "lt", "value": 300 }],
  "group_by": "destination_city",
  "aggregate": [{ "fn": "avg", "field": "price_usd", "as": "avg_price" }]
}

{ "rows": [
    { "destination_city": "Lisbon", "avg_price": 274.5 },
    { "destination_city": "Madrid", "avg_price": 289.0 }
  ],
  "row_count": 2 }

Ops: eq, neq, gt, gte, lt, lte, contains, in, exists. Aggregates: count, sum, avg, min, max. Limit caps at 200.

Webhook delivery

When an event lands, Inbin POSTs the event object verbatim to your webhook URL (one endpoint per application, delivery decided per inbox). Verify the HMAC before trusting the body.

Content-Type: application/json
X-Inbin-Event-Id: evt_9x8y7z
X-Inbin-Signature: sha256=...   // HMAC of body with your secret

// verify with @inbin/core
import { verifyWebhook } from "@inbin/core";
const event = verifyWebhook(req.body, req.headers, secret);

Retries back off exponentially. 30s, 2m, 10m, 1h, 6h, 24h. After six failures the event is dead-lettered. Redeliver it from the dashboard or via POST /events/:id/redeliver.

Attachments ride the same payload (see Events): an attachments array of metadata and presigned download_urls, no raw bytes. Each redelivery re-signs a fresh URL, so a link in an old attempt does not outlive its window.

MCP

Listed in the official MCP Registry · @inbin/mcp on npm

Every Inbin application is also an MCP server. Point an agent (Claude, ChatGPT, Cursor, custom) at https://inbin.dev/api/mcp with your Bearer API key and it can browse your parsed events without you writing a client. Transport is JSON-RPC 2.0 over HTTP (Streamable HTTP, MCP 2025-06-18). Inboxes marked restricted expose metadata only on this surface.

list_inboxesInboxes on this app.
list_eventsFilter by inbox / status / since. Cursor paginated.
search_eventsCase-insensitive substring across id / subject / from / to.
get_eventFull event body; pass raw:'text' to include the source email. Each event carries field_trust (which fields are verified vs free text a sender wrote) and sender_auth (SPF/DKIM/DMARC).
query_eventsFilter, sort, group and aggregate over extracted records.
await_confirmationBlock until a verification email lands; returns the parsed code/link.
backfill_emailImport one historical raw email you already have. Runs the same guard, dated by its original Date header, recorded as history so it never fires as a live event. Load your past so an agent has context from day one.
get_applicationApp profile: name, webhook, plan, usage.
share_event / revoke_event_shareMake one event resolvable by another Inbin app (by inbox address), so agents email the id instead of the content. Refused on restricted inboxes.
resolve_eventResolve an id another app shared with this one: verified structured data plus provenance, never their delivery history or raw email.
record_decision / check_decisionsWrite a decision into the ledger (provenance-tagged: human or agent) and look decisions up by topic before re-asking the human.
share_view / resolve_view / list_views / revoke_viewStanding grants: share a saved query (a lens) that keeps resolving to the current verified slice until revoked. Usage-counted, audited per read, restricted inboxes always excluded.
list_appsApps the authorizing account can access. Names and ids only, no data.
switch_appRequest a switch to another app. Revokes the connection and routes through the OAuth consent prompt, unless the target app's owner enabled authenticated switching.
redeliver_eventForce a webhook delivery attempt.
retry_extractionRe-run extraction for an event.
describe_schemaThe current extraction schema for this app.
curl https://inbin.dev/api/mcp \
  -H "Authorization: Bearer ink_live_..." \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Switching applications

An agent connected over OAuth can see the account’s other apps with list_apps (names and ids only) and request a move with switch_app. The switch is an authorization event, not a context change: the call revokes the connection’s tokens immediately, the next call returns 401, and the client prompts the account owner, who picks the app on the consent screen. The call only ever reduces access; it revokes even if the owner declines, so an injected rogue switch achieves nothing except disconnecting itself. Owners can opt an individual app into silent switching from already-authenticated sessions (dashboard toggle, default off; the tokens are still atomically re-scoped and every switch is audited). Never enable that on an app holding a different customer’s data: it would trade the per-tenant isolation for convenience.

Connect from your MCP client

Two ways to talk to Inbin’s MCP surface:

  1. Remote (recommended): point your client at the URL directly with your API key as a Bearer header. No install, no local process.
  2. Local wrapper: install @inbin/mcpfrom npm and run it as a stdio server. Useful for clients that don’t yet support remote HTTP transport.

Claude Desktop

Edit ~/Library/Application Support/Claude/claude_desktop_config.json and add:

{
  "mcpServers": {
    "inbin": {
      "url": "https://inbin.dev/api/mcp",
      "headers": {
        "Authorization": "Bearer ink_live_..."
      }
    }
  }
}

Restart Claude Desktop, then look for the “inbin” tools in the tools menu.

Cursor

Open Cursor Settings → MCP → Add. Paste:

{
  "inbin": {
    "url": "https://inbin.dev/api/mcp",
    "headers": {
      "Authorization": "Bearer ink_live_..."
    }
  }
}

Windsurf, Zed, Cline

Any client that supports MCP’s Streamable HTTP transport takes the same shape. Endpoint + Authorization header. If your client only supports stdio, use the local wrapper:

# In your MCP client's server list
{
  "inbin": {
    "command": "npx",
    "args": ["-y", "@inbin/mcp"],
    "env": {
      "INBIN_API_KEY": "ink_live_..."
    }
  }
}

The wrapper reads INBIN_API_KEY and proxies stdio to the remote MCP endpoint.

Restricted inboxes

Some mail should never be readable by an AI agent: HR notices, billing statements, anything carrying account numbers or verification codes. Set agent_access: "restricted" on an inbox (dashboard toggle or PATCH /inboxes/:id) and the MCP surface stops carrying its content. The rule in one sentence: the wire gets everything; the agent gets what the inbox permits. Your webhook, the dashboard, and the v1 REST API are unaffected.

get_event / list_events / search_eventsMetadata stays visible (subject, from, timestamps, status, type) so agents know the event exists; extracted and confirmation come back as '[restricted]'. Attachment metadata stays, but every download_url is nulled so an agent cannot fetch the file.
get_event rawRefused. The raw body is the unredacted source; there is no maskable form of it.
query_eventsRestricted-inbox events are excluded entirely, including from aggregates: group_by keys and min/max would leak raw values, so we don't offer a partial mode we can't make safe.
await_confirmationRefused. Confirmation codes are credentials; on a sensitive inbox they are exactly what the restriction protects.
redeliver_event / retry_extractionAllowed. Operations, not reads; nothing flows back to the agent beyond status.

Enforcement is read-time, not ingestion-time: the flag is evaluated on every agent read, so flipping an inbox to restricted protects its entire history immediately, including everything that arrived before you flipped it. Since inboxes are the per-customer unit (one inbox per end customer is the standard pattern), each customer’s stream carries its own agent-access policy.

What this is and is not: it governs the agent plane (MCP reads), by construction, at the query layer. It is not storage encryption, and it does not change what your own webhook receives. Owner-plane API keys used outside MCP read full data; treat them like the credentials they are.

Failure modes

WhenWhat happens
Model down / storage hiccupEvent becomes extract_failed with the error recorded; a cron retries up to 5 times over 7 days, and re-extract forces it manually. Null payloads are never delivered as success.
Your endpoint is downThe retry queue holds events; the dashboard shows the backlog.
Broken schemaRejected at PUT time. A broken schema is never accepted.
Duplicate emailDeduped by Message-ID; the webhook fires once.
Provider rate limitsIngest queues; events deliver in order.