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 is platform-admin only (
403 insufficient_permissionsfor anyone else). Submitting is not restricted this way. form_idis the identity,nameis a label - the server mints the id, and names are not unique: registeringdocs4.surveytwice gives two forms.- The owner is
created_by, the admin who registered it. The owner and platform admins change status and schema, retry hooks, and read every submission; everyone else reads only their own. - 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> --status 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/KrWttSwSBceEMNSYQSNw/submissions{
"status": 202,
"msg": "Accepted",
"data": {
"submission_id": "sub_9a2bb2da95e54509bb6282ec3498d9d1",
"form_id": "KrWttSwSBceEMNSYQSNw",
"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
Each reproduced live, 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
Form definitions are readable by any authenticated caller in the tenant.
Submissions are not: they are scoped to their submitter. A submitter's list
returns only their own entries, and the submitter query parameter is
ignored for them - it silently narrows to themselves rather than erroring.
Reading someone else's submission by id is
403 insufficient_permissions: cannot view another user's submission. The
owner and platform admins see every submission, anonymous ones included, and
may filter by submitter.
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 pauseupdate-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. Only the owner or a
platform admin may update the schema or change status; an unknown status name
is 400 invalid_request: unknown status 'archived'.
snug form list hides everything that is not active. --include-inactive
lifts that for a platform admin and is ignored for everyone else, though
asking for --status paused explicitly works for any caller.
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 in the
Form CONFIG reference.
Encrypted fields additionally require FORM_FIELD_ENCRYPTION_KEY.
Reference
- Form API - every endpoint, callable
- Execution hooks - routing an accepted submission onward
- List endpoints take
q,sort_by/sort_orderandpage/page_sizewith the semantics in Search queries, but narrow through namedstatus/submitterparameters, notfilter. - Related: Blob for attachments, Human-in-the-Loop, FSM and Job Queue as hook targets