Automette docs
Forms

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, and closed: false reopens.
  • 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=1 destroys 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 typeJSON type
text, textarea, email, tel, url, date, selectstring
numbernumber, or null when left empty
checkboxboolean
ratingnumber
multi_select, file_uploadarray of strings
repeaterarray 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

  1. POST /api/v1/forms with your field list
  2. POST /api/v1/forms/{id}/publish, then embed public_url on your site
  3. POST /api/v1/forms/{id}/webhooks pointing at your endpoint, and store the secret
  4. On each delivery: verify webhook-signature, dedupe on webhook-id, write your row
  5. 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

On this page