Skip to content

Forms

Try the interactive example ↓

Schema-backed intake without writing a backend handler. Register a JSON Schema plus a policy block, and the service validates every submission against it, keeps per-user drafts, pushes uploads into Blob storage, seals fields you mark sensitive, and routes accepted entries onward.

When to reach for it: contact and support intake, bug reports with screenshots, application forms that need an approval step, draft-and-resume onboarding, spam-resistant public forms.

When not to: fixed-option voting with a tally belongs to Poll; a standalone approval queue with no intake form is Human-in-the-Loop; free-form JSON with no schema to enforce is the KV Store.

Concepts

  • A form is a JSON Schema plus policy - the schema (Draft 7) validates submission bodies; the drafts, spam, multipart, execution and approval blocks decide everything else.
  • Registration uses workspace creation access. Human members can create private forms in the account root without an administrator role. Set workspace_id to place a form in a named workspace, and visibility to workspace to follow that workspace's access rules; omitted visibility uses the workspace's default (initially private).
  • form_id is the identity, name is a label - the server mints the id, and names are not unique: registering docs4.survey twice gives two forms.
  • The creator is stamped from the authenticated principal. The creator manages a private form. Additional reader, editor, reviewer and manager grants can go to users, groups or service accounts. JWT administrator claims do not override these permissions. See Sharing and authority.
  • Schemas are versioned - an update bumps version and appends an immutable snapshot, and each submission records the form_version it was validated against.
  • Only active forms accept submissions; the rest answer 409 form_not_active.

Register a form

snug form register --schema support-form.json
snug form register --schema survey-def.json --policy survey-policy.json

Despite the flag name, --schema takes the whole definition body, not a bare JSON Schema - the JSON Schema is its schema field, and passing a bare one fails with missing field 'name'. --policy overlays a second file's top-level keys, so shared policy can live in one place:

{
  "name": "docs4.support.request",
  "title": "Docs4 Support Request",
  "schema": { "type": "object", "properties": { "...": {} }, "required": ["subject", "severity"] },
  "fields": [{ "name": "account_id", "label": "Account ID", "kind": "text", "encrypted": true }],
  "drafts": { "enabled": true, "ttl_seconds": 3600 },
  "spam": { "honeypot_fields": ["website"], "allow_anonymous": false },
  "conditional_logic": [{ "when": "severity == 'high'", "require": ["details"] }]
}

name may not contain whitespace. Policy values are bounded at registration time, so "ttl_seconds": 9999999 is rejected with 400 invalid_form: draft ttl exceeds 604800 seconds, and an uncompilable schema with 400 invalid_schema.

Accept submissions

snug form submit --form <form_id> -d submission.json
snug form batch-submit --form <form_id> --file entries.json
snug form submit-public --form <form_id> -d submission.json   # no token needed
snug form list-submissions --form <form_id> --filter 'status[eq]=submitted'
snug form get-submission --form <form_id> --submission <submission_id>

Over HTTP the body goes under data:

curl -X POST -H "Authorization: Bearer $SNUG_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"data": {"rating": 5}}' \
  http://localhost:4000/api/v1/forms/019a0000-0000-7000-8000-000000000001/submissions
{
  "status": 202,
  "msg": "Accepted",
  "data": {
    "submission_id": "019a0000-0000-7000-8000-000000000002",
    "form_id": "019a0000-0000-7000-8000-000000000001",
    "form_version": 1,
    "status": "submitted",
    "execution_status": { "job": null, "webhook": null, "fsm": null, "approval": null }
  }
}

202, not 201: the entry is stored and its hooks dispatched, and execution_status reports where each landed - see Execution hooks.

/submissions/public takes no Authorization header at all and records submitted_by: "anonymous". It needs spam.allow_anonymous; without it the answer is 403 insufficient_permissions.

The batch endpoint is not atomic: it processes entries in order and stops at the first failure, so earlier ones are already stored when the call returns 400. Verified with a three-item batch whose second item was invalid - the first persisted, the third never ran. Exceeding the cap of 500 rejects the whole call with 400 batch_too_large before anything is written.

What gets rejected

All in the standard error envelope:

InputResponse
Value fails the schema400 validation_failed, every JSON Schema error joined into msg
Field not in the schema400 validation_failed: unknown field 'nickname'
conditional_logic rule unsatisfied400 validation_failed: conditional rule 'severity == 'high'' requires field 'details'
Honeypot field filled400 spam_rejected
Submitted faster than min_submit_ms400 spam_rejected: submission too fast: 12ms < 3000ms

Two of those surprise people. Unknown fields are rejected even without "additionalProperties": false - the service allowlists the schema's properties keys itself, and a schema with no properties at all skips the check and accepts any object. And min_submit_ms fires only when the caller sends client_metadata.started_at; absent that field the timing check is skipped entirely. Honeypots are stripped before validation, so they must not appear in the schema.

Drafts

