Skip to main content

Form submissions

Every submission your site's forms collect lands in a site's inbox, and this resource is that inbox over the API: read submissions, record which ones you've processed, and delete them once they're safely somewhere else.

Submissions live under a site, not under your organization — the paths all start /v1/sites/{siteId}/. Listing your sites is its own resource.

The form submission object

{
"id": "sub_1",
"object": "form_submission",
"form": "contact",
"path": "/contact",
"fields": { "email": "hi@example.com", "message": "Hello!" },
"read": false,
"created": "2026-07-20T18:23:23.950Z"
}
FieldTypeNotes
idstringSubmission id. Unique within a site, not across your organization.
objectstringAlways "form_submission".
formstring | nullThe form's name, as set in the Besigner. null on a submission that predates named forms.
pathstring | nullThe page path the visitor submitted from.
fieldsobjectExactly what the visitor typed, field name → value. Not writable — see below.
readbooleanYour processing flag. The console inbox toggles the same field.
createdstring | nullISO 8601.

read is the only writable field

A submission is a record of what a person typed on your site. If an integration could edit fields, nothing in the inbox would be attributable to the visitor any more — so fields, form, path and created are immutable over the API, and a PATCH that names any of them is rejected rather than silently ignored.

read is different: it's not the visitor's data, it's yours. It's the flag the console's inbox already toggles, and it's the one piece of state an integration needs to be able to write.

Why you want read

Lists are ordered by id, not by date. That single fact decides how you should write a sync:

  • Without read, "which submissions are new?" has no answer from the API. You'd page the whole list every run and keep your own record of every id you'd handled — forever, since ids never age out of the list.
  • With read, the state lives next to the submission. Page the list, handle anything with read: false, mark it, done. Two integrations can share one site without each keeping its own ledger.
// Process everything unhandled, exactly once.
let cursor = null
do {
const page = await get(
`/v1/sites/${siteId}/form-submissions?read=false`,
{ cursor },
)
for (const submission of page.data) {
await pushToCrm(submission)
await patch(
`/v1/sites/${siteId}/form-submissions/${submission.id}`,
{ read: true },
)
}
cursor = page.next_cursor
} while (cursor)

?read=false is what keeps that loop cheap. Without it the run pages the site's entire history every time — and every page is a billed request against your per-minute limit, so the cost of finding today's three leads grows with every lead you ever collected.

Mark after the downstream write succeeds, not before. PATCH is safe to repeat (see below), so a crash between the two means one duplicate downstream at worst — the other order means a lead you never sent and never will.

Endpoints

List form submissions

GET /v1/sites/{siteId}/form-submissions — scope forms:read (not sites:read). Paginated.

ParamNotes
formFilter to one form by exact name. Omit for every form on the site.
readtrue or false. Omit for both. Any other value is a 400 — see below.
limit, cursorStandard pagination.
# the unread queue for one form
curl "https://app.aglyn.com/api/v1/sites/host_demo/form-submissions?form=contact&read=false" \
-H "Authorization: Bearer aglyn_sk_…"
{
"object": "list",
"data": [ /* form submission objects */ ],
"next_cursor": null,
"has_more": false
}

?read= and the unread queue

?read=false is the query a lead sync actually wants: it returns the submissions nobody has processed, which is usually a handful of rows out of a site's entire history.

read is strictly true or false. ?read=1 and ?read=yes are 400 bad_request with code: "validation_failed" naming read, rather than being quietly treated as one of the two — a filter that guesses is worse than one that refuses, because guessing wrong returns a plausible page. An empty ?read= is the absent filter, not an error, so a client serializing an unset field gets the unfiltered list.

Combining ?form= with ?read= gives short pages. Only form narrows the query itself; read is applied to each page after it is read. So a page of 100 can come back with 3 rows, or with none at all, while has_more is still true. Stop on next_cursor, never on a page's length. Used on its own, ?read= narrows the query directly and pages come back full.

Retrieve a form submission

GET /v1/sites/{siteId}/form-submissions/{submissionId} — scope forms:read.

Returns a submission object, or 404 not_found ("No such form submission").

Mark a submission read or unread

PATCH /v1/sites/{siteId}/form-submissions/{submissionId} — scope forms:write.

Body

FieldTypeRequiredNotes
readbooleanyestrue or false. A string like "true" is rejected, not coerced.
curl -X PATCH \
"https://app.aglyn.com/api/v1/sites/host_demo/form-submissions/sub_1" \
-H "Authorization: Bearer aglyn_sk_…" \
-H "Content-Type: application/json" \
-d '{"read":true}'

Returns 200 with the updated submission.

No Idempotency-Key, and none needed. The same body twice lands the same state and returns the same 200, so retrying is free — which is exactly why it's safe to mark after the downstream write.

Any other key in the body is a 400:

{
"error": {
"type": "bad_request",
"message": "Only `read` can be changed on a form submission",
"code": "validation_failed",
"fields": { "fields": "Not writable on a form submission" }
}
}

Delete a form submission

DELETE /v1/sites/{siteId}/form-submissions/{submissionId} — scope forms:write. Accepts an Idempotency-Key.

{ "id": "sub_1", "object": "form_submission", "deleted": true }

This is the endpoint for a purge after export: archive the submissions somewhere you control, then delete them here so the inbox reflects only live work.

Send a key whenever you retry. Without one, deleting a submission that's already gone returns 404, which is correct for a wrong id and misleading for a retry — and a scheduled purge is the operation most likely to have its response lost to a timeout. With a key, the retry replays the original receipt instead.

Keys are scoped to the site. Submission ids are unique within a site, not across your organization, so one key can't carry a success from one site's purge onto another's.

A submission that was never there still returns 404, key or no key, and that 404 releases the key so you can correct the id and retry with the same one.

Deleting is permanent

There is no restore. The console inbox's own delete behaves the same way. If you need the submission later, export it before you call this.

Errors

StatustypeWhen
400bad_requestcode: "validation_failed" — on PATCH, read missing or not a boolean, or the body names a field that isn't writable. On the list, a ?read= that is neither true nor false.
403insufficient_scopeKey lacks forms:read (reads) or forms:write (mark/delete).
404not_found"No such site" — unknown or unowned site. "No such form submission" — unknown submission id.
405method_not_allowedThe Allow header lists what is supported: GET on the collection, GET, PATCH, DELETE on one submission.
409conflictcode: "idempotency_in_progress" — an earlier delete with the same key is still running.

See Conventions → Errors for the shared envelope.

  • Forms & lead capture — building the form, the inbox, spam protection, and per-plan submission allowances.
  • Sites — listing the sites these submissions belong to.
  • Conventions — pagination, ordering, idempotency, errors.