Forms

Forms are built in Cockpit and rendered by the storefront — the delivery route that serves a published form's FormKit schema, the block that renders it, and how a submission is recorded.

Forms in a storefront — contact, quote request, sample order, callback, registration of interest — are built in Cockpit and rendered by the theme. A customer's team creates the form and its fields; your theme renders whatever they built.

That means a new form is not a deploy. It's a form the customer creates and an editor places on a page.

How it works

text
Cockpit                 →  a form, with a slug and a field definition
    ↓
GET /v1/forms           →  the published form (status: live)
    ↓
GET /api/forms/{slug}   →  your BFF, localized for the buyer
    ↓
the `form` block        →  FormKit renders the definition verbatim
    ↓
POST /api/forms/{slug}/submit  →  POST /v1/forms/submissions

The definition is a FormKit schema — the same machinery the checkout uses. That's why an editor-built form gets real validation, conditional fields, and accessible markup without anyone writing a component for it.

FormKit, not another form library. If you're adding a custom input type, it's a FormKit input.

The delivery route

RouteWhat it does
GET /api/forms/{slug}Returns the published form for that slug — its name, its FormKit definition, and its settings.
POST /api/forms/{slug}/submitRecords a submission.

Both are on your own origin; the gateway call happens inside them. See The BFF pattern.

Three behaviours of the delivery route are worth knowing:

Only live forms are served. A draft form is a 404. There is no way for an unpublished form to appear on a storefront, which is the property you want — but it also means "the form 404s" usually means "it hasn't been published", not "the slug is wrong".

The definition is localized before it reaches the browser. Labels, validation messages, and the submit label are resolved to the buyer's locale server-side, so one form definition serves every language rather than one per language.

Settings come with it. The submit button label and the success message are the form's own, set by whoever built it — don't hard-code either in your theme.

The sample form

Every tenant that installs the Forms App starts with one sample form, with the slug contact, so there is a form to start from. It is seeded as a draft: set its status to live with PUT /v1/forms/{id} before the storefront can render it; until then a submission to it is refused with 422. The seeding runs when the app is installed, so normally nobody needs to call the route below.

POST /v1/forms/defaults is the manual re-run, for a tenant installed before the sample existed or one that removed it and wants it back. It is keyed on the slug: a slug that already exists is never overwritten, even if the sample has been edited since. The response lists created (the slugs this call wrote) and existing (the slugs that were already there). On a settled tenant created is empty.

Campaign and lead stage

Two optional fields on a form tie it to campaigns and to contacts. Both are set with POST /v1/forms and PUT /v1/forms/{id}, and GET /v1/forms filters on both (exact match).

  • campaign_ref (uuid, or null): the campaign the form belongs to. A campaign claims a form by setting this field, and null releases it. Every submission gets it as campaign_ref. A value that is not a uuid is a 400.
  • lead_stage (string, or null; default lead): the contact stage a sender is filed at. A person not known yet is created at this stage, but only when the submission carries an email or phone, and never for spam. A subscriber, or a bare contact nobody placed, is moved to it. Anybody further along stays where they are. A newsletter form uses subscriber. null means the form files no contact at all; set it on a form that should not create contacts.

When a contact is created or moved, its lead_source_ref is the campaign the submission resolved to, or form:<id> when there is none. A contact who is left where they are gets no lead_source_ref.

The block

The form block renders a form on any page. It's in the standalone content group of the block catalog, so an editor can drop it into a content section like any other block.

Its distinguishing feature is how the form is chosen. Rather than typing a slug, the editor picks from the tenant's live forms in a picker: the theme registers a complex option type through a Blökkli module, and the option renders as a list of real forms rather than a free-text field.

That's the pattern to copy whenever an editor has to reference a platform record. A free-text id field guarantees typos; a picker cannot produce an invalid reference.

The block renders the same way on a live page, on the editor canvas, and in preview — so what the editor sees while placing the form is the form.

Submissions

A submission is recorded against the form on the platform. Submissions are then managed where the form was built, and are reachable from the CLI for scripting and export:

Shell
revenexx forms submissions-list --form-id <FORM_ID>

The full command set — create, list, get, update, delete, prune — is in the forms command reference.

