ServicesForms

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, execution and approval blocks decide everything else.
  • Registration is platform-admin only (403 insufficient_permissions for anyone else). Submitting is not restricted this way.
  • 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 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 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> --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:

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

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" }   // 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

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. 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

On this page