Skip to main content

Form endpoint vs form backend API

When a plain form submission endpoint is enough, when you need a form backend with a shared schema, and the request path Fillo runs.

Build this with your agent. Copy a prompt that hands this guide to Claude Code, Codex, Cursor, or any coding agent.

A form endpoint accepts a POST and stores the values. A form backend also owns the schema, validation, uploads, response review, and delivery around that POST. This page helps you choose one for a form inside your product.

Compare the two

NeedPlain form endpointForm backend API and SDK
One stable contact formEnoughOptional
Native form inside a signed-in productYou build the UI and pass context yourselfSDK renders native controls, or headless state for your components
Teammates edit questions after launchYou build an editor and migration rulesDraft and published versions stay separate
Client and server validation agreeYou keep two copies in syncOne schema drives both
File uploadsYou build sessions, provider access, retries, and cleanupUpload lifecycle and storage adapters included
Response review and CSV exportYou build the admin viewResponse grid and exports included
Webhooks and integrationsYou build signing, retries, and statusSigned delivery with retries and a delivery log
Old answers survive schema editsYou version every changeEach response keeps its form version

If the left column is small and stable, use the endpoint.

What a plain endpoint leaves to you

ts
const response = await fetch("/api/contact", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ email, message }),
});

if (!response.ok) {
  // Map the server error back to the right field and retry state.
}

The code is short because the rest of the contract lives elsewhere. Your application still owns:

  • field definitions in the UI and in the server validator
  • what "required" means for a hidden field
  • stable IDs when labels or options change
  • spam handling and duplicates
  • upload sessions and storage access
  • the response store, review screen, exports, and deletion
  • webhook delivery and retries

That's fine when your app already has all of these.

How a request moves through Fillo

  1. A published schema names every field by a stable ID.
  2. The browser fetches the published form. Code-defined forms sync with a public pk_ key.
  3. The SDK renders native inputs inside your route, layout, and session.
  4. The server validates the same field IDs and visibility rules again.
  5. Files upload browser-direct to storage, and the server verifies each one.
  6. Fillo stores the accepted response with the form version that produced it.
  7. Webhooks, integrations, and exports read that stored response.

Failed deliveries retry from the stored response, so nobody has to submit twice. Files go to your connected Google Drive, Box, S3, or R2. Without connected storage, they use Fillo's short-lived transit storage.

Why the server validates again

Client validation gives fast feedback. It isn't authority. A custom renderer or a script can skip it, so the submission endpoint checks required fields, option IDs, and visibility again. Hiding a field changes the form flow. It doesn't grant access to an account, a file, or a management action.

Keep each credential in its place.

CredentialLives inCan do
No keyBrowserFetch a published form and submit to it by ID
pk_ publishable keyBrowser bundleSync code-defined schemas. Can't read responses.
fsk_ project API keyYour serverRead or manage project resources within its scopes

Management routes aren't CORS-open, so an fsk_ key pasted into browser code fails instead of working.

Read responses from your server

Create a key with responses:read, keep it in a server-only variable, and request a page:

ts
const response = await fetch(
  "https://fillo.so/api/v1/manage/forms/product-feedback/responses?limit=100",
  { headers: { Authorization: `Bearer ${process.env.FILLO_API_KEY}` } },
);

if (!response.ok) throw new Error(`Fillo returned ${response.status}`);

const { data, nextCursor } = await response.json();
// Pass nextCursor back as ?cursor= for the next page.

The list returns accepted responses. Held submissions stay out until someone releases them. Use a signed webhook when another system should react to every response. Use the API when your server needs to query or display responses on demand.

Pick who owns the schema

  • Builder-managed: a teammate edits a draft in Fillo and publishes it.
  • Code-managed: defineForm() or Fillo JSX stages the schema from your repository, then someone publishes it.

Pick one owner for structural changes per form. Code suits schemas that belong in review and tests. The builder suits copy changes without a deploy.

When a plain endpoint is enough

Stay with an endpoint when all of these hold:

  • The form is small and rarely changes.
  • Your app already validates on the server and stores responses.
  • Nobody needs a separate review screen.
  • Uploads, retries, exports, and delivery are absent or solved.
  • Only developers change the form.

Move to a form backend when two of these start repeating:

  • The same fields live in UI code, validators, exports, and admin views.
  • Teammates need safe edits after launch.
  • Uploaded files must stay attached to a response.
  • Several routes need the same form in different layouts.
  • Respondent identity matters.
  • Delivery failures need to be visible.
  • Old responses must stay readable after schema changes.

Start with one form whose cost is already visible, such as client intake or in-app feedback. Keep the host route, replace the contract, and verify one accepted response end to end. The Next.js feedback starter and the Vite intake starter show the whole path.

This page for agents: /guides/form-builder-api-vs-submission-endpoint.md · index at /llms.txt

Updated

Was this page helpful?