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.
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
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
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.
You don’t have to write the schema first. A stream with no schema takes its version 1 from the first form you attach — copied from the form’s published schema, else its inferred draft, else the fields seen in its recent submissions — and that form gets an identity mapping. So POST /v1/streams { "name": "Leads", "sources": [{ "form_id": "fm_…" }] } is a complete request, and so is POST /v1/streams/{id}/sources { "form_id": "fm_…" } on an empty stream. Forms attached after that map onto the shape the first one set. If neither the stream nor the form has any fields yet (a brand-new form with no submissions), the call fails 422 stream_schema_missing with a hint.
Mappings
{ "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. A mapping determines the Delivery payload; form fields it does not use remain on the original Submission. The preview response reports those unused values 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.