Go to app

API reference

One endpoint and a JSON body reach every Formstep method: forms, submissions, share links, requests, webhooks, workspaces and translations.


POSThttps://api.formstep.io/api/v1

Single endpoint, many methods

Every method is POST https://api.formstep.io/api/v1 with a JSON body {"method": "...", "params": {...}} and an Authorization: Bearer fs_... header. See the Developers overview for authentication, and API tokens for the token itself.

The same reference is available as an OpenAPI 3.1 description for code generators, API clients, and agents.

Methods

Conventions

  • params may be omitted; it defaults to {}. An unknown method is 404 METHOD_NOT_FOUND.

  • A token is bound to one workspace. Naming another workspace, or a form in one, is 403 FORBIDDEN even when you belong to both.

  • List methods paginate. They return { items, nextCursor, hasMore }; most also return canPaginate, which is false when hasMore is true but no cursor can resume (fuzzy search). Pass nextCursor back as cursor. limit is 1 to 100, default 20, except for requests.list, whose default is 25.

  • The rate limit is 120 calls per minute per token, shared with the MCP server; requests.create and documents.create also share 60 per minute. Failed authentication is limited separately, 30 per 15 minutes per IP, after which bad tokens see RATE_LIMITED instead of UNAUTHORIZED.

  • A request body can be at most 1 MiB. Larger bodies are rejected with VALIDATION_ERROR.

  • The path carries the API version. Breaking changes ship as /api/v2; new methods and new response fields do not.

Forms

forms.list

List forms in a workspace. Supports cursor pagination and optional fuzzy name search.

Parameters
workspaceIdstringRequired

Workspace ID.

folderIdstring | null

Filter by folder. Pass null for root-level forms only. Omit to list all.

querystring

Fuzzy name search. Results capped at limit; not cursor-paginated.

limitnumberDefault 20

Page size (1 to 100).

cursorstring

Pagination cursor from a previous response.

bash
curl -X POST https://api.formstep.io/api/v1 \
  -H "Authorization: Bearer fs_..." \
  -H "Content-Type: application/json" \
  -d '{
    "method": "forms.list",
    "params": { "workspaceId": "ws_abc123" }
  }'
200Success
json
{
  "ok": true,
  "data": {
    "items": [
      {
        "id": "frm_abc123",
        "name": "Contact Form",
        "folderId": null,
        "workspaceId": "ws_abc123",
        "isPublished": true,
        "publishedAt": 1714041851000,
        "unpublishedAt": null,
        "createdAt": 1714041800000,
        "lastEditedAt": 1714042000000,
        "emoji": null
      }
    ],
    "nextCursor": null,
    "hasMore": false,
    "canPaginate": false
  }
}
400Missing workspaceId
401Invalid or missing API token
429Rate limit exceeded

forms.get

Get full details for a single form, including questions, cover, logo, and a preview URL.

Parameters
formIdstringRequired

Form ID.

bash
curl -X POST https://api.formstep.io/api/v1 \
  -H "Authorization: Bearer fs_..." \
  -H "Content-Type: application/json" \
  -d '{
    "method": "forms.get",
    "params": { "formId": "frm_abc123" }
  }'
200Success
json
{
  "ok": true,
  "data": {
    "id": "frm_abc123",
    "name": "Event Feedback",
    "folderId": null,
    "workspaceId": "ws_abc123",
    "isPublished": true,
    "publishedAt": 1714041851000,
    "unpublishedAt": null,
    "createdAt": 1714041800000,
    "lastEditedAt": 1714042000000,
    "emoji": null,
    "questions": [
      {
        "id": "q_1",
        "type": "email-input",
        "inputType": "email",
        "title": "Your email",
        "metadata": null
      },
      {
        "id": "q_2",
        "type": "rating-input",
        "inputType": "rating",
        "title": "Overall experience",
        "metadata": { "kind": "rating", "maxStars": 5 }
      }
    ],
    "hasContent": true,
    "cover": null,
    "logo": null,
    "isDeleted": false,
    "trashedAt": null,
    "previewUrl": "https://formstep.io/preview/abc..."
  }
}
400Missing formId
404Form not found

forms.create

Create a new empty form. Returns the form and a preview URL.

Parameters
namestringRequired

Form name (1 to 255 characters).

workspaceIdstringRequired

Workspace ID.

folderIdstring

Place the form in a folder. Omit to create at workspace root.

200Form created
json
{
  "ok": true,
  "data": {
    "id": "frm_new123",
    "name": "Contact",
    "workspaceId": "ws_abc123",
    "folderId": null,
    "isPublished": false,
    "createdAt": 1714041851000,
    "previewUrl": "https://formstep.io/preview/abc..."
  }
}
400Missing name or workspaceId
401Invalid or missing API token

forms.update

Update form metadata: name, folder, emoji, cover, or logo. Does not update form content (use the MCP editor tools for that).

Parameters
formIdstringRequired

Form ID.

namestring

New form name (1 to 255 characters).

folderIdstring | null

Move form to a folder. Pass null to move to workspace root.

emojistring | null

Form emoji (max 10 characters). Pass null to clear.

coverobject

Cover. {"type": "color", "color": "#ffffff"}, {"type": "image", "url": "https://...", "offsetY": 50} ( offsetY 0 to 100, default 50), or {"type": "none"} to remove. Image URLs must be http(s) or a data:image URI.

logoobject

Logo. {"type": "icon", "name": "HeartIcon"}, {"type": "image", "url": "https://..."}, or {"type": "none"} to remove. Icon names are fixed: QuestionMarkIcon, ListBulletsIcon, ChartBarIcon, ClockCountdownIcon, HeartIcon, LightbulbIcon, CheckCircleIcon, MagnifyingGlassIcon, TrendUpIcon, EnvelopeIcon, PhoneIcon, CalendarIcon, LinkIcon, UsersIcon.

Pass at least one of the five updatable fields.

bash
curl -X POST https://api.formstep.io/api/v1 \
  -H "Authorization: Bearer fs_..." \
  -H "Content-Type: application/json" \
  -d '{
    "method": "forms.update",
    "params": {
      "formId": "frm_abc123",
      "name": "Updated Name",
      "emoji": "📋"
    }
  }'
200Form updated
json
{
  "ok": true,
  "data": {
    "id": "frm_abc123",
    "name": "Updated Name",
    "folderId": null,
    "emoji": "📋"
  }
}

cover and logo come back only when you sent them. A payload that matched the current state on every scalar field adds noChange: true.

