Create a developer Form group when you want campai to render fields and run your server function after submission. Build an internal follow-up request form: an administrator enters a case reference, and your app saves it on a review list. Repeating a reference returns “Already on the list” instead of creating duplicate work.
Use a developer app in your test organization. Enable Settings → Database access for this app. No organization API permissions or external service are needed. The follow-up list belongs to your app; it is not a native campai task or membership application.
What you’ll have at the end
An internal follow-up form that persists a case reference and recognizes repeated submissions without adding duplicate work.
- Reference field
- Normalize and validate
- Save once in app database
- Confirm or show duplicate
Before you start
- An unpublished Form group with Admin Scope
- Database access enabled
- A required text field named reference
On this page
- Open Edit → Add group, select Form, and name it
Form verification. - Choose the intended Scope. Select Admin for this internal review form.
- Create the group. In Project files, select its Form item.
- In the form builder, choose Add group to add a field section. This form section is distinct from the developer app group.
- Give the section the label
Follow-up request. - Choose Add new field → Text input.
- Set Question or caption to
Reference, Name toreference, and enable Required. - Save changes.
The caption is the text visitors see. Name is the stable key your server code uses. A field without a configured Name uses its generated field ID. Renaming a caption is not the same operation as changing the data key.
Keep this section non-repeating for the example. Repeating groups have a different result shape: an array of row objects under the group's generated ID.
Select the Form group's index.ts and replace its contents with:
Example
export async function main(result) {
const reference = result.reference.trim().toUpperCase()
if (!/^[A-Z0-9-]{3,40}$/.test(reference)) {
throw new Error('Use 3–40 letters, numbers or hyphens for the reference.')
}
const key = ['follow-up', reference] as const
const previous = await storage.get(key)
if (previous.versionstamp !== null) {
return { message: { title: 'Already on the list', message: `${reference} is already awaiting review.` } }
}
const saved = await storage.atomic()
.check({ key, versionstamp: null })
.set(key, { reference, status: 'open', requestedAt: new Date().toISOString() })
.commit()
if (!saved.ok) {
return { message: { title: 'Already on the list', message: `${reference} was just added by another submission.` } }
}
return { message: { title: 'Follow-up saved', message: `${reference} is on the review list.` } }
}The argument contains named values directly. For this field, read result.reference, not result.data.reference. The editor derives the argument type from the saved form definition. If the property is missing from the inferred type, check the field's Name and save the form before changing the code.
The form validation checks the required field before invoking the function. The server trims and uppercases the reference and limits it to 3–40 letters, digits, or hyphens. This turns guide-001 and GUIDE-001 into the same key. The atomic check ensures that two simultaneous submissions cannot both claim they created it.
The returned object has message.title and message.message. Return that object directly; do not wrap it in type, value, or data. The runtime adds its own transport envelope.
Choose the group's Simulate action. If you have unsaved changes, use Save and continue. Wait for validation and build to finish, then fill the rendered Reference field with GUIDE-001 and choose Start simulation.
Confirm the returned message says Follow-up saved and GUIDE-001 is on the review list. Submit it again and expect Already on the list. Check the simulation result as well as the rendered confirmation. A successful build without a submission has not executed main.
Repeat with an empty field and with whitespace. The empty field should fail form validation; whitespace must not produce a successful confirmation from your function. This catches mistakes that a happy-path submission alone will miss.
The form builder's Test action helps inspect the form layout. Use the app's Simulate workflow to verify the server code and its return value.
Use this independent server entry in a Schedule group in the same app and simulate it manually:
Example
export async function main() {
const entry = await storage.get<{ reference: string; status: string }>(['follow-up', 'GUIDE-001'])
if (entry.versionstamp === null) throw new Error('Submit GUIDE-001 first')
console.log({ reference: entry.value.reference, status: entry.value.status })
}Expect reference: "GUIDE-001" and status: "open". A confirmation is returned only after the database commit succeeds. A failed commit cannot produce “Follow-up saved.” This persists across simulations; React state or a module variable would not.
To build an administrator review screen, list entries using storage.list({ prefix: ['follow-up'] }, { limit: 50 }), return a small projection through a widget action, and page with the iterator's cursor. Add an explicit status transition when a reviewer completes an item. The widget guide demonstrates that client/server pattern.
Required text fields are inferred as strings. Optional fields can be absent, so check them before calling string methods. Number and Boolean fields have different types. For a repeating group, inspect the inferred array structure rather than treating it as a single top-level text value.
When changing a field's Name, update every place the server reads that name and retest. Do not derive field keys from translated captions. Keep stable keys even if the visitor-facing language changes.
The supported return contract allows either a message result or an HTML result, not both. Prefer the message result for a simple confirmation. If you return HTML, escape or sanitize submitted values before putting them into markup.
Receiving input does not save an application or create a member automatically. If that is the intended workflow, review Build a custom membership form and the applicable endpoint contract first. Grant only the server permissions that operation needs.
App simulation uses real API requests when your code calls the API. A write performed during simulation is not automatically rolled back. Keep tests limited to designated test records and avoid logging complete submissions.
Before sharing, review Set access for forms and widgets, publish the intended version, and test the shared form as the intended audience. An owner can simulate a form even when the published access policy would exclude another visitor.
Check your result
- GUIDE-001
- Saves one open follow-up request.
- Same reference again
- Returns Already on the list.
- Whitespace or invalid reference
- Rejects input without saving a request.