The list, GET /v1/forms/submissions, also filters on campaign_ref, contact_id (one person's submissions), lead_stage and page_url (one page, by its exact URL). Each filter is an exact match.

The create call, POST /v1/forms/submissions, also takes these optional fields. Each is create-only:

  • campaign_ref (uuid): the campaign the visitor came through, when the storefront resolved one from the UTM campaign. Used only when the form itself belongs to no campaign.
  • page_url: the page the form was sent from. Stored as page_url.
  • locale: the language the visitor read the form in.
  • ip: the visitor's address.
  • user_agent: the visitor's browser.

locale, ip and user_agent are passed to the consent manager, the app that holds marketing consents and sends the double opt-in confirmation mail. They go only when the visitor ticked a consent field. ip and user_agent are not stored on the submission.

Two things to be deliberate about:

  • A form is a data-collection surface, so treat its payload as personal data. Don't log submission bodies from your server routes; see Logs.
  • Don't use a form as an integration transport. If a quote request has to reach a CRM, build that in Integration Studio off the submission, rather than posting to a third party from your theme's submit route.

Reading, triaging and deleting a submission

Three routes work on one submission, by its id:

RouteWhat it does
GET /v1/forms/submissions/{id}Reads one submission: what the visitor typed, its source page and page_url, its inbox status, its metadata, and the contact it was filed against (contact_id, contact, lead_stage), its consents and its campaign_ref.
PUT /v1/forms/submissions/{id}Triage (moving a submission through the inbox): moves status and metadata.
DELETE /v1/forms/submissions/{id}Deletes the submission permanently.

Each route needs its own scope (forms.submissions.get, forms.submissions.update, forms.submissions.delete) or forms.*; see Scopes. A key without the scope is refused with 403.

Read. data is keyed by the name of each node in the form's definition. Treat it as end-user data. An id that is not a UUID is a 400; an id that is not in this tenant is a 404.

Triage with PUT. The inbox status is one of new, read, archived or spam, and it can move between any of them. The platform sets spam only when a honeypot field is configured and spam handling is set to keep and flag; under reject, a caught submission is never stored. The honeypot is off by default, so out of the box nothing is marked spam but by a person.

Under the default policy (Cockpit setting How much of a submission may be changed, submission_edit = status_only), only status and metadata can change: a patch that alters any other column, such as data, source, form_id or created_at, is refused with a 403 that names the columns.

Sending back a stored value unchanged is not a change, so a client that PUTs the whole row still works. An empty body is a 400. updated_at moves with the triage.

metadata is free-form, plus notify_email and notify_source (the recipient resolved when the submission arrived), spam_reason when the honeypot caught it, contact_outcome (what happened to the contact: found, moved, created, skipped or failed) and contact_reason when there is one. consent_requests holds one outcome per consent field (requested, not_checked, no_address or failed), written once the consent manager has been asked.

Request
curl -X PUT "https://api.revenexx.com/v1/forms/submissions/<SUBMISSION_ID>" \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..." \
  -H "Content-Type: application/json" \
  -d '{"status": "read"}'

Delete is permanent. There is no soft delete and no undo, and no event is emitted when a submission is deleted, so an automation that already acted on it is not told it was withdrawn. The response is {"deleted": true, "id": "<SUBMISSION_ID>"}; a missing id is a 404.

The retention setting (Cockpit: Delete submissions after (days), submission_retention_days) only limits the bulk prune; it does not apply to this route. To get a lead out of the inbox without losing it, PUT status: archived instead.

Adding your own fields

Because the definition is a FormKit schema, extending what a form can collect is a FormKit question, not a platform one. Register a custom input in the theme's FormKit configuration and it becomes available to schemas rendered by the theme.

The base layer already does this — the barcode-scanner input, for instance, is a custom FormKit input with a camera overlay, which is what makes "scan a SKU into a quick-order form" a field rather than a feature.

Vocabularies and deleting a form

RouteWhat it does
GET /v1/forms/vocabulariesLists the enums this app publishes, form-statuses and submission-statuses, each with its name, title and description. The values are not included: fetch one by name for them.
GET /v1/forms/vocabularies/{name}Returns one vocabulary with its values in lifecycle order. Each value has a key, title, description, a badge tone and a final flag. closed: true means no other value is accepted. An unknown name is a 404.
DELETE /v1/forms/{id}Deletes a form. If it has submissions, the Forms app's tenant setting form_delete_policy decides: block (the default) refuses with 409 and the count, archive archives the form and keeps its submissions, cascade deletes the form and its submissions. A form with no submissions is always deleted.

Read deleted and archived in the 200 response, not the status code alone. Under cascade, the submissions are removed from the database with no soft delete and no undo.

An operator sets the policy in Cockpit's settings form for the Forms app; the API cannot change it, and GET /v1/settings/apps/forms reads the value in force (Settings). The policy is one setting for the whole tenant, not per market, and the submission count (details.submissions in the 409, submissions in the 200 body) covers the form's whole inbox in every market. Check it, and GET /v1/forms/submissions?form_id=…, before you allow cascade. A non-UUID {id} is a 400; an id that is not in this tenant is a 404.

Next steps

Was this page helpful?