snug form save-draft --form-id <form_id> -d partial.json [--draft-id draft_211d60...]
snug form get-draft   --form-id <form_id> --draft-id draft_211d60...
snug form submit --form <form_id> -d complete.json --draft-id draft_211d60...

Drafts need drafts.enabled, otherwise 400 invalid_request: drafts are not enabled for this form. Field names are checked on save but values are not, so a half-filled draft stores fine. Each draft belongs to its caller - another user reading it gets 403 - and it expires on the form's ttl_seconds. A draft is consumed, not merged: passing --draft-id on submit deletes it afterwards but does not fill in missing fields, so replaying only the draft's partial data still fails with "severity" is a required property.

Attachments

snug form submit-multipart --form <form_id> -d data.json --file evidence=crash.log

The form needs multipart.enabled. The request is multipart/form-data with a JSON data part plus one part per file, named by its field. Files land in Blob and the submission stores only references - field, blob_id, content_type, size_bytes. blob_id is a Blob path, so snug blobs download -p "<blob_id>" -o out.log round-trips the file. allowed_content_types, max_files and max_file_bytes are enforced per submission (400 attachment_rejected) under the service limits.

Who can see a submission

A form's readers and reviewers can read its definition and version history. A participant sees only their own submissions while they retain form read access. Reviewers and managers can read every submission, including anonymous ones. An inaccessible answer returns 404; list filters only narrow the answers already authorized for the caller. Losing form access also removes access to that participant's old answers and drafts.

Unsubmitted drafts remain private to their participant, including against reviewers and managers. The public submission endpoint grants no definition, version, draft or answer reads. Anonymous answers have no principal in SQL; "anonymous" is only their display attribution.

Encrypted fields are redacted in every list response, the submitter's own included, and decrypted only on the detail endpoint:

"data": { "account_id": "[redacted]", "subject": "Cannot log in" }   // list
"data": { "account_id": "acct_9931",  "subject": "Cannot log in" }   // detail

Versions and lifecycle

snug form update-schema --form-id <form_id> -s schema-v2.json --fields fields.json
snug form list-versions --form-id <form_id>
snug form get-version   --form-id <form_id> --version 1
snug form set-status --form-id <form_id> --status active   # or: snug form pause

list-versions is a list, oldest first, filtering on version, updated_by, and created_at. update-schema appends a snapshot that is never rewritten, so get-version replays any earlier schema and its field metadata while existing submissions keep the form_version they were validated against. Editors update the schema; managers change lifecycle status. An unknown status name is 400 invalid_request: unknown status 'archived'.

snug form list hides everything that is not active. --include-inactive includes other statuses among the caller's authorized forms. Explicit --filter 'status[eq]=paused' likewise filters only accessible forms.

Sharing and authority

RoleDefinition/historySubmit while activeRead all answers/retry hooksEdit schemaLifecycle/sharing
ReaderYesYesNoNoNo
EditorYesYesNoYesNo
ReviewerYesYesYesNoNo
ManagerYesYesYesYesYes

Share through the common access API or CLI:

snug access grant --target forms --id <form_id> --subject-kind user --subject-id <user_id> --role reader
snug access grant --target forms --id <form_id> --subject-kind group --subject-id <group_id> --role reviewer
snug access revoke --target forms --id <form_id> --subject-kind user --subject-id <user_id> --role reader

The equivalent HTTP request is POST /api/v1/access/forms/<form_id>/grants (DELETE to revoke) with {"subject":{"kind":"user","id":"<user_id>"},"role":"reader"}. An optional expires_at limits a grant. Workspace visibility and group membership use the same enterprise boundary as other permission-aware services. Service accounts create workspace-visible forms under explicit workspace creation grants; being their creator grants no implicit human ownership. The API key's service scopes are an additional ceiling, not a replacement for resource permission checks.

A reviewer role gives access to accepted answers and workflow retries; it does not decide an approval. HITL remains the authority for approval decisions. Schema/version writes and submission counters commit in SQL transactions. Hook dispatch happens after commit and is best effort; SQL and Redis are not one distributed transaction.

Limits and configuration

Schemas up to 256 KB, submissions up to 512 KB, ten files of up to 50 MB each, 500 entries per batch and a seven-day ceiling on draft TTLs by default, all tunable through the FORM_* variables. Encrypted fields additionally require FORM_FIELD_ENCRYPTION_KEY.

Reference

  • Form API - every endpoint, callable
  • Execution hooks - routing an accepted submission onward
  • Both list endpoints speak the standard list contract. Forms filter on status, version, created_by, submission_count, created_at, and updated_at, sort on name, version, submission_count, created_at, and updated_at, and take q over name and title. Submissions filter on status, submitted_by, form_version, anonymous, created_at, and updated_at, and sort on form_version, created_at, and updated_at.
  • Related: Blob for attachments, Human-in-the-Loop, FSM and Job Queue as hook targets

Interactive example

Collect structured submissions against a versioned schema. Support drafts, validation, and review without building a handler for each form.

An illustrative example that runs in your browser. No account or API calls required.