# 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. |