forms.publish

Publish a form so it can accept responses, and freeze its field keys into a new snapshot. Idempotent: an already-published form returns success with alreadyPublished: true, and an unpublished form is republished from its last snapshot.

Parameters
formIdstringRequired

Form ID.

A form with content blocks but no questions publishes with a warning. A form with no content at all cannot be published. Publishing does not create a public URL; call shareLinks.create for that.

forms.unpublish

Take a form offline. Respondents can no longer open it. Idempotent: a form that is not published returns alreadyUnpublished: true. Reversible with forms.publish.

Parameters
formIdstringRequired

Form ID.

forms.delete

Move a form to trash. Its active share links are revoked, so their public URLs stop serving.

Parameters
formIdstringRequired

Form ID.

forms.restore

Restore a form from trash.

Parameters
formIdstringRequired

Form ID.

folderIdstring | null

Where to restore it. Omit for its original folder, null for workspace root, or a folder ID.

A form that is not in trash returns alreadyRestored: true.

formSettings.get

Read a form’s behavior settings.

Parameters
formIdstringRequired

Form ID.

Returns { settings, isDefault, availableEmailDomains, defaultFromAddress, payment }. isDefault is true when the form has no saved settings row yet and you are seeing the defaults. availableEmailDomains holds the verified domain ids you can pass as emailDomainId, and payment reports whether Stripe is connected (connecting it is a dashboard step).

formSettings.update

Update a form’s behavior settings. A partial update: only the fields you send are written.

Parameters
formIdstringRequired

Form ID.

Access

language (BCP-47, default "en"), requireAuthentication, showBranding, captchaEnabled, passwordEnabled, password (4 characters or more; a string implies passwordEnabled: true, null clears the gate).

Owner notifications

notifyOnSubmission, notificationEmails (array), selfNotificationSubject, selfNotificationEmailBody, pdfGenerationEnabled, showViewSubmissionButton. Owner emails are not translatable, so write them in the language you want.

Respondent notifications

respondentNotificationEnabled, respondentNotificationTo (the field id of an email question, or null), respondentNotificationSubject, respondentNotificationBody, respondentNotificationPdfEnabled.

Reminders

respondentReminderEnabled, respondentReminderTo, respondentReminderSubject, respondentReminderBody, respondentReminderRequiredFieldIds, and reminderSteps (idle offsets like ["1d","3d","1w"], at most 5, sorted and deduplicated on save, [] for none). The schedule applies to abandoned public-link responses and to requests alike. Pro.

After submit

redirectUrl (http(s); null or "" clears), redirectQueryParams ( [{ paramName, fieldId }]), allowAnotherResponse (mutually exclusive with a redirect), maxSubmissionsPerRespondent (0 = unlimited, max 1000), editAfterSubmit, maxEdits (max 3; 0 means unlimited on Pro and Business, 3 on Free).

Retention

draftRetentionDays and submissionRetentionDays (0 to 36500, null reverts to the default). Submission retention is Business, and setting it clears any fixed deletion date configured in the builder.

emailDomainIdstring | null

A verified email-domain id from formSettings.get, for a custom From address. null resets to the default sender.

Subjects and bodies are plain text and accept {{variable}} placeholders; newlines become paragraphs. Customizing a respondent subject or body makes it translatable, so its keys appear in translations.listEntries right away.

Submissions

submissions.list

List a form’s submissions, newest page first, with cursor pagination.

Parameters
formIdstringRequired

Form ID.

includeDraftsbooleanDefault false

Include responses that were started but never submitted. Drafts are a Pro feature: on Free only completed submissions are listed.

translationLanguagestring

Attach stored AI translations of the answers under items[].translation.display, keyed like display. items[].answers and items[].display always stay the original.

limitnumberDefault 20

Page size (1 to 100).

cursorstring

Pagination cursor from a previous response.

200Success
json
{
  "ok": true,
  "data": {
    "formId": "frm_abc123",
    "formName": "Event Feedback",
    "items": [
      {
        "id": "sub_xyz789",
        "submittedAt": "2026-05-18 18:02:28",
        "isCompleted": true,
        "createdAt": "2026-05-18 18:02:19",
        "answers": {
          "email": "user@example.com",
          "plan": "pro",
          "contacts": [{ "name": "Ada" }, { "name": "Grace" }]
        },
        "display": {
          "email": "user@example.com",
          "plan": "Pro",
          "contacts": "Ada, Grace"
        }
      }
    ],
    "nextCursor": null,
    "hasMore": false,
    "canPaginate": false
  }
}

Same answers as webhooks and callbacks

Each item carries answers keyed by field key and display with the same keys as readable text. A webhook payload, a request callback and requests.get carry the same shape. A choice answer is its option key, a repeating group an array of instances. Call fields.list for each key’s title and option labels. This method returns no totals.

submissions.pdf

Get a link to one submission’s PDF. Built for the Zapier connector: it returns a result only when the form has an active Zapier integration configured to include the PDF, and the PDF was retained.

Parameters
formIdstringRequired

Form ID.

submissionIdstringRequired

Submission ID. Must belong to that form and be completed.

200Success
json
{
  "ok": true,
  "data": {
    "url": "https://api.formstep.io/api/storage/...",
    "filename": "formstep-submission-sub_xyz789.pdf",
    "contentType": "application/pdf",
    "byteLength": 148213
  }
}
404No retained PDF for a Zapier integration on this submission

submissions.sample

Build a sample submission payload for a form, without any real data. It is the exact shape a share-link submission delivery carries, so connectors use it for field discovery; a request-born submission reaches a subscription as request.completed instead, sampled by

requests.sample. data.form.snapshotId is the form’s current published version, the same id live events carry, or null while the form is unpublished.

Parameters
formIdstringRequired

Form ID.

200Sample generated
json
{
  "ok": true,
  "data": {
    "id": "evt_example000000000000",
    "type": "submission.completed",
    "createdAt": "2026-05-18T19:00:00.000Z",
    "apiVersion": "2026-09-24",
    "test": true,
    "data": {
      "form": { "id": "frm_abc123", "name": "Event Feedback", "snapshotId": "js7abc123" },
      "submission": {
        "id": "sub_example000000000000",
        "respondentEmail": "respondent@example.com",
        "submittedAt": "2026-05-18T19:00:00.000Z",
        "updatedAt": null,
        "editCount": 0,
        "pdfUrl": null,
        "language": "en"
      },
      "answers": { "your_email": "john@example.com" },
      "display": { "your_email": "john@example.com" }
    }
  }
}

