Skip to content

Schemas and drift: know what your forms actually send

A form schema is a versioned declaration of what a form collects. You never have to write one, but when you run many sites you want to know the moment one of them changes.

In short: A Postbag form schema is an immutable, versioned JSON Schema (draft 2020-12) plus UI hints. Forms run in observe mode (accept everything, detect drift), enforce mode (validate; violations are quarantined, never dropped) or managed mode (Postbag owns the schema and serves it at /s/{id}/schema so the site renders the form from it).

Three modes, one default

observe (default): accept everything. If a schema exists, compare each submission to it and raise drift events on differences. If none exists, infer one in the background and offer it as a draft.

enforce: validate against the current version. Violations are stored as quarantined with reason schema_violation and raise a drift event. Nothing is rejected, nothing is dropped.

managed: Postbag owns the schema. GET /s/{id}/schema serves it with open CORS and sites render the form from it. The site cannot drift because it has no schema of its own. This is the mode an agent uses when it builds the sixteenth site in a fleet from a stream's template.

Versions are immutable

Publishing a schema creates a new row: form_schemas (form_id, version) is unique and rows are never updated. Submissions and deliveries record the version they were validated against, so an old delivery still means what it meant. Stream schemas work the same way and are the outbound contract for every route on the stream.

publish a version
curl -X POST https://postbag.dev/v1/forms/fm_8f3kq2/schema -H "Authorization: Bearer pb_live_…" -d '{
  "json_schema": { "type": "object", "required": ["email"],
    "properties": { "email": { "type": "string", "format": "email" }, "message": { "type": "string" } } },
  "ui": { "email": { "label": "Email", "widget": "email", "order": 1 }, "message": { "label": "Message", "widget": "textarea", "order": 2 } },
  "changelog": "v2: message optional"
}'

Drift

A drift event records form, submission, kind (new_field, missing_field, type_change) and details, and stays open until someone publishes a new version or dismisses it. GET /v1/forms/{id}/drift lists them; the dashboard shows a "Change detected" badge. Organization system webhooks can subscribe to drift.detected, so a CRM or a site factory learns about a change without polling.

Inference

For observe forms with no schema, POST /v1/forms/{id}/schema/infer (and the background housekeeping loop) builds a draft from recent submissions: field names, types and which fields were always present. You review and publish it as v1. Inferred versions are flagged as such.

Questions, answered

Do I have to define a schema to use Postbag?
No. A form with no schema in observe mode accepts anything. Schemas exist for the people who need them: fleets, partners, and agents that must not produce a form a stream does not understand.
What happens to a submission that violates an enforced schema?
It is stored with status quarantined and reason schema_violation, a drift event is raised, and routes skip it by default. You can publish a compatible schema and retry.
Can a downstream system be notified when a schema changes?
Yes. Subscribe an organization webhook to form.schema.changed, stream.schema.changed or drift.detected under /v1/webhooks.

Your first form is three minutes away.

Sign up, get a submit URL, point a form at it. The first submission lands in your inbox and your email. Everything else appears when you need it.