Quickstart

An identity is a persistent address. Mail sent to acme-billing@ any domain routed to Sendir, or to a plus-tagged variant like acme-billing+run-42@, lands in that identity. Create one, attach a webhook, and every message reaches your software with its code already extracted.

1 · Create an identity
curl -X POST $SENDIR_URL/api/topics \
  -H 'content-type: application/json' \
  -H "cookie: __Secure-better-auth.session_token=$SESSION" \
  -d '{"name": "Acme billing login", "address": "acme-billing"}'

# 201 → { "topic": { "id": "7d1e…", "address": "acme-billing", … } }
2 · Send its mail to your webhook
curl -X POST $SENDIR_URL/api/topics/7d1e… \
  -H 'content-type: application/json' \
  -H "cookie: __Secure-better-auth.session_token=$SESSION" \
  -d '{"name": "otp-worker", "targetType": "webhook",
       "target": "https://your-app.com/hooks/sendir", "signing": "signed",
       "filter": {"from": "vendor\\.com$"}}'

# 201 → { "subscription": { "id": "…", "signingSecret": "whsec_…", … } }

Or do both from the Identities console, and use Send test email to run a message through the real pipeline.

API

This preview exposes identities under /api/topics (the internal name) and authenticates management calls with the console session cookie. Every call is scoped to your organization: another organization's identity answers 404. Scoped API keys and the versioned /v1 surface are on the roadmap. Errors are JSON { "error": "…" } with 400, 401, 403 (account waiting for approval), 404, 409 or 422; unexpected failures return a bare 500 and never echo internals.

MethodPathAuthDoes
GET/api/topicssessionList identities
POST/api/topicssessionCreate an identity: name, address
GET/api/topics/:idsessionMessages, with extractions, and destinations
POST/api/topics/:idsessionAdd a destination: webhook (signed or public), email or mailbox
POST/api/inboundbearer INBOUND_WEBHOOK_SECRETReceive one email
POST/api/outbox/drainbearer CRON_SECRETRetry deliveries that are due

Inbound

Point your inbound provider at /api/inbound. It accepts Resend inbound events and a generic JSON shape. The recipient's local part picks the identity; a plus tag is ignored for routing. Retries are safe: the provider event id, then the message id, is the idempotency key.

Generic payload
curl -X POST $SENDIR_URL/api/inbound \
  -H "authorization: Bearer $INBOUND_WEBHOOK_SECRET" \
  -H 'content-type: application/json' \
  -d '{"from": "security@vendor.com", "to": "acme-billing+run-42@your-domain.com",
       "subject": "Your sign-in code", "text": "Your code is 482913.",
       "idempotencyKey": "provider-event-id"}'

# 201 new · 200 duplicate · 404 no such identity · 422 unrecognized payload

Extraction

Every message is parsed deterministically from its subject and text part, or text recovered from HTML. The same results appear in webhook payloads, in the API and in the inbox. Results carry a confidence and, when the message states one ("expires in 10 minutes"), an expiresAt. Extracted values are treated as secrets and are never written to logs.

MethodPathAuthDoes
otp4–8 digits, or letters + digitshigh / mediumHigh when a keyword such as code, passcode or verify precedes it
auth_linkmagic, login, sign-in, authhighSign-in and magic links
confirmation_linkconfirm, verify, activatehighEmail and account confirmation
password_reset_linkreset, forgot, recoverhighPassword reset

Webhooks

Each matching destination gets a message.received POST with the message and its extractions. 2xx is success. 429 and 5xx retry with exponential backoff (5s × 2ⁿ with jitter, capped at 30 minutes) for up to 8 attempts, then the delivery is dead-lettered. Redirects and other 4xx responses are not retried. Targets must be public https; private and link-local addresses are refused at creation and again at delivery.

Each webhook destination is signed (the default) or public. A signed destination gets its own whsec_… secret, returned when it is created and revealable in the console; its deliveries carry x-sendir-signature, the hex HMAC-SHA256 of {x-sendir-timestamp}.{raw body}. Verify it against the raw body before parsing. A public destination receives the same payload unsigned: use it only where the receiver authenticates the request another way.

Verify a delivery (Node)
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifySendir(body: string, headers: Headers, secret: string): boolean {
  const timestamp = headers.get("x-sendir-timestamp") ?? "";
  const signature = headers.get("x-sendir-signature") ?? "";
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // replay window
  const expected = createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex");
  return signature.length === expected.length && timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

Roadmap

Not built yet. Listed so nothing above is mistaken for it.

  • Versioned /v1 API: cursor pagination, rate limits, consistent envelopes, OpenAPI spec
  • Scoped, hashed, revocable API keys, including keys limited to one identity
  • Verification sessions: wait for the next matching message for one job, never an older one
  • TypeScript SDK with sendir.verifications.wait()
  • Cloudflare Email Routing ingest, raw MIME in private R2 storage, attachments
  • Team organizations: invites and roles (today every account has one personal organization)
  • Custom domains, identity metadata and external ids
  • Webhook secret rotation, delivery history, manual replay, event log