The webhooks reference explains each field of the payload.

Fields

fields.list

List every field of a form’s current published version, with the key to address each one by. Call this before requests.create instead of hard-coding keys. See List the fields for what each flag means.

Parameters
formIdstringRequired

Form ID. A form that has never been published has no field keys yet and answers published: false with no items.

bash
curl -X POST https://api.formstep.io/api/v1 \
  -H "Authorization: Bearer fs_..." \
  -H "Content-Type: application/json" \
  -d '{
    "method": "fields.list",
    "params": { "formId": "j57..." }
  }'
200Success
json
{
  "ok": true,
  "data": {
    "published": true,
    "items": [
      { "key": "company_name", "type": "text", "title": "Company", "required": true, "prefillable": true },
      {
        "key": "company_size", "type": "select", "title": "Company size", "required": false, "prefillable": true,
        "options": [
          { "key": "1_50", "label": "1–50" },
          { "key": "51_200", "label": "51–200" }
        ]
      },
      {
        "key": "satisfaction", "type": "matrix", "title": "How did we do?", "required": false, "prefillable": true,
        "rows": [{ "key": "delivery_speed", "label": "Delivery speed" }],
        "columns": [{ "key": "very_good", "label": "Very good" }, { "key": "poor", "label": "Poor" }]
      },
      { "key": "case_id", "type": "hidden", "title": "Case", "required": false, "prefillable": false, "context": true },
      { "key": "total", "type": "number", "title": "Total", "required": false, "prefillable": false, "calculated": true }
    ],
    "hasMore": false
  }
}

A repeating group is type: "group" with repeating: true and a members array. A Documents block is type: "documents" and carries documents: [{ name }], the authored files every respondent already sees.

400Missing formId
404Form not found

Requests

A request assigns one published form to one person and calls you back when it ends. How the parameters fit together is explained in Create requests from code.

requests.create

Create a request. Spends one unit of the workspace’s monthly allowance, whether or not the recipient answers.

Parameters
formIdstring

The published form to assign. Send this or form, not both.

formobject

Build the form in this call instead: { name, blocks, language?, logo?, cover?, emoji?, theme?, settings?, translations? }. See Create a form and a request in one call.

workspaceIdstring

Where an inline form goes. Defaults to the token’s workspace; any other is refused.

recipientobject

{ email?, name? }. An email is required when delivery is "email"; otherwise it only identifies the person in the Requests page and on their answers.

prefillobject

Initial answers by field key. The recipient sees them and can change them.

readonlystring[]

Prefilled keys the recipient cannot change. Every key here must also appear in prefill, and a locked required field must be prefilled with a non-empty value.

contextobject

Values for the form’s hidden fields, by field key. Trusted, unchangeable, and echoed back in the callback. An unknown key is rejected with UNKNOWN_FIELD_KEY.

metadataobject

Your own bookkeeping. Never reaches the form; comes back in callbacks and reads.

languagestring

One of the form’s published languages. Defaults to the form’s own default.

deliverystringDefault none

"email" to have Formstep send the invitation (needs a recipient email, and Pro or Business or one of a Free account’s 10 free invitations, which go only to the account’s own address), or "none" to deliver the link yourself.

remindersstring[]

Override the form’s reminder schedule for this request: up to five offsets such as ["2d", "12h", "90m"], each at least an hour. An empty array switches reminders off. See The reminders parameter.

expiresAtnumber

Epoch milliseconds. Defaults to 30 days out; 365 days is the maximum.

callbackUrlstring

Where Formstep POSTs the callback when the request ends. HTTPS only, and the host must resolve to a public address.

externalIdstring

Your own id for this request. Filterable in requests.list.

idempotencyKeystring

Repeating it with the same body returns the original request with deduplicated: true. A different body is rejected. Keys live 30 days.

domainIdstring

Mint the link on one of your custom domains.

documentsobject[]

[{ documentId, field?, name? }]: files handed to this one recipient, uploaded first with documents.create.

testbooleanDefault false

A dry run: nothing is emailed, the callback carries "test": true, and the submission counts nowhere. The link closes within 24 hours, and on Free a workspace may create 10 test requests a day.

bash
curl -X POST https://api.formstep.io/api/v1 \
  -H "Authorization: Bearer fs_..." \
  -H "Content-Type: application/json" \
  -d '{
    "method": "requests.create",
    "params": {
      "formId": "j57...",
      "recipient": { "email": "ada@acme.com", "name": "Ada" },
      "prefill": { "company_name": "Acme" },
      "readonly": ["company_name"],
      "context": { "case_id": "CASE-9" },
      "delivery": "email",
      "externalId": "run-42",
      "callbackUrl": "https://automation.example/webhook/resume-abc",
      "idempotencyKey": "run-42"
    }
  }'
200Success
json
{
  "ok": true,
  "data": {
    "id": "kd7...",
    "status": "pending",
    "url": "https://form.formstep.io/r/rq_...",
    "deliveryStatus": "queued",
    "expiresAt": 1794787200000,
    "createdAt": 1789379200000,
    "externalId": "run-42",
    "deduplicated": false
  }
}

deliveryStatus is one of not_requested, queued, sent, failed and bounced; see Delivery status.

With form, the response also carries form: { id, name, reused, fields: [{ key, type, options? }], aiValidations?, similarForm? }: the form that was built or reused, the keys its answers come back under, and each choice question’s option keys for a later prefill. aiValidations lists the keys its AI Validations report under in data.aiValidations, when it has any. similarForm appears when the call made a new form although an open one has the same block types in the same order: usually a changed name or title. Send the same definition every time to reuse one form.

400Unknown field key, wrong value shape, or a locked key that is not prefilled
400Form not published (FORM_NOT_PUBLISHED) or closed (FORM_CLOSED), or callbackUrl not allowed (CALLBACK_URL_NOT_ALLOWED)
400An inline form refused: a block fails its schema, Stripe or Cal.com is not connected, or publishing refused it (INLINE_FORM_…)
402Monthly allowance spent (MONTHLY_ALLOWANCE_REACHED), Free invitations spent (FREE_INVITATIONS_USED) or sent to another address (FREE_INVITATIONS_OWN_ADDRESS_ONLY), or reminders below Pro
404Form not found
409Idempotency key reused with a different body (IDEMPOTENCY_CONFLICT)
429More than 60 requests.create calls in a minute on this token, or a Free workspace's 11th test request in a day (TEST_REQUEST_LIMIT_REACHED)

