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
| Need | Plain form endpoint | Form backend API and SDK |
|---|---|---|
| One stable contact form | Enough | Optional |
| Native form inside a signed-in product | You build the UI and pass context yourself | SDK renders native controls, or headless state for your components |
| Teammates edit questions after launch | You build an editor and migration rules | Draft and published versions stay separate |
| Client and server validation agree | You keep two copies in sync | One schema drives both |
| File uploads | You build sessions, provider access, retries, and cleanup | Upload lifecycle and storage adapters included |
| Response review and CSV export | You build the admin view | Response grid and exports included |
| Webhooks and integrations | You build signing, retries, and status | Signed delivery with retries and a delivery log |
| Old answers survive schema edits | You version every change | Each response keeps its form version |
If the left column is small and stable, use the endpoint.
What a plain endpoint leaves to you
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
- A published schema names every field by a stable ID.
- The browser fetches the published form. Code-defined forms sync with a public
pk_key. - The SDK renders native inputs inside your route, layout, and session.
- The server validates the same field IDs and visibility rules again.
- Files upload browser-direct to storage, and the server verifies each one.
- Fillo stores the accepted response with the form version that produced it.
- 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.
| Credential | Lives in | Can do |
|---|---|---|
| No key | Browser | Fetch a published form and submit to it by ID |
pk_ publishable key | Browser bundle | Sync code-defined schemas. Can't read responses. |
fsk_ project API key | Your server | Read 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:
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.
Related
- Code-first example: Inspect a schema rendered with your own components.
- Form builder API: See what the management API covers.
- Management API reference: Scopes, pagination, errors, and rate limits.
- Security model: Where each credential belongs and what published forms expose.