Forms API
Everything the builder does to a form's questions can be done from code. Use it to provision a form per client, keep form definitions in your repository, or let your own automation stand a form up without anyone opening a dashboard.
All endpoints use the same Bearer API key as the rest of the API, and are scoped to the key's team. Full reference: Forms endpoints.
What belongs where
The API owns the questions. The builder owns the design — theme, layout, logic, multi-page flow, notification emails, document generation.
The split is deliberate, and it holds in both directions: restyling a form in the builder never disturbs the fields your code provisioned, and PATCHing fields never flattens the layout someone designed.
You choose the keys
{
"title": "Book a free class",
"fields": [
{ "key": "parent_name", "type": "text", "label": "Your name", "required": true },
{ "key": "parent_email", "type": "email", "label": "Email", "required": true },
{ "key": "child_age", "type": "select", "label": "How old is your child?",
"required": true, "options": [
{ "value": "8_9", "label": "8–9 years" },
{ "value": "10_11", "label": "10–11 years" }
] },
{ "key": "message", "type": "textarea", "label": "Anything else?" }
]
}key is the contract. It is what arrives in the form.submitted webhook and in
the submissions endpoint, so your integration decides it rather than discovering a
generated one. Keys must match [a-z][a-z0-9_]* and be unique within the form.
Field types: text, email, tel, number, textarea, select, checkbox,
date. Richer question types — ratings, file uploads, multi-select, repeating
sections — are added in the builder and read back marked editable: false.
The API never destroys what it cannot create: leaving such a field out of a
PATCH keeps it, and you can send it straight back unchanged. So the obvious
round trip is safe — GET the form, change one label, PATCH the whole
fields array back, and nothing built in the dashboard is lost.
Anything an update did beyond what you literally asked for comes back in
warnings:
{ "warnings": [
"Removed field \"message\".",
"Dropped 1 conditional logic rule(s) that depended on a removed field."
] }Heading and description are content, not metadata
title names the form in your dashboard. It is not what respondents read.
What they read is a heading block and an intro paragraph, and both are settable and removable:
{
"title": "Book a free class",
"heading": "Book a free class",
"description": "Fifty minutes, online, and free."
}heading defaults to title when you create a form. Set either to null to
remove it:
{ "heading": null, "description": null }That combination is what an embed usually wants — a host page nearly always
supplies its own headline in its own typography, and a form repeating it
underneath looks like a mistake. Removing the heading costs you nothing in the
dashboard, because title still names the form there.
Both come back on GET, so you can check what a form actually renders.
Appearance
Design belongs in the builder, with three exceptions — an embedded form has to sit inside a page we don't control:
{ "appearance": { "background": "#0b1120", "surface": "#101828", "card": "transparent" } }background— the page behind the form. Ignored in embeds, which are always transparent.surface— the card the questions sit on.card: "transparent"— drops the card entirely, so your own surface shows through.
Colours are hex (#1a56db or #fff). There is no "transparent" colour — use
card: "transparent" instead.
Embeds are transparent by default. The page, the card, and the document
background are all see-through, so a form drops onto a coloured surface without
any of this. Use appearance when you want the form to carry its own colours.
Email notifications
A form provisioned from code should not need a dashboard visit before it emails
anyone, so notifications are settable on create and on PATCH:
{
"notifications": {
"emails": ["sheeba@example.com"],
"reply_to_field": "parent_email",
"include_document_link": false
},
"respondent_confirmation": {
"email_field": "parent_email",
"subject": "We've got your request",
"message": "Thanks — she'll be in touch within a day with some times."
},
"sender_name": "Riverside Tutors"
}Both point at fields by the key you chose, never by an internal id — the
whole contract is that you own the keys. email_field must name an email
field; anything else is rejected with a message saying so. Send
"respondent_confirmation": null to turn the confirmation off.
All of it comes back on GET /api/v1/forms/{id}, so an integration can verify
its own configuration rather than trust that a POST landed.
Limits and where respondents land
{
"limits": { "close_at": "2026-09-01T00:00:00Z", "max_submissions": 100 },
"after_submit": { "action": "redirect", "redirect_url": "https://example.com/thanks" }
}limits closes the form on its own — by date, by response count, or both.
after_submit chooses between the thank-you page authored in the builder
(show_page) and sending the respondent somewhere on your own site
(redirect).
Spam protection
Every form has a captcha on by default. The hosted and embedded pages solve it for real respondents, so it only matters if your own code posts responses.
If that is you, submit through
POST /api/v1/forms/{id}/submissions instead of the public endpoint. Your API
key establishes who is calling, so no captcha applies and the form keeps its
protection for the public page. See Submitting from your own
code.
You can still turn it off with "captcha": false on create or in a PATCH,
followed by a publish — but a form open to the internet with no captcha will
collect spam, so prefer the authenticated endpoint.
Submitting from your own code
curl -X POST https://automette.com/api/v1/forms/$FORM_ID/submissions \
-H "Authorization: Bearer $AUTOMETTE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"answers": {
"video_title": "Ship It Faster",
"tagline": "Build a document API in one afternoon",
"cover_image": "form-uploads/cm4frm.../image.jpg"
}
}'The answers run through the same path as a response typed into the form: they validate against the published version, the close state and submission limit apply, the response appears in the inbox and the CSV export, webhooks fire, emails send, and a connected template generates in the background.
For image and file answers, send the key returned by
POST /api/forms/{id}/upload. That endpoint needs no authentication and is the
same one the hosted page uses.
This is not how you generate a document from code.
POST /v1/renders is: give it the data, get
the file back. Come here when you want what the form adds around it — the
response log, the notifications, the field keys and template mapping you
configured once and do not want to restate on every call. Typical reasons are a
front-end you host yourself, importing responses from another form product, and
testing your own pipeline without disabling spam protection.
Closing, archiving, deleting
Provisioning a form per client means eventually retiring one, and none of it needs a dashboard visit:
PATCH { "closed": true, "closed_message": "…" }stops responses immediately. Closing is not a new version, so no publish is needed, andclosed: falsereopens.PATCH { "archived": true }files it away, closing it as a side effect.DELETE /api/v1/forms/{id}moves it to the trash and stops the public link. Responses are kept, but the form leaves this API — restore it from the dashboard.?permanent=1destroys it and its responses outright.
Publish is what makes it real
POST /api/v1/forms returns a draft. Nothing is reachable until you publish:
POST /api/v1/forms/{id}/publish{ "status": "published", "version": 3,
"public_url": "https://forms.automette.com/abc123",
"embed_url": "https://forms.automette.com/abc123/embed" }public_url is the link to share; embed_url is the same form without page
chrome, for an <iframe>. The embed posts its own height to the host page as
it changes, so the iframe can size itself instead of guessing — see
Publishing for the listener.
Editing later follows the builder's rule — a PATCH changes the draft only,
and respondents keep seeing the published version until you publish again.
Reading submissions
Webhooks are still the right primary path: they arrive as responses land, with no polling. Add one per form and verify the signature — see Webhooks.
But every webhook consumer eventually has an outage, and a delivery your endpoint never acknowledged has to be recoverable. That is what the submissions endpoint is for:
GET /api/v1/forms/{id}/submissions?since=2026-08-01T00:00:00Z&limit=100{ "submissions": [
{ "id": "cm4sub9tz0001kp04h3m8x2qa",
"submitted_at": "2026-08-14T18:22:10.000Z",
"answers": { "parent_name": "Anita", "child_age": "8_9" } }
],
"next_cursor": null }answers is the same object the webhook carries, so one parser handles both
paths. Call it with since on deploy, or after any incident, and backfill
whatever you missed. Results are newest first and cursor paginated; spam is
withheld unless you pass status=spam or status=all.
Answer values are typed
Values are typed by field, not always strings. A number field returns
3, not "3", and a checkbox returns true. A string-only parser drops
those values silently.
| Field type | JSON type |
|---|---|
text, textarea, email, tel, url, date, select | string |
number | number, or null when left empty |
checkbox | boolean |
rating | number |
multi_select, file_upload | array of strings |
repeater | array of objects |
A skipped field may be absent from the object entirely.
Webhook secrets
Creating a webhook through the API returns its signing secret in the 201
response, and only there:
POST /api/v1/forms/{id}/webhooks { "url": "https://example.com/hooks/automette" }Store it at that moment — no later call will show it again, and without it a form provisioned entirely from code cannot verify its own deliveries.
Deliveries follow the Standard Webhooks spec:
webhook-id: evt_form_cm4sub9tz0001kp04h3m8x2qa
webhook-timestamp: 1755244930
webhook-signature: v1,K5o0N…The signature is an HMAC-SHA256 over
{webhook-id}.{webhook-timestamp}.{raw-body}, base64-encoded. webhook-id is
evt_form_<submission_id> and is stable across retries, so dedupe on it. See
Webhooks for verification code you can copy.
A typical integration
POST /api/v1/formswith your field listPOST /api/v1/forms/{id}/publish, then embedpublic_urlon your sitePOST /api/v1/forms/{id}/webhookspointing at your endpoint, and store the secret- On each delivery: verify
webhook-signature, dedupe onwebhook-id, write your row - On deploy:
GET /api/v1/forms/{id}/submissions?since=…to catch anything missed
Steps 1–3 happen once. Steps 4–5 are the running integration.
Last updated on