requests.get

Get one request in full: status, outcome, what was prefilled, its timeline, and, once completed, answers and display keyed by field key, the same two maps the callback carries.

Parameters
requestIdstringRequired

Request ID.

200Success
json
{
  "ok": true,
  "data": {
    "id": "kd7...",
    "formId": "j57...",
    "status": "completed",
    "outcome": "approve",
    "isTest": false,
    "recipient": { "email": "ada@acme.com", "name": "Ada" },
    "language": "en",
    "externalId": "run-42",
    "metadata": null,
    "context": { "case_id": "CASE-9" },
    "prefill": { "company_name": "Acme" },
    "readonlyKeys": ["company_name"],
    "delivery": "email",
    "deliveryStatus": "sent",
    "hasCallback": true,
    "callbackFailedAt": null,
    "submissionId": "kp2...",
    "url": "https://form.formstep.io/r/rq_...",
    "answers": { "company_name": "Acme", "decision": "approve" },
    "display": { "company_name": "Acme", "decision": "Approve" },
    "timeline": [
      { "id": "kd7...:created", "type": "created", "at": 1789379200000 },
      { "id": "kd7...:completed", "type": "completed", "at": 1789465600000 }
    ],
    "expiresAt": 1794787200000,
    "completedAt": 1789465600000
  }
}

outcome vs status

status says whether the request finished; outcome says what the recipient decided: approve, decline, or changes. It is null on anything but a completed request whose recipient picked one of the three, including a form with no decision question. The callback URL itself is never returned; hasCallback only says whether one is set.

The example above is trimmed. A full response also carries workspaceId, formSnapshotId, createdVia, documents, reminderStep, remindersSent, reminderDueAt, and the rest of the timestamps (updatedAt, openedAt, startedAt, lastActivityAt, expiredAt, canceledAt, canceledBy, cancelReason).

callbackFailedAt is set while this request's callback has run out of attempts, and cleared once one gets through or you replay it. While it is set, the callback was never delivered, so this read is your only copy. Once the form's retention window passes after a request ends, the request is deleted and requests.get answers REQUEST_NOT_FOUND. An expired or canceled request is deleted without any setting, 30 days after it ended by default, so read what you need when its callback arrives.

timeline is derived, oldest first. Each entry has an id, an at, and a type: created, invitation, reminder, opened, started, completed, expired, canceled, callback. Delivery entries add deliveryStatus and attemptCount, and callbacks add eventType. Delivery rows are kept 30 days, so older timelines thin back to the timestamps.

404Request not found (REQUEST_NOT_FOUND)

requests.list

List requests in a workspace or on one form, newest first. Test requests are left out unless you ask for them. Each row names its form in formName, and formOrigin is "inline" when a request built that form.

Parameters
workspaceIdstring

Scope to a workspace. Give this or formId.

formIdstring

Scope to one form.

statusstring

pending, completed, expired, or canceled.

outcomestring

approve, decline, or changes. Implies completed requests only.

externalIdstring

Your own id, to find the request a run created.

includeTestbooleanDefault false

Include requests created with test: true.

limitnumberDefault 25

Page size (1 to 100).

cursorstring

Pagination cursor from a previous response.

200Success
json
{
  "ok": true,
  "data": {
    "items": [
      {
        "id": "kd7...",
        "formId": "j57...",
        "formName": "Mortgage documents",
        "formOrigin": "inline",
        "status": "pending",
        "outcome": null,
        "recipient": { "email": "ada@acme.com", "name": "Ada" },
        "externalId": "run-42",
        "deliveryStatus": "sent",
        "expiresAt": 1794787200000,
        "createdAt": 1789379200000
      }
    ],
    "nextCursor": null,
    "hasMore": false
  }
}

List items carry the same fields as requests.get minus url, answers, display, and timeline, and each one carries isTest. Give workspaceId or formId; sending neither is 400 VALIDATION_ERROR with reason SCOPE_REQUIRED. outcome overrides status, since only a completed request has a verdict.

requests.cancel

Withdraw a pending request. The link stops working, the recipient sees a withdrawn notice, and a request.canceled callback fires.

Parameters
requestIdstringRequired

Request ID.

reasonstring

Your note for why, kept on the request and sent in the callback.

200The canceled request
409Already completed, expired, or canceled (REQUEST_NOT_PENDING)

requests.delete

Delete a request and everything it gathered: the recipient’s submission and answers, uploaded files, the documents you attached, and its timeline and delivery history. Works in any status. A pending request’s link stops working, and no callback fires. Use it when a recipient asks you to erase their data; to withdraw a pending request and hear about it in your callback, use requests.cancel. Deleting takes an API token; an OAuth app, such as an AI agent connected over MCP, gets 403 FORBIDDEN with reason API_KEY_REQUIRED, whatever its scopes.

Parameters
requestIdstringRequired

Request ID.

When the request built its form (an inline form) and no other request uses that form, the form is deleted too and formDeleted is true. Deleting cannot be undone.

200{ id, deleted: true, formDeleted }
403Called with an OAuth app's token (API_KEY_REQUIRED)
404No such request in a workspace you can reach (REQUEST_NOT_FOUND)

requests.remind

Email the recipient now, without touching the reminder schedule. Needs a recipient email and a Pro or Business plan.

Parameters
requestIdstringRequired

Request ID. Must still be pending, and not a test request.

Manual reminders must be at least 10 minutes apart, and a request gets at most 8 reminders in total, manual and scheduled together. The automatic schedule is untouched: reminderStep and reminderDueAt stay where they were.

200The request, with remindersSent incremented
400No recipient email on the request (RECIPIENT_EMAIL_REQUIRED)
402Request reminders require Pro or Business (UPGRADE_REQUIRED)
409Not pending (REQUEST_NOT_PENDING), too soon (REMINDER_TOO_SOON, with details.retryAfterMs), cap reached (REMINDER_CAP_REACHED), or a test request (TEST_REQUEST)

requests.replayCallback

Re-send the callback a request fired when it ended, with the same payload and the same event id, so a receiver that already handled it can deduplicate. Use it after fixing a broken endpoint.

Parameters
requestIdstringRequired

Request ID. Must be completed, expired, or canceled.

