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.
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", … } }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.
| Method | Path | Auth | Does |
|---|---|---|---|
| GET | /api/topics | session | List identities |
| POST | /api/topics | session | Create an identity: name, address |
| GET | /api/topics/:id | session | Messages, with extractions, and destinations |
| POST | /api/topics/:id | session | Add a destination: webhook (signed or public), email or mailbox |
| POST | /api/inbound | bearer INBOUND_WEBHOOK_SECRET | Receive one email |
| POST | /api/outbox/drain | bearer CRON_SECRET | Retry 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.
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 payloadExtraction
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.
| Method | Path | Auth | Does |
|---|---|---|---|
| otp | 4–8 digits, or letters + digits | high / medium | High when a keyword such as code, passcode or verify precedes it |
| auth_link | magic, login, sign-in, auth | high | Sign-in and magic links |
| confirmation_link | confirm, verify, activate | high | Email and account confirmation |
| password_reset_link | reset, forgot, recover | high | Password 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.
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