Forms API
Introduction
The Forms API powers data-entry screens — creating and editing a profile, updating account details, or any other screen where the user fills in values and saves them. The Form Screen plugin renders the screen from a JSON form configuration, prefills it from the feed entry the user selected, and posts the result to a customer-hosted endpoint as a CloudEvent.
Three artifacts take part, and all three must agree on field IDs:
| Artifact | Provides | Owned by |
|---|---|---|
| Form configuration feed | properties[] — one entry per field. Defines what is rendered and how it validates on device. | The form author, in Zapp |
| Entry feed | extensions.form_data on the selected entry — the values the form opens with. | The backend |
| Submission endpoint | Receives data.form_data, persists it, returns success or validation errors. | The backend |
A field ID appears in all four places: properties[].id, the prefilled form_data, the submitted data.form_data, and the fieldErrors keys of an error response. Rename it in one place and prefill, submission, or error display silently breaks.
Editable Entries Feed
The feed that lists the records the user can edit — profiles, for example. Two things make an entry editable:
type— the entry type, typically"profile-edit". The value itself is a recommendation, not a requirement; what matters is that it is mapped to the Form screen in the app configuration. When the user taps the entry, the app resolves the type mapping, navigates to the Form screen, and passes the entry data to it.form_dataextension — the entry'sextensionsobject carries aform_datapayload used to prefill the form.
{
"title": "Manage Profiles",
"entry": [
{
"id": "a3JVE000005wcej2AA",
"title": "Main Profile",
"type": {
"value": "profile-edit"
},
"extensions": {
"form_data": {
"profileName": "Main Profile",
"dob": "2024-12-31",
"parentalControls": ["allow_comment", "allow_change_picture"],
"kidsProfile": false,
"profileId": "a3JVE000005wcej2AA",
"email": "profile.user@example.com",
"profileImage": "char:1741"
},
"master": 1
}
}
]
}
Hidden fields. Not every key in form_data has to be editable. The form renders a component only for the fields declared in the form configuration. A key with no matching component — "profileId" above — is never shown, stays in the screen state, and is sent back unchanged on submit. This is the supported way to round-trip immutable server identifiers.
Creating a New Record
To create rather than edit, the app must reach the Form screen without form_data, or with an empty object. Two common ways:
- Inline with the editable entries feed — append a "New Profile" entry as the last element. It can share the same type mapping or use a different one, such as
profile-add, as long as it resolves to the Form screen. - As a separate manual feed — attach a feed to a distinct "Create New" button component on the list screen. In Zapp this is a Manual Feed.
{
"id": "69ef9a0d-5582-4b60-ae34-6a2fc01fbc2b",
"title": "Profile - Create New Button",
"type": {
"value": "feed"
},
"extensions": {},
"entry": [
{
"id": "e6df0cea-7305-433f-b466-1817bec74787",
"title": "New Profile",
"type": {
"value": "profile-add"
},
"extensions": {
"form_data": {}
}
}
]
}
Form Configuration
To set up the form in your app:
- Add the Form Screen plugin to your app in Zapp.
- Create a new screen of this type.
- In the screen configuration, open the Data section.
- Assign the form structure feed — the JSON described below — to the Form Feed selector.
The configuration is an object with a required properties array. Each entry defines one field: a text input, date picker, checkbox, selector, or button.
{
"id": "fieldId",
"type": "textInput | datePicker | checkBox | singleSelect | multiSelect | button | label",
"preset": "PresetName",
"options": {
"title": "Field title"
}
}
How It Works Internally
- State initialization — the screen copies the selected entry's
extensions.form_datainto its internal screen state. With no entry data, for example when creating, the state starts empty. - Component instantiation — the screen parses
propertiesand instantiates one UI component per field using the declaredpreset. - Data binding — each component is bound to its value in the screen state. User input updates the state, and the state is what gets submitted.
Form Configuration Sample
{
"properties": [
{
"id": "profileName",
"type": "textInput",
"preset": "FormTextInput",
"options": {
"title": "Profile Name",
"required": true,
"minLength": 3,
"maxLength": 12
}
},
{
"id": "dob",
"type": "datePicker",
"preset": "FormTextInput",
"options": {
"title": "Date of Birth",
"required": true
}
},
{
"id": "parentalControls",
"type": "multiSelect",
"preset": "FormMultiSelectGroup",
"options": {
"title": "Parental Controls",
"required": true,
"minItems": 1,
"maxItems": 3,
"items": [
{ "const": "allow_comment", "title": "Allow comments" },
{ "const": "allow_change_picture", "title": "Allow picture changes" },
{ "const": "allow_change_name", "title": "Allow name changes" }
]
}
},
{
"id": "kidsProfile",
"type": "checkBox",
"preset": "FormMultiSelectGroup",
"options": {
"title": "Kids Profile"
}
},
{
"id": "buttonSave",
"type": "button",
"preset": "FormButtonSave",
"options": {
"title": "Save",
"extensions": {
"tap_actions": {
"actions": [
{ "type": "validateForm" },
{
"type": "postForm",
"options": {
"data": {
"form_data": "@{screen/}"
},
"inflateData": true,
"subject": "profile_update",
"type": "com.applicaster.post.data.v1",
"url": "https://example.com/events"
}
},
{
"type": "navigateToScreen",
"options": {
"typeMapping": "home-screen"
}
}
]
}
}
}
}
]
}
The save button above uses FormButtonSave, which the published formTypes.ts does not declare — it lists FormButton, FormButtonCancel and FormButtonDelete. Confirm the preset against your installed plugin version before copying this sample.
Field Types
| Field type | Preset | Notes |
|---|---|---|
textInput | FormTextInput | Free text input. Supports required, minLength, maxLength, secure, and numeric validation through options.inputType: "number". |
datePicker | FormTextInput | Date entry field. The form contract uses type: "datePicker"; the current rendering path still uses FormTextInput and passes inputType: "date" to the text field component. Values are stored and submitted as ISO 8601 strings such as YYYY-MM-DD; date-time strings with an offset are also accepted by validation. |
checkBox | FormMultiSelectGroup | Boolean field. A required checkbox must be set to true. For server compatibility, tolerate both booleans and "true" / "false" strings in received payloads. |
singleSelect | Usually FormMultiSelectGroup, or a custom preset such as FormAvatarPicker | One selected value. Items can be provided inline or through itemsFeedURL. |
multiSelect | FormMultiSelectGroup | Multiple selected values. Items can be provided inline or through itemsFeedURL. Server prefill and submit values are arrays of selected option IDs. Supports required, minItems, and maxItems. |
button | FormButton, FormButtonCancel, FormButtonDelete | Executes an action chain. See Buttons. |
label | "" | Static text. Confirm support against your installed plugin version before using it. |
Options
| Option | Applies to | Notes |
|---|---|---|
title | All visible fields | Display title for the generated component or feed. |
description | All visible fields | Secondary text. |
required | Inputs, date picker, checkbox, selectors | Validated before submission when validateForm runs. |
minLength, maxLength | Text input and number input | Length constraints. |
minItems, maxItems | Multiselect | Selection-count constraints. Optional empty multiselect fields remain valid unless required is also set. |
secure | Text input | Secure text entry. |
inputType | Text input | string, number, or date. |
items | Select fields | Inline options, each { "const": "<id>", "title": "<label>" }. |
itemsFeedURL | Select fields | URL that provides selectable entries. Use instead of items, not alongside it. |
extensions.tap_actions | Buttons | Action chain executed by the button. |
Date Picker
Use datePicker when the field collects a date. The submitted value is an ISO 8601 date such as 2000-05-31. The validator also accepts ISO date-time values, for example 2024-12-31T10:30:00Z or 2024-12-31T10:30:00+02:00.
Empty values are handled by required; an optional empty date field is valid. An invalid filled value shows the form validation error Please enter a valid date.
Until there is a dedicated date picker component, the screen renders this field through the text input component with date input behavior. Form authors should still use type: "datePicker" as the stable form contract.
Multiselect
Each option has a string const, and the value crossing the server boundary is an array of those const values.
{
"id": "parentalControls",
"type": "multiSelect",
"preset": "FormMultiSelectGroup",
"options": {
"title": "Parental Controls",
"items": [
{ "const": "allow_comment", "title": "Allow comments" },
{ "const": "allow_change_picture", "title": "Allow picture changes" }
],
"minItems": 1,
"maxItems": 2
}
}
Server prefill and the submitted payload both use the same shape:
{
"parentalControls": ["allow_comment", "allow_change_picture"]
}
If the user selects nothing, the submitted value is an empty array: "parentalControls": [].
Avoid commas in multiselect option IDs. The screen state uses comma-delimited internal storage while the form is open, so a comma inside an option const splits into two selections at the server boundary. Use simple stable IDs such as allow_comment or allow_change_name.
Buttons
A button is type: "button" plus an action chain under extensions.tap_actions. The preset is the role and the styling; the behavior is entirely in the action chain — a delete-styled button with a save chain will save.
| Role | Preset | Chain |
|---|---|---|
| Save | FormButton (samples above use FormButtonSave) | validateForm → postForm → navigateToScreen or goBack |
| Cancel | FormButtonCancel | navigateToScreen or goBack, optionally after confirmDialog |
| Delete | FormButtonDelete | confirmDialog → sendCloudEvent (or postForm) → navigateToScreen or goBack |
goBack returns to the previous screen instead of pushing a new one, so a save or delete does not leave the user one level deeper in the stack every time. It is supported by the plugin but not yet declared in formTypes.ts; form-e2e accepts it, and its drift test will flag the day the vendored types catch up.
Available actions are validateForm, postForm, navigateToScreen, goBack, confirmDialog, and sendCloudEvent. Two rules govern every chain:
- Actions run in order and the chain stops on failure. When
postFormreturns field errors, nothing after it runs. Anything that must not happen on a failed submit — navigation, a success dialog — belongs afterpostForm; anything that must happen first belongs before it. - Only
postFormsurfacesformErrorandfieldErrors.sendCloudEventposts and moves on: it cannot show the user an error and it does not stop the chain. UsepostFormwherever the server can reject the submission with a message the user needs to see — a save, above all. UsesendCloudEventfor a post with nothing to report back: a delete, and fire-and-forget side events such as analytics chained after a submission.
Cancel
No validateForm — a half-filled form is exactly what the user is abandoning — and no postForm. Add confirmDialog only if losing edits should be confirmed. All four dialog texts are required, and dismissing the dialog stops the chain.
{
"id": "buttonCancel",
"type": "button",
"preset": "FormButtonCancel",
"options": {
"title": "Cancel",
"extensions": {
"tap_actions": {
"actions": [
{
"type": "confirmDialog",
"options": {
"title": "Discard changes?",
"message": "Your edits will not be saved.",
"okButtonText": "Discard",
"cancelButtonText": "Keep editing"
}
},
{
"type": "navigateToScreen",
"options": { "typeMapping": "manage-profiles" }
}
]
}
}
}
}
Delete
confirmDialog first, because deletion is not undoable. Then sendCloudEvent: a delete has nothing to validate and nothing for the server to reject, so it does not need postForm's error surfacing. No validateForm either — the record is being removed, so a half-valid form must still be deletable.
Use postForm here instead if your endpoint can genuinely refuse a delete ("you cannot delete your last profile") and you want that message shown on the form. Mind the payload key when you switch: postForm sends the screen state as data.form_data, while sendCloudEvent sends the keys you declare under data — formTypes.ts declares form.
{
"id": "buttonDelete",
"type": "button",
"preset": "FormButtonDelete",
"options": {
"title": "Delete Profile",
"extensions": {
"tap_actions": {
"actions": [
{
"type": "confirmDialog",
"options": {
"title": "Delete this profile?",
"message": "This cannot be undone.",
"okButtonText": "Delete",
"cancelButtonText": "Cancel"
}
},
{
"type": "sendCloudEvent",
"options": {
"data": { "form": "@{screen/}" },
"inflateData": true,
"subject": "profile_delete",
"type": "com.applicaster.post.data.v1",
"url": "https://example.com/events"
}
},
{
"type": "navigateToScreen",
"options": { "typeMapping": "manage-profiles" }
}
]
}
}
}
}
The delete payload carries the same screen state as a save — the record identifier rides along as a hidden key. The server tells the two apart by subject, and should read the identifier from form_data rather than trusting an edited display name as the lookup key.
Server-Side Guide
The server participates in two directions: it passes initial values into the form through the selected entry's extensions.form_data, and it receives submitted values from the postForm CloudEvent under data.form_data.
Passing Data Into the Form
When editing an existing object, include a form_data object in the selected feed entry. Keys must match the form field IDs.
{
"extensions": {
"form_data": {
"profileId": "a3JVE000005wcej2AA",
"profileName": "Tester Test",
"email": "profile.user@example.com",
"displayName": "Big Test",
"gender": "Male",
"dob": "2000-05-31",
"profileImage": "char:1741",
"kidsProfile": false,
"parentalControls": [
"allow_comment",
"allow_change_picture",
"allow_change_name"
]
}
}
}
| Field type | Server prefill shape | Example |
|---|---|---|
textInput | String, or another primitive that can be stringified | "Tester Test" |
datePicker | ISO 8601 string | "2000-05-31" |
singleSelect | Selected option const string | "char:1741" |
multiSelect | Array of selected option const strings | ["allow_comment", "allow_change_picture"] |
checkBox | Boolean preferred; tolerate "true" / "false" strings when receiving submissions | false |
For new objects, omit form_data or pass {}. Null and undefined values are not prefilled into visible fields.
Multiselect Server Contract
- Send selected values as
string[]inextensions.form_data. - Receive selected values as
string[]in the submitteddata.form_data. - Receive no selection as an empty array
[], including untouched multiselect fields. - Do not send option objects as selected values. Option objects belong in the form configuration
itemsor item feed, not inform_data. - Do not rely on comma-separated strings at the server boundary. Comma-separated storage is an internal screen-state detail only.
Correct:
{
"parentalControls": ["allow_comment", "allow_change_picture"]
}
Incorrect:
{
"parentalControls": [{ "const": "allow_comment", "title": "Allow comments" }]
}
Receiving Form Submissions
With inflateData: true, the app resolves @{screen/} — the whole screen state — before sending. The backend receives a CloudEvent envelope whose data.form_data contains the submitted values.
{
"specversion": "1.0",
"type": "com.applicaster.post.data.v1",
"datacontenttype": "application/json",
"subject": "profile_update",
"source": "aio://com.example.profileapp/versions/0.2.0-dev.1",
"time": "2026-09-02T20:44:11.103Z",
"data": {
"form_data": {
"profileName": "Tester Test",
"kidsProfile": "false",
"profileId": "a3JVE000005wcej2AA",
"profileImage": "char:1741",
"email": "profile.user@example.com",
"displayName": "Big Test",
"gender": "Male",
"dob": "2000-05-31",
"parentalControls": [
"allow_comment",
"allow_change_picture",
"allow_change_name"
]
}
},
"id": "26681f0c-796c-429b-bdab-176acba65f7c"
}
Read the form values from data.form_data. Treat the other CloudEvent fields as event metadata: type identifies the event kind, subject identifies the configured business action, source identifies the app and version, time records when the event was created, and id is the event identifier.
Normalize and validate the payload by field ID on the server. Important submitted shapes are:
| Field type | Submitted shape |
|---|---|
textInput | String |
datePicker | ISO 8601 string |
singleSelect | Selected option const string |
multiSelect | Array of selected option const strings; empty selection is [] |
checkBox | Boolean when normalized from a declared checkbox field, but backends should also tolerate "true" and "false" strings from legacy flows or hidden values |
Client-side required, minLength, and minItems are validated by validateForm on device as a user-experience affordance, not as enforcement. Re-check every constraint on the server.
Returning Server Validation Errors
If server validation fails, return a failed HTTP response whose body contains formError, fieldErrors, or both. fieldErrors must be keyed by form field IDs, not by UI component IDs.
{
"status": 400,
"formError": "Profile could not be saved.",
"fieldErrors": {
"profileName": ["This profile name is already used."],
"dob": ["Date of birth must be in the past."],
"parentalControls": ["Select at least one parental control."]
}
}
The form presents formError as a form-level message and presents fieldErrors inline on the matching fields. When the server returns field errors, the form submission action is cancelled so later actions in the button chain do not continue.
The failure has to arrive as a failed HTTP status. A 200 carrying an error body is read as success, and the rest of the button's action chain — including navigation away from the form — runs anyway.
Backend Checklist
- Keep field IDs stable across form configuration, prefilled
form_data, submitted payloads, andfieldErrors. - Send multiselect selected values as arrays of option IDs.
- Treat missing optional values and empty arrays intentionally;
[]means the user has no selected multiselect options. - Store date values as ISO 8601 strings.
- Tolerate checkbox booleans and
"true"/"false"strings when reading submitted payloads. - Decide explicitly what to do with
form_datakeys you do not recognize. An allowlist of writable field IDs is the safe default: hidden identifiers still round-trip because you read them without writing them blindly. - Return validation errors with
fieldErrors[fieldId] = string[]and a failed HTTP status. - Preserve hidden identifiers by including them in the initial
form_datawhen they must come back on submit. - Give each business action its own
subjectso one endpoint can serve save, delete, and any other write.
Related
- Profiles Feed — the feed that lists profiles for the picker screen.
- Cloud Events — envelope handling for the endpoint that receives submissions.