200Success
json
{
  "ok": true,
  "data": { "dispatchId": "kf9...", "eventId": "evt_kf9..." }
}
409Still pending, so there is no terminal callback to replay (REQUEST_NOT_TERMINAL)
409The request was created without a callbackUrl (NO_CALLBACK_TO_REPLAY)

requests.sample

Build a sample request event for a form, without any real request. It is the exact envelope a request_* subscription created with webhooks.create receives, so connectors use it for field discovery. A completed sample carries the same example answers submissions.sample shows; an expired or canceled sample carries the request block alone.

Parameters
formIdstringRequired

Form ID.

eventTypestringRequired

Which ending to sample, in the webhooks.create spelling. The envelope’s type is the dotted form.

One ofrequest_completedrequest_expiredrequest_canceled
200Sample generated
json
{
  "ok": true,
  "data": {
    "id": "evt_example000000000000",
    "type": "request.completed",
    "createdAt": "2026-05-18T19:00:00.000Z",
    "apiVersion": "2026-09-24",
    "test": true,
    "data": {
      "request": {
        "id": "req_example000000000000",
        "externalId": "order-1234",
        "status": "completed",
        "language": "en",
        "recipient": { "email": "recipient@example.com", "name": "Sample Recipient" },
        "metadata": { "source": "sample" },
        "context": {},
        "createdAt": "2026-05-18T18:00:00.000Z",
        "completedAt": "2026-05-18T19:00:00.000Z"
      },
      "form": { "id": "frm_abc123", "name": "Event Feedback", "snapshotId": "js7abc123" },
      "submission": {
        "id": "sub_example000000000000",
        "respondentEmail": "respondent@example.com",
        "submittedAt": "2026-05-18T19:00:00.000Z",
        "updatedAt": null,
        "editCount": 0,
        "pdfUrl": null,
        "language": "en"
      },
      "answers": { "your_email": "john@example.com" },
      "display": { "your_email": "john@example.com" }
    }
  }
}

The request block and the outcome are documented under Request callbacks; the submission half on the webhooks reference. Sample ids are the fixed placeholders shown above and test is true, so a receiver can tell a sample from a live event.

documents.create

Reserve an upload for a file you will hand to one recipient through the form’s Documents block. You upload the bytes outside this API: you get a presigned PUT URL, upload the file to it, and requests.create verifies the object before the request exists.

Parameters
formIdstringRequired

The form whose Documents block will show the file. Scopes the upload to that workspace.

namestringRequired

Display name the recipient sees (1 to 200 characters). Overridable per request.

contentTypestringRequired

application/pdf or an image type: image/png, image/jpeg, image/webp, image/gif, image/avif, image/bmp, image/tiff. Office documents are not accepted.

sizenumberRequired

Exact byte length. Maximum 25 MB (26,214,400).

sha256string

Hex digest of the bytes. Verified after upload when given.

200Upload reserved
json
{
  "ok": true,
  "data": {
    "id": "kn7...",
    "name": "Lease contract draft",
    "contentType": "application/pdf",
    "size": 412000,
    "uploadUrl": "https://...",
    "expiresAt": 1792198800000
  }
}

PUT the raw bytes to uploadUrl within the hour, with Content-Type set to the type you declared, then reference the id from requests.create:

json
{
  "method": "requests.create",
  "params": {
    "formId": "j57...",
    "documents": [
      { "documentId": "kn7...", "name": "Your lease contract" },
      { "documentId": "kn8...", "field": "attachments" }
    ]
  }
}
  • field is the Documents block’s field key. Optional when the form has exactly one block; required with two or more.

  • The block’s authored documents stay; yours appear below them, for this one recipient.
  • Caps: 25 MB per document, 100 MB of documents per request, 20 documents shown per block including the authored ones.
  • One upload can be referenced by any number of requests. An upload nobody references ages out. Bytes count against the workspace owner’s storage until the last request referencing them is stripped by retention.

Every failure here is 400 VALIDATION_ERROR. The reasons, from this method and from requests.create, are listed under Documents in the error reference.

Connections

connections.list

The Stripe and Cal.com accounts a workspace has connected. Read it before building an inline form with a payment or a booking: a person connects each service once, on the page connectUrl names, and an automation cannot.

Parameters
workspaceIdstringRequired

Workspace ID.

200Success
json
{
  "ok": true,
  "data": {
    "connectUrl": "https://app.formstep.io/k57.../integrations",
    "items": [
      { "provider": "stripe", "id": "pc7...", "name": "Acme Ltd", "status": "connected", "ready": true, "default": true },
      { "provider": "cal.com", "id": "oc7...", "name": "ada@acme.com", "status": "active", "ready": true }
    ],
    "nextCursor": null,
    "hasMore": false,
    "canPaginate": false
  }
}

ready says whether forms can use the account now. default marks the Stripe account payments charge through unless a payment block names another.

scheduler.listEventTypes

The Cal.com event types a booking block can name, and whether each can be booked from a form.

Parameters
workspaceIdstringRequired

Workspace ID.

connectionstring

The Cal.com connection id from connections.list. Required when the workspace has several.

Returns { connection: { id, name }, items: [{ id, title, durationMinutes?, bookable, reasons? }] }. Only a bookable event type publishes; reasons says why the others are not.

400No Cal.com connection, an expired one, or several and none named. The message says which, and where to connect.

paymentConnections.setDefault

Make a Stripe account the workspace default, the one payment blocks charge through unless they name another.

Only the workspace owner can change the default. A token of any other member gets 403 FORBIDDEN.

Parameters
workspaceIdstringRequired

Workspace ID.

connectionstringRequired

A Stripe connection id of that workspace, from connections.list.

200{ connection }, the new default
404The connection belongs to another workspace, or does not exist

Webhooks

webhooks.list

List webhook subscriptions for a form.

Parameters
formIdstringRequired

Form ID.

200Success
json
{
  "ok": true,
  "data": {
    "items": [
      {
        "subscriptionId": "int_abc123",
        "formId": "frm_abc123",
        "provider": "zapier",
        "targetUrl": "https://hooks.zapier.com/...",
        "eventType": "submission_created",
        "status": "active",
        "createdAt": 1714041851000
      }
    ],
    "hasMore": false
  }
}

webhooks.create

Subscribe a URL to form events: new or abandoned submissions, or requests on the form ending. The URL must use HTTPS.

Parameters
formIdstringRequired

Form ID.

targetUrlstringRequired

HTTPS URL to receive webhook payloads.

providerstringRequired

Which tool the subscription belongs to. It is a label for your own bookkeeping. There is no marketplace app to install, and every provider behaves the same.

