# Postbag > Postbag is a form backend that routes. Point any HTML form at a Postbag endpoint; every submission is stored durably, then delivered to email, Telegram and signed webhooks by rules. Multi-tenant, self-hostable, and built for AI agents: one API key is enough to create, verify and route a form without a browser. API base: https://postbag.dev · Agent onboarding: https://postbag.dev/llms.txt · OpenAPI: https://postbag.dev/openapi.json · Site: https://postbag.dev ## Pages - [Routing: many forms, one shape, any destination](https://postbag.dev/features/routing/): Postbag routes form submissions with streams, mappings and routes: group many forms, map their fields onto one versioned schema, and deliver to email, Telegram or webhooks with windows, digests and quality rules. - [Never lose a submission: a durable outbox, not a best-effort send](https://postbag.dev/features/never-lose-a-submission/): Every Postbag submission is stored as a database row before anything else, and delivery is an outbox drained by a worker with retries. Spam, rate limits and schema violations are stored with a status, never dropped. - [Destinations: email, Telegram and signed webhooks](https://postbag.dev/features/destinations/): Postbag delivers form submissions to email (with Reply-To from the submission), Telegram bot chats, and HMAC-SHA256-signed webhooks. Each destination can be tested with a sample payload from the API. - [Schemas and drift: know what your forms actually send](https://postbag.dev/features/schemas-and-drift/): Postbag form schemas are immutable versions with observe, enforce and managed modes. Drift detection tells you when a site starts sending different fields; inference proposes a schema from what is arriving. - [Spam protection that stores, flags and never deletes](https://postbag.dev/features/spam-protection/): Postbag fights form spam with a honeypot, per-form rate limits, origin allowlists and optional Cloudflare Turnstile. Suspicious submissions are stored with a status, excluded from routes by default, and reversible. - [Self-hosting Postbag: one container, one Postgres](https://postbag.dev/features/self-hosting/): Postbag is self-hostable by design: one Docker image (api, worker or both), one Postgres 16 database and a docker-compose file. The hosted product runs the same image. Multi-arch (arm64 and amd64). - [For AI agents](https://postbag.dev/for-ai-agents/): how an agent with only an API key creates, verifies and routes a form. - [Pricing](https://postbag.dev/pricing/): plan limits. - [Compare](https://postbag.dev/compare/): Postbag vs Formspree, Formspark, Getform (Forminit), Basin, Web3Forms, Netlify Forms. - [Glossary](https://postbag.dev/glossary/): the fixed vocabulary. --- # Postbag documentation > Start here: what Postbag is, the three calls that matter, and where everything else lives. Written for humans and for agents; every page has a Markdown twin. Source: https://postbag.dev/docs/ Postbag is a form backend that routes. Websites `POST` to a submit URL; Postbag stores every submission durably and delivers it to email, Telegram and signed webhooks according to routes you configure. It is multi-tenant, self-hostable, and agent-native: everything a human can do in the dashboard, an agent can do with an API key. ## The one idea **The database makes it correct; events make it fast.** A submission is a row before it is anything else. Delivery is an outbox drained by a worker. Spam, quota and rate-limit outcomes are stored with a status, never dropped. ## The three calls that matter 1. `GET /v1/me`: who am I, which organization, which scopes, what already exists. Call this first with your API key. 2. `POST /v1/quickstart`: one call to a working, routed form. Idempotent by (project, name). Returns a submit URL, embed snippets and a verification recipe. 3. `POST /s/{formId}`: submit to a form. No auth. Accepts JSON, urlencoded and multipart (text fields). Pass `_test: true` to get submission and delivery ids back, so you can poll `GET /v1/deliveries/{id}` and see `sent`. ## Where to go next - [Quickstart](/docs/quickstart/): three minutes to your first email. - [Submit endpoint](/docs/submit-endpoint/): every control field, content type and status. - [API overview](/docs/api/): resources, conventions, pagination, idempotency, errors. - [Agent guide](/docs/agents/): the exact flow an agent should follow, and the repo conventions. - [Webhook signatures](/docs/webhooks/): verify `Postbag-Signature` in Node, Python and Go. - [Routing](/docs/routing/), [Schemas](/docs/schemas/), [Destinations](/docs/destinations/). - [Architecture](/docs/architecture/), [Security](/docs/security/), [Self-hosting](/docs/self-hosting/), [Error codes](/docs/errors/). ## For agents The live API describes itself: `GET /llms.txt` is the onboarding page in Markdown and `GET /openapi.json` is generated from the route definitions, so it is always current. Every page on this site also has a Markdown twin: request it with `Accept: text/markdown`, or append `index.md` to the URL path. The whole documentation set is concatenated at [/llms-full.txt](/llms-full.txt). --- # Quickstart: your first form in three minutes > Create a Postbag account, get a submit URL, point an HTML form at it, and receive the first submission by email. Works with plain HTML, fetch, React, Astro and Next.js. Source: https://postbag.dev/docs/quickstart/ The solo-dev test Postbag is held to: time from signup to the first email in your inbox under three minutes, meeting exactly one new noun (Form). ## 1. Create an account and a form Sign up at [/app](/app/sign-up). The first-run screen creates a form and asks for the email that should be notified. You get a submit URL like `https://postbag.dev/s/fm_8f3kq2` and embed snippets. Prefer the API? Mint an API key under API keys and run: ```bash curl -X POST https://postbag.dev/v1/quickstart \ -H "Authorization: Bearer pb_live_…" -H "content-type: application/json" \ -d '{ "name": "Contact", "notify_email": "you@example.com", "origin": "https://example.com" }' ``` The response contains `form.submit_url`, `embed.{html,fetch,react,astro,nextjs_action}`, a `verify` recipe and `next[]` suggestions. ## 2. Point a form at it ```html
``` Plain HTML posts get a `303` redirect to `_redirect` (or the form's `redirect_url` setting, or a hosted thanks page). JSON posts get `{ "ok": true, "submission_id": "sb_…", "status": "received" }`. ## 3. Send a test and watch it arrive ```bash curl -X POST https://postbag.dev/s/fm_8f3kq2 -H "content-type: application/json" \ -d '{ "email": "you@example.com", "message": "hello", "_test": true }' # → { "ok": true, "submission_id": "sb_…", "status": "received", "deliveries": ["dl_…"] } ``` The submission shows up in the inbox immediately and the email arrives within seconds. `_test` submissions are routed like real ones so you can confirm the wire, but they are excluded from quotas. ## 4. Add a second destination (optional) A Telegram chat, a webhook into your CRM, or both: ```bash curl -X POST https://postbag.dev/v1/destinations -H "Authorization: Bearer pb_live_…" \ -d '{ "type": "telegram", "name": "Sales chat", "config": { "bot_token": "123:abc", "chat_id": "-100…" } }' curl -X POST https://postbag.dev/v1/routes -H "Authorization: Bearer pb_live_…" \ -d '{ "form_id": "fm_8f3kq2", "destination_id": "ds_…" }' ``` That is the solo-dev path. Streams, mappings, schemas, windows and digests exist for when you need them and stay out of the way until you do. See [Routing](/docs/routing/). --- # Submit endpoint reference: POST /s/{formId} > Everything about the Postbag submit endpoint: accepted content types, control fields (_redirect, _gotcha, _test, _idempotency, _subject), responses, CORS, rate limits, the 256 KB body limit, and how each outcome is stored. Source: https://postbag.dev/docs/submit-endpoint/ `POST /s/{formId}` is the hot path. It is public (no auth), boring on purpose, and never makes a third-party network call except optional Turnstile verification. ## Content types | Content-Type | Notes | |---|---| | `application/x-www-form-urlencoded` | Default for HTML forms. Repeated keys become arrays. | | `multipart/form-data` | Text fields only in this phase; a file part returns `415 unsupported_media_type` with a hint. | | `application/json` | Any JSON object. Nested objects are stored as-is. | Maximum body size is **256 KB**; larger bodies return `413 payload_too_large`. ## Control fields Fields starting with `_` are stripped from `data` and interpreted: | Field | Effect | |---|---| | `_redirect` | Where to send an HTML (non-JS) post after a `303`. Overrides the form's `redirect_url` setting. | | `_gotcha` | The honeypot (rename via `settings.honeypot_field`). A non-empty value stores the submission as `spam`. | | `_test` | `true` stores a test submission: routed like any other, excluded from quotas, and the response includes `deliveries[]` ids to poll. | | `_idempotency` | Same as the `Idempotency-Key` header: unique per form; a repeat returns the original submission with `idempotent: true`. | | `_subject` | Optional subject hint for email destinations. | ## Responses | Client | Success | Notes | |---|---|---| | JSON / fetch | `200 { ok, submission_id, status, deliveries? }` | `status` is `received`, `quarantined` or `spam`. Spam and quarantine still answer 200; bots learn nothing. | | HTML form | `303` to `_redirect` → form `redirect_url` → hosted thanks page | | | Paused form | `202`, stored, not routed | | | Unknown form | `404 not_found` | | | Rate limited | `429 rate_limited` (and stored as `quarantined`) | `Retry-After` set. | Every error body is `{ "error": { "code", "message", "hint", "docs" } }`. See [Error codes](/docs/errors/). ## What the endpoint checks, in order 1. Resolve the form (cached). Unknown → 404. `paused` → stored, not routed. 2. Parse the body and strip control fields. 3. Cheap checks that all **store anyway** with a status: honeypot → `spam`; origin not in `settings.allowed_origins` → `quarantined/origin_rejected`; over `settings.rate_limit` → `quarantined/rate_limited`; Turnstile failed → `quarantined/turnstile_failed`. 4. Schema: in `enforce` and `managed` modes, validate; a violation stores `quarantined/schema_violation` and raises a drift event. In `observe`, compare to the current schema if any and raise drift on differences. Never blocks. 5. One transaction: insert the submission, plan one delivery per applicable route (direct and via streams, respecting `enabled`, `window`, `quality`), write `submission.received`. 6. Respond, then `NOTIFY postbag_deliveries` so an idle worker wakes immediately. ## CORS `settings.allowed_origins` doubles as the CORS allowlist for fetch-based submissions. Empty means any origin may post (and the `Access-Control-Allow-Origin` echoes the request origin). `GET /s/{formId}/schema` (managed forms) is always CORS-open. ## Metadata stored with each submission `ip` (from `CF-Connecting-IP`, then the first `X-Forwarded-For` hop, then the socket), `user_agent`, `origin`, `referer`, `country` (from Cloudflare when present), `received_at`, `content_type`, and the form schema version validated against, if any. --- # API overview: resources, conventions, pagination, idempotency > The Postbag /v1 API: authentication with pb_live_ keys and scopes, every resource (projects, forms, submissions, streams, destinations, routes, deliveries, events, webhooks), cursor pagination, Idempotency-Key, if_exists, and the error envelope. The OpenAPI document is the contract. Source: https://postbag.dev/docs/api/ The OpenAPI document at `GET /openapi.json` is generated from the live route definitions and is the source of truth. Dashboard, SDK and agents are clients of the same API; there is no UI-only capability. ## Authentication `Authorization: Bearer pb_live_…` (or a dashboard session cookie). Keys are organization-scoped, shown once, stored hashed, and carry scopes: `manage` ⊇ `read` ⊇ `submit`. `GET /v1/me` tells you which. ## Conventions - **Ids are prefixed and self-describing:** `prj_` project, `fm_` form, `sb_` submission, `st_` stream, `ds_` destination, `rt_` route, `dl_` delivery. - **Errors:** `{ "error": { "code", "message", "hint", "docs", "details"? } }`. `hint` says what to do; `docs` deep-links into [the error reference](/docs/errors/). - **Every create returns `next[]`:** suggested follow-up calls with ready-to-send bodies. - **`Idempotency-Key`** is honoured on every `POST` under `/v1`; replaying the same key with a different body returns `409 idempotency_conflict`. - **`if_exists: "return"`** on creates makes them idempotent by `(project, slug)` or `(organization, slug)`. - **Cursor pagination:** `?cursor=&limit=` (max 200), opaque cursors, `next_cursor` in the response. ## Resources | Resource | Paths | |---|---| | Discovery | `GET /v1/me`, `POST /v1/quickstart`, `GET /llms.txt`, `GET /openapi.json` | | Projects | `GET/POST /v1/projects`, `GET/PATCH/DELETE /v1/projects/{id}` | | Forms | `GET/POST /v1/forms`, `GET/PATCH/DELETE /v1/forms/{id}`, `GET /v1/forms/{id}/embed`, `GET/POST /v1/forms/{id}/schema`, `GET /v1/forms/{id}/schema/versions`, `POST /v1/forms/{id}/schema/infer`, `GET /v1/forms/{id}/drift` | | Submissions | `GET /v1/forms/{id}/submissions`, `GET /v1/submissions`, `GET /v1/submissions/{id}` | | Streams | `GET/POST /v1/streams`, `GET/PATCH/DELETE /v1/streams/{id}`, `GET/POST /v1/streams/{id}/schema`, `GET/POST /v1/streams/{id}/sources`, `DELETE /v1/streams/{id}/sources/{sourceId}`, `GET /v1/streams/{id}/preview` | | Destinations | `GET/POST /v1/destinations`, `GET/PATCH/DELETE /v1/destinations/{id}`, `POST /v1/destinations/{id}/test` | | Routes | `GET/POST /v1/routes`, `GET/PATCH/DELETE /v1/routes/{id}` | | Deliveries | `GET /v1/deliveries`, `GET /v1/deliveries/{id}`, `POST /v1/deliveries/{id}/retry` | | Events and system webhooks | `GET /v1/events`, `GET/POST /v1/webhooks`, `GET/PATCH/DELETE /v1/webhooks/{id}`, `GET /v1/webhooks/{id}/deliveries` | | API keys | `GET/POST/DELETE /v1/api-keys` | | Public | `POST /s/{formId}`, `GET /s/{formId}/schema` | ## Events and system webhooks The organization's append-only log: `submission.received`, `submission.quarantined`, `delivery.sent`, `delivery.dead`, `form.schema.changed`, `stream.schema.changed`, `drift.detected`, `destination.failing`. Organization-level system webhooks (distinct from route destinations) subscribe to any of these; dispatch is triggered from Postgres so a subscriber learns of a schema change without polling. Their deliveries are listed under `GET /v1/webhooks/{id}/deliveries`. ## SDK `@postbag/sdk` is a TypeScript client generated from the OpenAPI document (openapi-typescript + openapi-fetch). It is used by the dashboard and will be published to npm together with the CLI and MCP server. --- # Agent guide: set up Postbag from a coding agent > Step-by-step guide for AI coding agents (Claude Code, Cursor, Codex, Copilot) to create, verify and route a Postbag form with only an API key: discovery, quickstart, test submissions, destinations, streams, idempotency, and the postbag.json repo convention. Source: https://postbag.dev/docs/agents/ This page is written to be followed by an agent. It is also what `GET /llms.txt` points to. If you are an agent reading this: you need an API key with the `manage` scope and nothing else. ## 0. Orientation ``` GET {API}/llms.txt # Markdown onboarding: vocabulary, the three calls, conventions GET {API}/openapi.json # full contract, generated from live routes GET {API}/v1/me # organization, scopes, limits, counts of what exists ``` Vocabulary is fixed: organization, project, form, submission, form_schema, stream, stream_schema, mapping, destination, route, delivery, drift. Do not invent synonyms when you write code or config for the user. ## 1. Create a working form in one call ``` POST {API}/v1/quickstart { "name": "", "project": "", "origin": "https://", "notify_email": "", "telegram": { "bot_token": "…", "chat_id": "…" } /* optional */, "webhook": { "url": "…", "secret": "…" } /* optional */ } ``` Idempotent by (project, name): re-running returns the same form. The response has `form.submit_url`, `embed` (html, fetch, react, astro, nextjs_action), `verify` (a curl and a follow-up GET), and `next[]`. ## 2. Put the form in the site Use `embed.` verbatim or adapt it. Keep the honeypot input (`_gotcha`) and, for non-JS forms, a `_redirect`. Never hand-write a submit URL: take it from the API response. ## 3. Verify without a human ``` POST {submit_url} { "email": "agent@example.com", "message": "test", "_test": true } → { "ok": true, "submission_id": "sb_…", "status": "received", "deliveries": ["dl_…"] } GET {API}/v1/deliveries/dl_… → poll until status is "sent" (or "failed"/"dead": read last_error and response) ``` For a destination on its own: `POST {API}/v1/destinations/{id}/test` returns the provider's response inline. ## 4. Record the wiring in the repo Write `postbag.json` at the repo root: ```json { "form_id": "fm_…", "submit_url": "https://…/s/fm_…", "project": "portfolio" } ``` and add to `CLAUDE.md` / `AGENTS.md`: > Forms on this site post to Postbag. Config in `postbag.json`. To add a form, create it through the Postbag API in the same project and use the embed from the response. Never hand-write a submit URL. ## 5. Fleet mode (many sites, one partner) When a stream already exists for the kind of site you are building: ``` GET {API}/v1/streams/{id} # current schema, sources, and a form template POST {API}/v1/forms { "from_template": "st_…", "name": " contact", "tags": ["vending"], "schema_mode": "managed" } ``` The form comes back pre-attached to the stream with a valid mapping and its schema served at `GET /s/{id}/schema`. If the mapping would be incomplete, you get a `422 mapping_incomplete` listing the missing fields, now, not at delivery time. ## Rules of the road - Send `Idempotency-Key` on POSTs you might retry. Use `if_exists: "return"` on creates. - Every error is `{ code, message, hint, docs }`. Read `hint` first; it is written for you. - Ids tell you what they are: `fm_`, `sb_`, `st_`, `ds_`, `rt_`, `dl_`, `prj_`. - Spam and quarantine are statuses, not rejections. A 200 with `"status": "quarantined"` means stored, not delivered; read `quarantine_reason`. - Do not poll submissions to "see if it worked"; poll the delivery ids from a `_test` post. --- # Webhook destinations and signature verification > How Postbag delivers to webhook destinations: the JSON payload, Postbag-Signature (t=…,v1=… HMAC-SHA256), Postbag-Delivery and Postbag-Event headers, retry and dead-letter behaviour, and verification code in Node.js, Python and Go. Source: https://postbag.dev/docs/webhooks/ A webhook destination is a URL, an optional secret and optional extra headers. It is the universal extension point: CRMs, automation tools, your own services. ## The request ```http POST https://crm.example.com/postbag Content-Type: application/json Postbag-Delivery: dl_a91x02 Postbag-Event: submission.received Postbag-Signature: t=1724200000,v1=5f1c…e9a2 { "id": "dl_a91x02", "type": "submission.received", "schema_version": 3, "stream": { "id": "st_…", "slug": "vending-leads" } | null, "form": { "id": "fm_…", "slug": "kontorsautomat-contact" }, "data": { …mapped payload… }, "extras": { … }, "meta": { … } } ``` `Postbag-Event` is `submission.received` for instant routes and `digest.ready` for digest routes (one payload per period containing the period's submissions). ## Response handling | Your response | Postbag does | |---|---| | `2xx` | Marks the delivery `sent`, stores status, latency and a body excerpt. | | `410` | Treats the destination as having disabled itself; no retry. | | anything else, or a timeout (10 s) | Marks `failed`, schedules a retry with backoff `min(2^attempts × 30 s ± 20 %, 6 h)`, up to 10 attempts, then `dead` and raises `delivery.dead`. | Dead deliveries keep their payload and can be retried from the dashboard or `POST /v1/deliveries/{id}/retry`. ## Verifying the signature The signature is `HMAC-SHA256(secret, "{t}.{rawBody}")` in hex. Always verify over the **raw** body bytes, compare in constant time, and reject stale timestamps. ### Node.js ```ts import { createHmac, timingSafeEqual } from "node:crypto" export function verifyPostbag(secret: string, header: string, rawBody: string, toleranceSec = 300): boolean { const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=") as [string, string])) const t = Number(parts.t) if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex") const given = parts.v1 ?? "" return expected.length === given.length && timingSafeEqual(Buffer.from(expected), Buffer.from(given)) } ``` ### Python ```python import hmac, hashlib, time def verify_postbag(secret: str, header: str, raw_body: bytes, tolerance=300) -> bool: parts = dict(kv.split("=", 1) for kv in header.split(",")) t = int(parts.get("t", "0")) if abs(time.time() - t) > tolerance: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts.get("v1", "")) ``` ### Go ```go func verifyPostbag(secret, header string, rawBody []byte, tolerance time.Duration) bool { parts := map[string]string{} for _, kv := range strings.Split(header, ",") { p := strings.SplitN(kv, "=", 2); if len(p) == 2 { parts[p[0]] = p[1] } } t, err := strconv.ParseInt(parts["t"], 10, 64) if err != nil || time.Since(time.Unix(t, 0)).Abs() > tolerance { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(strconv.FormatInt(t, 10) + ".")); mac.Write(rawBody) return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(parts["v1"])) } ``` ## Organization system webhooks Separate from route destinations, `POST /v1/webhooks { url, events[], secret? }` subscribes to organization events (`submission.received`, `delivery.dead`, `form.schema.changed`, `stream.schema.changed`, `drift.detected`, `destination.failing`, …). Dispatch is triggered from Postgres; deliveries are listed at `GET /v1/webhooks/{id}/deliveries` and signed the same way. --- # Routing: forms, streams, mappings, routes, digests and windows > How Postbag routes submissions: direct routes, streams with explicit and tag-based sources, field mappings (from, const, default), versioned stream schemas, route modes (instant, digest with cron and timezone), delivery windows and quality rules. Source: https://postbag.dev/docs/routing/ A **route** goes from a source (one form or one stream) to one destination, with rules. A **stream** groups forms and gives them one versioned output schema. A **mapping** turns a form's fields into the stream's fields. ## Direct routes ```bash POST /v1/routes { "form_id": "fm_…", "destination_id": "ds_…" } ``` Defaults: `mode: { "type": "instant" }`, `quality: { "exclude_spam": true, "exclude_quarantined": true }`, `enabled: true`. ## Streams and sources ```bash POST /v1/streams { "name": "Vending leads", "slug": "vending-leads", "schema": { "json_schema": { "type": "object", "required": ["name", "phone"], "properties": { … } } }, "sources": [ { "selector": "tag:vending", "mapping": { … } }, { "form_id": "fm_…", "mapping": { … } } ] } ``` Sources are explicit (`form_id`) or selectors (`tag:vending`, `project:prj_…`). Each carries a mapping. `GET /v1/streams/{id}/preview` shows recent submissions mapped through the current mappings. ## Mappings ```json { "name": { "from": "fullName" }, "company": { "from": "Företag" }, "phone": { "from": "tel", "default": null }, "site": { "const": "kontorsautomat.se" } } ``` Exactly one of `from`, `const` or `expr` per field. `expr` (JSONata, ADR-005) is reserved and currently returns `422 expressions_not_enabled` with a hint to use `from`, `const` or `default`. Unmapped form fields are kept under `extras`. A mapping is `valid` or `incomplete`; incomplete blocks attachment with `422 mapping_incomplete` listing the missing fields. ## Stream schemas Versioned and immutable, like form schemas. Publishing a new version emits `stream.schema.changed` and re-validates every source mapping. Deliveries record the `schema_version` their payload conforms to. ## Route rules | Rule | Shape | Effect | |---|---|---| | `mode` | `{ "type": "instant" }` or `{ "type": "digest", "cron": "0 8 * * *", "timezone": "Europe/Stockholm" }` | Digest groups a period into one delivery per destination, unique by (route, period). Empty periods send nothing. | | `window` | `{ "from": ISO, "until": ISO }` (each nullable) | Outside the window the delivery is created as `skipped` with reason `window`. | | `quality` | `{ "exclude_spam", "exclude_quarantined" }` | Both default `true`; skipped deliveries carry reason `quality`. | | `enabled` | boolean | Disabled routes plan no deliveries. | | `filter`, `transform` | expression strings | Reserved for the expression phase; accepted by the schema, not yet evaluated. | ## Deliveries One row per (submission, route). Statuses: `pending`, `sending`, `sent`, `failed`, `dead`, `skipped`. Each keeps `payload` (the snapshot actually sent), `schema_version`, `attempts`, `next_attempt_at`, `last_error`, and the last `response` (status, body excerpt, latency). Retry with `POST /v1/deliveries/{id}/retry`. --- # Schemas: observe, enforce, managed, versions, drift and inference > Postbag form schemas and stream schemas: immutable versions with JSON Schema and UI hints, the three schema modes, how drift is detected and resolved, and how inference proposes a schema from real submissions. Source: https://postbag.dev/docs/schemas/ A **form schema** is a versioned, immutable declaration of what a form collects: `json_schema` (JSON Schema draft 2020-12 for the submission `data`), `ui` (per-field label, placeholder, order, widget, help, options) and a `changelog`. **Stream schemas** have the same shape and are the outbound contract of a stream. ## Modes | Mode | Behaviour | |---|---| | `observe` (default) | Accept everything. If a schema exists, compare and raise drift events. If none, infer one in the background and offer it. | | `enforce` | Validate against the current version. Violations are stored `quarantined` with reason `schema_violation`, never rejected, and raise a drift event. | | `managed` | Postbag owns the schema. `GET /s/{id}/schema` serves it (CORS open) and sites render the form from it. Validation as `enforce`. The site cannot drift because it has no schema of its own. | ## Publishing a version ```bash POST /v1/forms/{id}/schema { "json_schema": { … }, "ui": { "email": { "label": "Email", "widget": "email", "order": 1 } }, "changelog": "v2" } GET /v1/forms/{id}/schema/versions ``` `form_schemas (form_id, version)` is unique and rows are never updated. Submissions record `form_schema_version`. ## Drift A drift event is raised when what a form actually receives differs from its declared schema: `kind` is `new_field`, `missing_field` or `type_change`, with details and the triggering submission. `GET /v1/forms/{id}/drift` lists open events; publishing a new version or dismissing resolves them. Subscribe an organization webhook to `drift.detected` to be told without polling. ## Inference `POST /v1/forms/{id}/schema/infer` builds a draft from recent submissions (field names, types, presence). Housekeeping does the same in the background for `observe` forms without a schema. Drafts are reviewed and published as a version; inferred versions carry an `inferred` flag. ## Embed snippets follow the schema `GET /v1/forms/{id}/embed` renders HTML, fetch, React, Astro and Next.js snippets from the current schema's `ui` hints (label, widget, order), so a managed form's markup always matches its contract. --- # Destinations: email, Telegram and webhook configuration > Configuration reference for Postbag destinations: email (to, cc, subject_template, from_name, Reply-To), Telegram (bot_token, chat_id, template), webhook (url, secret, headers), the /test endpoint, redaction, and retry limits per type. Source: https://postbag.dev/docs/destinations/ Destinations are organization-level and reusable across routes. Create with `POST /v1/destinations`, test with `POST /v1/destinations/{id}/test`. ## Email ```json { "type": "email", "name": "Ops inbox", "config": { "to": ["ops@example.com"], "cc": [], "subject_template": "New submission: {{form.name}}", "from_name": "Forms" } } ``` Sent through Resend from a Postbag domain (or your `MAIL_FROM` when self-hosted). `Reply-To` is set from the submission: `settings.reply_to_field` on the form, or the first field that looks like an email. Templates see `form`, `submission`, `data` and `meta`. Max attempts: 8. ## Telegram ```json { "type": "telegram", "name": "Sales chat", "config": { "bot_token": "123456:ABC…", "chat_id": "-1001234567890", "template": "New lead from {{form.name}}: {{data.name}}" } } ``` Create a bot with @BotFather, add it to the chat, and use the chat id (negative for groups). Messages are HTML-formatted with values escaped. Max attempts: 8. ## Webhook ```json { "type": "webhook", "name": "CRM", "config": { "url": "https://crm.example.com/postbag", "secret": "whsec_…", "headers": { "X-Source": "postbag" } } } ``` JSON POST with `Postbag-Delivery`, `Postbag-Event` and, when a secret is set, `Postbag-Signature`. 10 s timeout, max 10 attempts. See [Webhook signatures](/docs/webhooks/). ## Testing `POST /v1/destinations/{id}/test` sends a sample payload through the real adapter and returns `{ ok, status_code, latency_ms, response_excerpt, error? }` inline. Secrets are never echoed back: `GET /v1/destinations/{id}` returns a redacted config. ## Health A destination that fails repeatedly is marked failing and raises `destination.failing`; alerts are throttled per destination so a down endpoint does not produce a storm. ## What is next `slack` and `discord` (incoming-webhook URL plus a template) are accepted by the API schema and are the next adapters. Native destinations follow only once the webhook path has proven a pattern. --- # Architecture: the submit path, the outbox and the worker > How Postbag runs: one Hono server with a public submit path and the /v1 API, a worker that drains a Postgres outbox with SELECT … FOR UPDATE SKIP LOCKED, LISTEN/NOTIFY plus a 15-second tick, exponential backoff, digests, drift inference, retention, and multi-tenancy. Source: https://postbag.dev/docs/architecture/ One TypeScript monorepo, one Docker image, one Postgres. `api`, `worker` and `all` are entrypoints of the same image. Postgres is the only stateful dependency. ``` HTML form / fetch ──► POST /s/{form} (public) agent / CLI / MCP ──► /v1/* (keyed) dashboard ──► /app/* (SPA, cookie auth) marketing/docs ──► / (static) │ worker: drains deliveries, digests, inference, retention │ Postgres (truth) ──► Resend, Telegram, webhooks ``` ## The submit path Resolve form → parse → cheap checks that store anyway → schema → **one transaction** (submission + one delivery per route + event) → respond → `NOTIFY`. No third-party call in the write path; Turnstile is the bounded exception. Details in the [submit endpoint reference](/docs/submit-endpoint/). ## The worker - Claims with `SELECT … FOR UPDATE SKIP LOCKED` on `deliveries WHERE status IN ('pending','failed') AND next_attempt_at <= now()`. Multiple workers are safe by construction. - Applies the destination adapter, records `response`, moves status. Backoff `min(2^attempts × 30 s ± 20 %, 6 h)`; `dead` after 8 attempts (email, Telegram) or 10 (webhook). `dead` raises `delivery.dead` and a dashboard alert; storms are throttled per destination. - Digest loop once a minute: for every (route, period) whose period has closed, one delivery per destination, keyed by the unique `(route_id, period_key)`. - Housekeeping: schema inference for `observe` forms, retention deletion, destination health. - Wakes on `LISTEN postbag_deliveries` and on a 15 s tick regardless. Realtime is an accelerator, never the transport. ## Destination adapters ```ts interface DestinationAdapter { type: string configSchema: ZodType // validates and documents config redactConfig(c: C): Partial // what the API may echo back test(c: C, sample: Payload): Promise deliver(c: C, payload: Payload, ctx: DeliveryContext): Promise } ``` Adding a destination type is adding one file implementing this. Webhook is the reference implementation. ## Multi-tenancy `organization_id` on every tenant table; repositories take an organization scope and refuse to run without one. Row-level security policies and a `postbag_app` role ship in the migrations as a second fence. Public submit runs on a narrow, audited path. Plan limits are checked at creation (forms, destinations) and counted per month (submissions) with soft-fail: over-limit submissions are stored and flagged, delivery pauses until the plan allows. ## Observability Structured JSON logs with `org_id`, `form_id`, `delivery_id`. `/health` reports database, worker heartbeat and oldest pending delivery age. Every organization sees its own events stream: observability is a product feature. ## Deliberately not doing No Redis or external queue (the outbox is the queue). No form builder. No multi-region until p95 submit latency from target markets exceeds 300 ms. --- # Security: keys, scopes, tenancy, signatures, spam > Postbag's security model: API keys hashed at rest with manage/read/submit scopes, organization scoping on every row with row-level security as a second fence, HMAC-SHA256 webhook signatures, honeypot, rate limits, origin allowlists and Turnstile, and a no-drop data policy. Source: https://postbag.dev/docs/security/ ## Authentication and keys Dashboard sessions use cookies (Better Auth). Everything else uses `Authorization: Bearer pb_live_…`. Keys are organization-scoped, shown once, stored as SHA-256 hashes with a visible prefix, and carry scopes: `manage` ⊇ `read` ⊇ `submit`. Revoke under API keys or `DELETE /v1/api-keys/{id}`. ## Tenancy Every tenant-owned row has a non-null `organization_id`; repositories require an organization scope, so a cross-tenant query cannot be expressed. Postgres row-level security policies and a `postbag_app` role ship in the migrations as a second fence. ## Outbound signatures Webhook and system-webhook deliveries carry `Postbag-Signature: t=…,v1=…` (HMAC-SHA256 over `{t}.{body}`) when a secret is configured. Verify over the raw body in constant time and reject stale timestamps. Secrets are never echoed back by the API. ## Inbound protection Honeypot, per-form per-IP rate limit with burst, origin allowlist (also the CORS policy), optional Cloudflare Turnstile, 256 KB body limit, and no file uploads in this phase. All outcomes are stored with a status; nothing is silently dropped and bots receive the same response as humans. ## Data Submissions are deleted only by explicit user action or the plan's retention period. Test submissions are excluded from quotas. Structured logs carry ids, not payloads. ## Reporting Found something? Use the contact form on the [about page](/about/#contact). We prefer coordinated disclosure and respond quickly. --- # Self-hosting guide: Docker, Postgres, environment > Run Postbag yourself: the Docker image (api, worker or all), Postgres 16, docker-compose, every environment variable (DATABASE_URL, APP_URL, BETTER_AUTH_SECRET, POSTBAG_ROLE, MIGRATE_ON_BOOT, RESEND_API_KEY, MAIL_FROM), health checks and upgrades. Source: https://postbag.dev/docs/self-hosting/ Postbag is one image plus Postgres. The hosted product runs the same image. ## docker-compose ```yaml services: db: image: postgres:16-alpine environment: { POSTGRES_DB: postbag, POSTGRES_USER: postbag, POSTGRES_PASSWORD: change-me } volumes: [ "postbag-postgres:/var/lib/postgresql/data" ] healthcheck: { test: ["CMD-SHELL", "pg_isready -U postbag -d postbag"], interval: 2s, retries: 15 } postbag: build: . # or the published image when available depends_on: { db: { condition: service_healthy } } environment: DATABASE_URL: postgres://postbag:change-me@db:5432/postbag NODE_ENV: production PORT: "3000" APP_URL: https://forms.example.com BETTER_AUTH_SECRET: a-long-random-secret POSTBAG_ROLE: all # api | worker | all MIGRATE_ON_BOOT: "true" TZ: UTC RESEND_API_KEY: re_… MAIL_FROM: "Forms " ports: [ "3000:3000" ] volumes: { postbag-postgres: {} } ``` ## Environment | Variable | Meaning | |---|---| | `DATABASE_URL` | Postgres connection string. | | `APP_URL` | Public origin. Used in submit URLs, embed snippets, `llms.txt` and docs links. | | `BETTER_AUTH_SECRET` | Session signing secret. | | `POSTBAG_ROLE` | `api`, `worker` or `all`. Run two containers for independent scaling; several workers are safe. | | `PORT`, `TZ`, `NODE_ENV` | Defaults `3000`, `UTC`, `production`. | | `MIGRATE_ON_BOOT` | `true` runs pending Drizzle migrations at start. | | `RESEND_API_KEY`, `MAIL_FROM` | Email destinations. Verify the sending domain in Resend. | ## Health and operations `GET /health` returns database status, worker heartbeat and oldest pending delivery age; the image has a Docker `HEALTHCHECK` on it. Logs are structured JSON. Migrations live in `packages/db/drizzle` and are applied in order; never edit an applied migration. ## Upgrades Pull the new image, restart with `MIGRATE_ON_BOOT=true`. Schemas (form and stream) are immutable versions, so upgrades never rewrite your contracts. ## Single-organization installs Disable signups after creating the first organization if the instance is private. Plan limits under the `selfhost` plan are effectively unlimited. ## Access to the image Postbag is open source: the server is licensed under AGPL-3.0 and the client packages (SDK, CLI, MCP server) under MIT. The repository opens publicly with the first npm release of the client packages; until then, [get in touch](/about/) and we will arrange access. The image is multi-arch (arm64, amd64) and built from the same Dockerfile as production. --- # Error codes > Every Postbag API error is { code, message, hint, docs }. This page lists each code, its HTTP status and the hint the API returns, so humans and agents can act on it. Source: https://postbag.dev/docs/errors/ Every error response has the shape: ```json { "error": { "code": "mapping_incomplete", "message": "…", "hint": "Map every required stream field before attaching.", "docs": "https://postbag.dev/docs/errors/mapping_incomplete", "details": { … } } } ``` `hint` says what to do next. `docs` deep-links to the section below. `details` carries structured specifics (validation issues, missing fields, retry delay). ## Codes | Code | Status | Hint | |---|---|---| | `unauthorized` | 401 | Provide a session cookie or an `Authorization: Bearer pb_live_…` key. | | `forbidden` | 403 | Use credentials with permission for this operation (check key scopes). | | `origin_rejected` | 403 | Add the site origin to the form's allowed origins. The submission was still stored as quarantined. | | `not_found` | 404 | Check the id and organization scope. | | `conflict` | 409 | The resource already exists, or is still referenced elsewhere. Consider `if_exists: "return"`. | | `idempotency_conflict` | 409 | Reuse an `Idempotency-Key` only for the identical operation. | | `plan_limit_reached` | 402 | Change plan limits or remove an unused resource. | | `payload_too_large` | 413 | Reduce the payload size, field count, or nesting depth (limit 256 KB). | | `unsupported_media_type` | 415 | File uploads are not supported yet; send text fields only. | | `validation_failed` | 422 | Correct the fields described in `details.issues` and retry. | | `mapping_incomplete` | 422 | Map every required stream field before attaching; `details` lists them. | | `schema_violation` | 422 | Publish a compatible schema or correct the submitted fields. | | `expressions_not_enabled` | 422 | Use `from`, `const` or `default` until expressions ship. | | `rate_limited` | 429 | Retry after the indicated delay (`Retry-After`). The submission was stored as quarantined. | | `internal_error` | 500 | Retry; contact support if this persists. |