Forms
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,executionandapprovalblocks decide everything else. - Registration uses workspace creation access. Human members can create
private forms in the account root without an administrator role. Set
workspace_idto place a form in a named workspace, andvisibilitytoworkspaceto follow that workspace's access rules; omitted visibility uses the workspace's default (initially private). form_idis the identity,nameis a label - the server mints the id, and names are not unique: registeringdocs4.surveytwice gives two forms.- The creator is stamped from the authenticated principal. The creator
manages a private form. Additional
reader,editor,reviewerandmanagergrants 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
versionand appends an immutable snapshot, and each submission records theform_versionit was validated against. - Only
activeforms accept submissions; the rest answer409 form_not_active.
Register a form
snug form register --schema support-form.json
snug form register --schema survey-def.json --policy survey-policy.jsonDespite 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:
| Input | Response |
|---|---|
| Value fails the schema | 400 validation_failed, every JSON Schema error joined into msg |
| Field not in the schema | 400 validation_failed: unknown field 'nickname' |
conditional_logic rule unsatisfied | 400 validation_failed: conditional rule 'severity == 'high'' requires field 'details' |
| Honeypot field filled | 400 spam_rejected |
Submitted faster than min_submit_ms | 400 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.logThe 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" } // detailVersions 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 pauselist-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
| Role | Definition/history | Submit while active | Read all answers/retry hooks | Edit schema | Lifecycle/sharing |
|---|---|---|---|---|---|
| Reader | Yes | Yes | No | No | No |
| Editor | Yes | Yes | No | Yes | No |
| Reviewer | Yes | Yes | Yes | No | No |
| Manager | Yes | Yes | Yes | Yes | Yes |
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 readerThe 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, andupdated_at, sort onname,version,submission_count,created_at, andupdated_at, and takeqover name and title. Submissions filter onstatus,submitted_by,form_version,anonymous,created_at, andupdated_at, and sort onform_version,created_at, andupdated_at. - Related: Blob for attachments, Human-in-the-Loop, FSM and Job Queue as hook targets