One ofzapiermaken8n
eventTypestringDefault submission_created

Event type to subscribe to. The three submission_* types deliver the submission payload: submission_created a first submission, submission_updated a respondent’s edit, and submission_abandoned an idle draft. The three request_* types deliver the matching request event whenever a request on the form ends that way, signed with this subscription’s secret; test requests reach no subscription.

One ofsubmission_createdsubmission_updatedsubmission_abandonedrequest_completedrequest_expiredrequest_canceled
idleWindowstring

Required when eventType is submission_abandoned; rejected for every other type.

One of12h1d3d1w
signingSecretstring

Optional HMAC signing secret, 32 to 255 characters. When supplied, deliveries include X-Formstep-Signature. The secret is stored but never returned by the API.

bash
curl -X POST https://api.formstep.io/api/v1 \
  -H "Authorization: Bearer fs_..." \
  -H "Content-Type: application/json" \
  -d '{
    "method": "webhooks.create",
    "params": {
      "formId": "frm_abc123",
      "targetUrl": "https://hooks.zapier.com/hooks/catch/...",
      "provider": "zapier",
      "eventType": "submission_created",
      "signingSecret": "whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
    }
  }'
200Webhook created
json
{
  "ok": true,
  "data": {
    "subscriptionId": "int_new123",
    "formId": "frm_abc123",
    "provider": "zapier",
    "targetUrl": "https://hooks.zapier.com/hooks/catch/...",
    "eventType": "submission_created"
  }
}

Abandoned-submission subscriptions return the selected idleWindow from both webhooks.create and webhooks.list. Every other subscription omits it.

A request subscription hears the same events a callback does, but as its own delivery: its own event id, its own signature, and its own five-attempt retry budget, after which the subscription pauses. A request created with a callbackUrl on a form with a request_completed subscription therefore fires twice, once to each receiver. requests.replayCallback re-sends the callback only. Use requests.sample to see the payload before any request has ended.

webhooks.delete

Remove a webhook subscription.

Parameters
subscriptionIdstringRequired

Subscription ID from webhooks.list or webhooks.create.

200Webhook deleted
json
{
  "ok": true,
  "data": {
    "subscriptionId": "int_abc123",
    "deleted": true
  }
}

Analytics

analytics.get

Get aggregated analytics metrics for a form. Supports date range, device, traffic source, and country filters.

Analytics are a Pro feature, and the rule follows the workspace owner’s plan, the same way the dashboard’s Analytics tab does. If the owner is not on Pro, this returns UPGRADE_REQUIRED, including for history recorded while they were. A free member of a Pro owner’s workspace gets the data.

Parameters
formIdstringRequired

Form ID.

fromnumber

Start of date range as Unix timestamp in milliseconds. Must be less than or equal to to when both are set.

tonumber

End of date range as Unix timestamp in milliseconds. Omit both for all time; period then comes back as { "from": null, "to": null }.

devicestringDefault all

Filter by device type.

One ofalldesktopmobiletablet
trafficSourcestring

Filter by traffic source (e.g. "Direct", "Google").

countrystring

Filter by 2-letter country code (e.g. "US", "DE").

includeEventsbooleanDefault false

Also return the sanitized analytics events behind the metrics, for your own analysis. No visitor ids.

Rates are numbers from 0 to 100, counts are integers, and totalEvents is the raw event row count before deduplication into unique visitors.

200Success
json
{
  "ok": true,
  "data": {
    "formId": "frm_abc123",
    "period": { "from": null, "to": null },
    "totalEvents": 17,
    "metrics": {
      "views": 7,
      "uniqueVisitors": 7,
      "engaged": 6,
      "submissions": 4,
      "engagementRate": 86,
      "completionRate": 57,
      "bounceRate": 14
    },
    "breakdown": {
      "byBrowser": { "Chrome": 7 },
      "byCountry": { "US": 4, "DE": 3 },
      "byDevice": { "desktop": 6, "mobile": 1 },
      "bySource": { "Direct": 5, "Google": 2 }
    }
  }
}

Workspaces

workspaces.list

List the workspaces your token can reach. No parameters.

An API token is bound to one workspace, so this returns exactly that one, even when your account belongs to several.

Parameters
200Success
json
{
  "ok": true,
  "data": {
    "items": [
      {
        "id": "ws_abc123",
        "name": "Acme Inc",
        "createdAt": 1714041800000,
        "role": "owner"
      }
    ],
    "nextCursor": null,
    "hasMore": false,
    "canPaginate": false
  }
}

workspaces.createInvite

Create an invite link for a workspace. Invite methods take an API token from the workspace owner; an OAuth app gets 403 FORBIDDEN with reason API_KEY_REQUIRED, whatever its scopes.

Parameters
workspaceIdstringRequired

Workspace ID.

expiresAtnumber

Expiration as a future Unix timestamp in milliseconds.

maxUsesnumber

Maximum number of times the invite can be used.

200Invite created
json
{
  "ok": true,
  "data": {
    "id": "inv_abc123",
    "workspaceId": "ws_abc123",
    "code": "aBcDeFgH",
    "expiresAt": null,
    "maxUses": null,
    "uses": 0,
    "createdAt": 1714041851000
  }
}

workspaces.getInvite

Get one workspace invite. Returns the same shape as workspaces.createInvite.

Parameters
inviteIdstringRequired

Invite ID.

404Invite not found

workspaces.updateInvite

Update an existing workspace invite. Provide at least one of expiresAt or maxUses, or the call is rejected. Returns the updated invite.

Parameters
inviteIdstringRequired

Invite ID.

expiresAtnumber

New expiration timestamp in milliseconds.

maxUsesnumber

New max uses limit.

workspaces.revokeInvite

Permanently revoke a workspace invite.

Parameters
inviteIdstringRequired

Invite ID.

200Invite revoked
json
{
  "ok": true,
  "data": {
    "inviteId": "inv_abc123",
    "revoked": true
  }
}

Folders

folders.list

List folders in a workspace.

Parameters
workspaceIdstringRequired

Workspace ID.

limitnumberDefault 20

Page size (1 to 100).

cursorstring

Pagination cursor.

200Success
json
{
  "ok": true,
  "data": {
    "items": [
      {
        "id": "fld_abc123",
        "name": "Customer Feedback",
        "workspaceId": "ws_abc123",
        "parentId": null,
        "createdAt": 1714041800000
      }
    ],
    "nextCursor": null,
    "hasMore": false,
    "canPaginate": false
  }
}

