Skip to content
FormFlow

Read Submissions with a scoped Management API token

A Management API token is hashed, shown once and scoped by Ability: five read routes over Forms, Submissions, refusals and one attempt thread, with cursor pagination and no write surface at all.

A dark FormFlow plate titled 'Read only. By design.' with a scoped token chip feeding three read rows, Forms, Submissions and Refusals, and a read path that stops at a dashed write boundary before a closed door plated 'no writes'.

The Management API is the read side of FormFlow: five GET routes, one bearer token, and no way to write anything. It is the surface a Team's own tooling uses when it needs the Submissions FormFlow already holds, rather than a copy in a spreadsheet.

Make the token as narrow as the job

A token belongs to the Team, not to a person. Creating one gives it a name, at least one Ability — submissions:read, refusals:read or forms:read — optionally a list of Forms, and optionally an expiry date. FormFlow stores only a hash, so the credential is shown once, at creation, and never again. Revoking it is immediate; an expired token keeps its row and frees its slot.

Two of those settings do most of the work. A token limited to named Forms reaches those Forms and nothing else. A token with only forms:read can list Form metadata without reading a single Submission. Neither is a preference: a read past a token's reach answers exactly as a read for a record that does not exist, so an absent record and a forbidden one look the same.

The five reads

Every route sits under /api/v1/manage and authenticates with a bearer token in the Authorization header.

  • GET /api/v1/manage/forms — Form metadata. Requires forms:read.

  • GET /api/v1/manage/submissions — a page of Submissions. Requires submissions:read.

  • GET /api/v1/manage/submissions/{uuid} — one Submission, including the spam signals the list omits.

  • GET /api/v1/manage/refused-submissions — the refusals the Form's ledger kept. Requires refusals:read.

  • GET /api/v1/manage/attempts/{attemptId} — one attempt's ordered life across the ledgers that recorded it.

A Submission's payload is keyed by field name and returned as posted. Most fields are strings, a multiselect is a list, a file field is a reference with its stored name, and a grid is an object keyed by row label. The single-Submission route is the only one that returns spam signals.

Page with the cursor

List routes answer with data and meta. limit takes 1 to 200 and defaults to 50, and the order is created_at then id, descending. meta.has_more says whether another page exists; meta.next_cursor is the opaque value to send back as cursor. There is no offset, because the set moves while you read it and an offset would silently skip or repeat rows.

cursor=""
while :; do
  page=$(curl -sS "https://formflow.digital/api/v1/manage/submissions?limit=100&cursor=$cursor" \
    -H "Authorization: Bearer $FORMFLOW_TOKEN")
  echo "$page" | jq -r '.data[] | [.uuid, .form_id, .created_at] | @tsv'
  cursor=$(echo "$page" | jq -r '.meta.next_cursor // empty')
  [ "$(echo "$page" | jq -r '.meta.has_more')" = "true" ] || break
done

Filters are narrow on purpose: form_id, since, until, is_spam, unread and q. q is exact-token search over a blind index, not substring search, and it only answers where search indexing is enabled.

Follow one refusal to its attempt

A Refused submission and a Submission are different records. A refusal is kept so a customer can act on it, it is never delivered, and it never becomes a Counted Submission. Both carry an attempt id, and the attempt route assembles that one attempt's thread — preflight friction, refusal, acceptance, rescue, recovery, receipt and every delivery obligation — with no submitted values and no submitter metadata.

An attempt id is not unique on its own, so the thread is always scoped to one Form. When the id exists on more than one, the answer is 409 and names them, and you repeat the request with form_uuid to choose.

Where the read surface is blunt

Three more boundaries are worth knowing before you build on it. Payloads are decrypted inside FormFlow and returned as stored, so a token that reads Submissions is a token that reads personal data, and its storage deserves the same care as the credential. A row whose payload cannot be decrypted comes back with payload_error of payload_unavailable while the rest of the page still arrives. And the response codes are precise: 401 for an invalid, expired or revoked token, 403 for a missing Ability or an unavailable Plan, 404 when the record is outside the token's reach, 422 for an invalid filter or cursor, and 429 with Retry-After for the rate limit.

The full contract — every parameter, response shape and error — is in /docs/api. The v1.0.0 Changelog entry records the API as shipped: a documented management API beside the one endpoint that receives Submissions.