folders.create

Create a folder in a workspace. Idempotent: returns the existing folder if a folder with the same name already exists.

Parameters
workspaceIdstringRequired

Workspace ID.

namestringRequired

Folder name (1 to 255 characters).

parentIdstring | null

Parent folder ID for nesting. Omit for root level.

200Folder created
json
{
  "ok": true,
  "data": {
    "id": "fld_new123",
    "name": "Customer Feedback",
    "workspaceId": "ws_abc123",
    "parentId": null,
    "createdAt": 1714041851000,
    "alreadyExisted": false
  }
}

folders.update

Rename a folder or move it to a different parent.

Parameters
folderIdstringRequired

Folder ID.

namestring

New folder name (1 to 255 characters).

parentIdstring | null

New parent folder. Pass null to move to root.

folders.delete

Delete a folder and all its subfolders. The forms inside move to the Trash, where they can be restored for 30 days.

Parameters
folderIdstringRequired

Folder ID.

200Folder deleted
json
{
  "ok": true,
  "data": {
    "folderId": "fld_abc123",
    "deletedFolderIds": ["fld_abc123"],
    "trashedFormIds": ["frm_in_folder"],
    "deletedFormIds": ["frm_in_folder"],
    "message": "Folder and 0 subfolder(s) deleted; 1 form(s) moved to the Trash."
  }
}

Translations

translations.listLanguages

List all languages configured on a form.

Parameters
formIdstringRequired

Form ID.

200Success
json
{
  "ok": true,
  "data": {
    "formId": "frm_abc123",
    "items": [
      {
        "language": "es",
        "completion": { "total": 15, "current": 12, "outdated": 2, "missing": 1, "suggested": 0 },
        "lastUpdatedAt": 1714041851000
      }
    ],
    "nextCursor": null,
    "hasMore": false,
    "canPaginate": false
  }
}

translations.addLanguage

Register a language on a form. Every other translation method fails with 404 NOT_FOUND until you do.

Parameters
formIdstringRequired

Form ID.

languagestringRequired

BCP-47 language tag (e.g. "es", "pt-BR").

200Language added
json
{
  "ok": true,
  "data": {
    "formId": "frm_abc123",
    "language": "es",
    "rowId": "tl_new123"
  }
}

translations.removeLanguage

Remove a language and all its translations from a form.

Parameters
formIdstringRequired

Form ID.

languagestringRequired

BCP-47 language tag.

translations.listEntries

List every source key for one language on a form, with its current state. This is how you discover the key values translations.setEntry takes.

Parameters
formIdstringRequired

Form ID.

languagestringRequired

BCP-47 language tag. Must already be on the form.

200Success
json
{
  "ok": true,
  "data": {
    "formId": "frm_abc123",
    "language": "es",
    "completion": { "total": 15, "current": 12, "outdated": 2, "missing": 1, "suggested": 0 },
    "items": [
      {
        "key": "block_q_1.title",
        "status": "current",
        "value": "[{\"text\":\"Tu correo electrónico\"}]",
        "sourceFragment": "[{\"text\":\"Your email\"}]",
        "updatedAt": 1714041851000,
        "block": { "id": "q_1", "type": "email-input", "label": "Your email" }
      }
    ],
    "hasMore": false
  }
}

status is missing (nothing stored), outdated (the source changed since), current, or suggested (an AI suggestion staged but not accepted). Keys cover form content (block_<id>.*) and, once an author has customized them, the respondent confirmation and reminder emails (email.confirmation.*, email.reminder.*).

translations.setEntry

Set a single translation entry. The language must have been added via translations.addLanguage first.

Parameters
formIdstringRequired

Form ID.

languagestringRequired

BCP-47 language tag.

keystringRequired

A key from translations.listEntries. Do not construct one by hand.

valuestringRequired

The translated fragment, JSON-stringified. Its mark structure must match the source fragment.

bash
curl -X POST https://api.formstep.io/api/v1 \
  -H "Authorization: Bearer fs_..." \
  -H "Content-Type: application/json" \
  -d '{
    "method": "translations.setEntry",
    "params": {
      "formId": "frm_abc123",
      "language": "es",
      "key": "block_q_1.title",
      "value": "[{\"text\":\"Tu correo electrónico\"}]"
    }
  }'

Returns { formId, language, key }.

translations.deleteEntry

Delete a single translation entry, reverting that key to the form’s default language. Idempotent. When the last entry for a language goes, the language drops off the form’s published languages.

Parameters
formIdstringRequired

Form ID.

languagestringRequired

BCP-47 language tag.

keystringRequired

Translation key to delete.

Account

me.get

Get information about the authenticated user.

Parameters
200Success
json
{
  "ok": true,
  "data": {
    "id": "usr_abc123",
    "email": "you@example.com",
    "name": "Jane Doe"
  }
}

Meta

methods.list

List every method name this deployment serves, sorted. The authoritative answer when this page and the server disagree.

Parameters
200Success
json
{
  "ok": true,
  "data": {
    "methods": [
      "methods.list",
      "analytics.get",
      "folders.create",
      "folders.delete",
      "folders.list",
      "folders.update",
      "formSettings.get",
      "formSettings.update",
      "forms.create",
      "forms.delete",
      "forms.get",
      "forms.list",
      "..."
    ]
  }
}

Error reference

Every error response has the same shape. code says what kind of failure it is, and details.reason says what caused it. The set of codes is closed: a new failure adds a reason, never a code. Branch on code, and read reason to know what to fix.

error response
json
{
  "ok": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "No field with key \"company\" on this form.",
    "details": { "reason": "UNKNOWN_FIELD_KEY", "field": "prefill.company", "validKeys": ["company_name", "contacts"] }
  }
}

details is there whenever the server can name the cause. Besides reason, it may carry field (the parameter at fault, dotted for nesting), validKeys, validValues (the values a parameter accepts), expectedType, feature (on UPGRADE_REQUIRED), and retryAfterMs (on a throttled call).

Codes

CodeStatusWhat it means
VALIDATION_ERROR400Invalid or missing parameters, or a body over 1 MiB.
UNAUTHORIZED401Missing or invalid API token.
UPGRADE_REQUIRED402The plan does not include the feature, or a limit of the plan is spent. The reason says which.
FORBIDDEN403The token cannot reach the resource, such as a form in another workspace.
NOT_FOUND404The resource does not exist.
METHOD_NOT_FOUND404Unknown method name. methods.list lists the ones there are.
CONFLICT409The resource is not in a state that allows the call, such as a request that is no longer pending, or an idempotency key reused with a different body.
RATE_LIMITED429Too many calls: over a token's rate limit, over an email limit, or too many failed authentications from this IP. Retry after retryAfterMs.
INTERNAL_ERROR500Unexpected server error. Try again later.

Reasons when you create a request

These come from requests.create. Each is a VALIDATION_ERROR unless the row names another code. An inline form adds its own INLINE_FORM_* reasons, listed under When the call is refused.

ReasonWhat to do
FORM_TRASHEDRestore the form from the trash. A form in the trash takes no new requests.
FORM_NOT_PUBLISHEDPublish the form. A request pins a published version, so there has to be one.
FORM_CLOSEDReopen the form in Settings, Submissions tab, Accepting submissions. A closed form takes no new requests.
UNKNOWN_FIELD_KEYNo field has that key, or it belongs in another parameter: every context key must be a hidden field on the published form, and free-form keys go in metadata. validKeys lists the accepted keys. A key that used to work was changed or deleted in the editor.
CONTEXT_KEY_NOT_HIDDEN_FIELDA visible question or a calculated field was sent in context. Visible questions go in prefill; calculated fields take nothing.
INVALID_PREFILL_VALUEWrong shape for the question type; expectedType says what was wanted. Send the option key, not the label, and { "row_key": "column_key" } for a matrix. expectedType "not_prefillable" means the field takes no value from you: a file, signature, payment, appointment or calculated field.
READONLY_REQUIRES_PREFILLA locked key has no value. Every key in readonly must also be in prefill.
READONLY_REQUIRED_EMPTYA required field is locked with an empty value, so the recipient could never submit. Send a value or stop locking it.
LANGUAGE_NOT_PUBLISHEDThat language is not published on the current version. validKeys lists the ones that are.
INVALID_REMINDER_SCHEDULEAn offset could not be read or is under one hour, an offset appears twice, or there are more than five. Use whole days, hours or minutes: 2d, 12h, 90m.
EXPIRY_OUT_OF_RANGEexpiresAt is in the past or more than 365 days out.
CALLBACK_URL_NOT_ALLOWEDThe URL is not HTTPS, carries credentials, or resolves to a private address. Localhost does not work, so use a tunnel.
DOMAIN_NOT_ALLOWEDThat custom domain is not active, or belongs to another workspace.
RECIPIENT_EMAIL_REQUIREDEmail delivery and a reminder schedule need recipient.email.
RECIPIENT_NAME_TOO_LONGrecipient.name is over 100 characters. The invitation greets the recipient by it, so send their name only.
IDEMPOTENCY_CONFLICTCONFLICT. That key was used with a different body. Use a new key, or send the original body again unchanged.
UPGRADE_REQUIREDUPGRADE_REQUIRED. The plan does not include the feature: invitations and reminders need Pro or Business.
FREE_INVITATIONS_USEDUPGRADE_REQUIRED. This Free account has spent its free invitations. Create the request with "delivery": "none" and send the link yourself, or upgrade.
FREE_INVITATIONS_OWN_ADDRESS_ONLYUPGRADE_REQUIRED. On Free, invitations go only to your own address, which validValues names. Send the link yourself, or upgrade.
MONTHLY_ALLOWANCE_REACHEDUPGRADE_REQUIRED. The workspace has spent this month's allowance. Requests already created can still be answered; new ones wait for the 1st of the month (UTC) or an upgrade.
TEST_REQUEST_LIMIT_REACHEDRATE_LIMITED. A Free workspace has created its 10 test requests for today.
RECIPIENT_EMAIL_LIMITEDRATE_LIMITED. This address has had the most emails one form may send it in a day. Retry after retryAfterMs.
INVITATION_RATE_LIMITEDRATE_LIMITED. The account has sent its hourly share of invitations and reminders. Retry after retryAfterMs.

Documents

From documents.create and from the documents parameter of requests.create. Each is a VALIDATION_ERROR except DOCUMENT_NOT_FOUND, which is NOT_FOUND.

ReasonWhat to do
DOCUMENT_TYPE_NOT_ALLOWEDSend a PDF or an image. Office documents are not accepted.
DOCUMENT_TOO_LARGEOne document may be 25 MB.
INVALID_DOCUMENT_NAMEThe name must be 1 to 200 characters.
INVALID_DOCUMENT_SHA256sha256 must be 64 hex characters.
DOCUMENT_NOT_FOUNDNo upload with that documentId in this workspace, or it aged out unused.
DOCUMENT_NOT_UPLOADEDThe upload was reserved, but the bytes never arrived. PUT the file to its uploadUrl first.
DOCUMENT_INVALIDThe uploaded bytes disagree with the size, type or sha256 you declared, or the file is not a PDF or an image.
INVALID_DOCUMENT_TARGETThe form has no Documents block, or it has several and field does not name one. validKeys lists the block keys.
DOCUMENTS_TOO_MANYThe block would show more than 20 documents, counting the ones the form already has.
DOCUMENTS_TOO_LARGEOne request may carry 100 MB of documents in total.

Reasons on an existing request

From requests.get, requests.list, requests.cancel, requests.delete, requests.remind and requests.replayCallback. Each is a CONFLICT unless the row names another code.

ReasonWhat to do
REQUEST_NOT_FOUNDNOT_FOUND. No request with that id in a workspace this token can reach, or it was deleted.
SCOPE_REQUIREDVALIDATION_ERROR. requests.list needs a workspaceId or a formId.
API_KEY_REQUIREDFORBIDDEN. requests.delete takes an API token. An OAuth app, such as an agent connected over MCP, cannot delete.
REQUEST_NOT_PENDINGThe request is already completed, expired or canceled. A finished request cannot be reminded or canceled.
REQUEST_EXPIREDThe expiry has passed, but the status has not caught up yet. A reminder would point at a link that no longer opens.
REMINDER_TOO_SOONA manual reminder went out less than 10 minutes ago. Wait retryAfterMs.
REMINDER_CAP_REACHEDThe request has had all 8 reminders it will ever get, manual and scheduled together.
TEST_REQUESTNothing is ever emailed for a test request. Open its link yourself instead.
REQUEST_NOT_TERMINALThe request is still pending, so there is no callback to replay yet.
NO_CALLBACK_TO_REPLAYThe request was created without a callbackUrl.

Next steps