# formbase Docs — Requests

# Requests overview

Ask one specific person to fill in one form, then resume your workflow when they are done.

## Requests overview

A request is one assignment of a published form to one person. Your automation creates it, formbase hands back a secret link, the recipient fills in a prefilled form, and formbase calls your workflow back so the run continues.

<h2 id="what-a-request-is">What a request is</h2>

<p>
  A form is a reusable design. A <strong>request</strong> is one assignment of that design to one named person — with their data already
  filled in, their case number attached, and a callback waiting for the moment they finish.
</p>

<p>
  You do not build a different form for this. Any published form can be sent as a request. Nothing about the form changes; what changes is
  who opens it and what your automation knows when they are done.
</p>

<h2 id="channels">Two channels, one form</h2>

<p>
  In the Share sheet, a published form has two tabs — <strong>Public link</strong> and <strong>Requests</strong>. These are the two{' '}
  <em>channels</em> a form reaches people through, and a form can use both at once. The public link goes on your website; requests go to the
  three suppliers you are chasing this week. Same form, same submission inbox.
</p>

> ℹ️ **Three ways to create a request**
> <p>
>     Open a published form, press <strong>Share</strong>, and pick the <strong>Requests</strong> channel. The <strong>Manual</strong> tab
>     creates one request by hand — fill in the recipient, prefill what you know, and press <strong>Send invitation</strong> or{' '}
>     <strong>Create link</strong>. The <strong>curl</strong> and <strong>MCP</strong> tabs hand you a ready-made snippet for the same thing
>     from Make, Zapier, Claude, or a direct API call.
>   </p>
>   <p>
>     A request made by hand behaves exactly like one an automation created: same lifecycle, same reminders, same signed callback.{' '}
>     <strong>Try it yourself</strong> creates a test request addressed to you and hands you its link — nothing is emailed — so you can walk
>     the whole flow through before you wire anything up.
>   </p>

> ℹ️ **Step by step in Zapier and n8n**
> <p>
>     The <a href="/guides/overview">Zapier guides</a> walk through it click by click:{' '}
>     <a href="/guides/zapier/send-a-request">send a request from a Zap</a>, <a href="/guides/zapier/request-outcome">act on its outcome</a>,
>     and <a href="/guides/zapier/manage-requests">look it up, remind or cancel it</a>. The same three jobs have{' '}
>     <a href="/guides/overview#n8n">guides for self-hosted n8n</a>.
>   </p>

<h2 id="lifecycle">The lifecycle</h2>

<ol>
  <li>
    Your automation calls <code>requests.create</code> with a form id, the recipient, and any values you already know.
  </li>
  <li>
    formbase returns a <strong>request link</strong> — a secret URL like <code>https://form.formbase.so/r/rq_…</code>. It opens as many
    times as the recipient needs, from any device.
  </li>
  <li>
    The recipient gets the link, either through the <strong>request invitation</strong> email formbase sends, or through your own channel if
    you would rather send it yourself.
  </li>
  <li>
    They open a normal formbase form: your branding, your theme, your language — with their values already in place and the fields you
    locked shown read-only.
  </li>
  <li>
    They submit. The answers land in the form's inbox like any other submission, and formbase POSTs a signed <strong>callback</strong> to
    the URL your automation gave it, with the answers keyed by field key.
  </li>
</ol>

<p>
  If they never finish, reminders chase them on a schedule, and the request eventually expires. Either way your automation is told — the
  callback fires on expiry and cancellation too, so a workflow run is never left hanging.
</p>

<h2 id="statuses">Statuses</h2>

<p>
  Completed, expired, and canceled are terminal: the link keeps working but shows an <strong>outcome page</strong> — a receipt, or a plain
  "this request has expired" notice — instead of the form.
</p>

<p>
  While a request is pending, formbase also records when it was first opened, when the first answer was saved, and the last activity. That
  is what the Requests page means by "not opened", "opened", and "in progress" — activity, not extra statuses.
</p>

<h2 id="what-you-need">What you need</h2>

<ul>
  <li>
    A <strong>published</strong> form. Field keys freeze at first publish, and a request pins the version that was live when it was created.
  </li>
  <li>
    An <a href="/developers/api-tokens">API token</a>, or the <a href="/developers/mcp-server#requests">MCP server</a> connected to your
    agent. Requests made by hand in the Share sheet need neither.
  </li>
  <li>
    A <strong>Pro or Business plan</strong> if you want formbase to email the invitation or send reminders. Creating requests, prefilling
    them, locking fields, and receiving callbacks work on every plan — you deliver the link yourself. A Free account can have formbase email
    its first <a href="/subscription-billing/limits-quotas#free-invitations">10 invitations</a> to try it.
  </li>
  <li>
    Room in this month's <a href="/requests/creating-requests#allowance">allowance</a>. Requests and share-link submissions spend the same
    pool, and a request costs its unit the moment it is created.
  </li>
</ul>

<h2 id="next">Next</h2>

<div class="not-prose grid gap-3 sm:grid-cols-2">
  - [Create a request](/requests/creating-requests) — Discover field keys, prefill, lock, and attach context.
  - [Field keys](/requests/field-keys) — The stable names automations address your fields by.
  - [Invitations & reminders](/requests/invitations-and-reminders) — Email delivery, the shared schedule, and expiry.
  - [Callbacks & signing](/requests/callbacks) — Resume your workflow and verify that the call is ours.
  - [The Requests page](/requests/managing-requests) — See what you are waiting on and act on it.
</div>


# Creating a request

Discover a form's field keys, then create a request with prefilled values, locked fields, and context.

## Creating a request

Two API calls: ask the form what it can be told, then assign it to one person with the values you already know.

<h2 id="start-in-the-share-sheet">Start in the Share sheet</h2>

<p>
  Open your published form, click <strong>Share</strong>, and pick the <strong>Requests</strong> tab. The card there gives you everything
  you need to make the first call:
</p>

<ul>
  <li>
    The <strong>form id</strong>, with a copy button.
  </li>
  <li>
    A <strong>curl</strong> snippet and an <strong>MCP</strong> prompt, both built from your form's real field keys — so the example is
    already addressed to the fields this form actually has.
  </li>
  <li>
    A <strong>Manual</strong> tab that creates one request by hand, and <strong>Try it yourself</strong>, which turns what you filled in
    there into a request in <a href="#test-mode">test mode</a> and hands you its link.
  </li>
  <li>
    A link through to the <a href="/requests/managing-requests">Requests page</a>, filtered to this form.
  </li>
</ul>

> ⚠️ **Publish first**
> <p>
>     An unpublished form cannot be requested, and the snippets stay disabled until you publish. Field keys are frozen at first publish — that
>     is what lets your automation keep addressing <code>company_name</code> a year later. See <a href="/requests/field-keys">Field keys</a>.
>   </p>

<h2 id="discover-fields">Step 1 — Discover the fields</h2>

<p>
  <code>fields.list</code> returns every field of the form's current published version, with the key to address it by, the value shape it
  takes, and which bucket it belongs in.
</p>

```
curl -X POST https://api.formbase.so/api/v1 \
  -H "Authorization: Bearer $FORMBASE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"method":"fields.list","params":{"formId":"j57..."}}'
```

```
{
  "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": "case_id", "type": "hidden", "title": "Case", "required": false, "prefillable": false, "context": true },
      { "key": "total", "type": "number", "title": "Total", "required": false, "prefillable": false, "calculated": true },
      { "key": "signature", "type": "signature", "title": "Sign here", "required": true, "prefillable": false }
    ],
    "hasMore": false
  }
}
```

<ul>
  <li>
    <code>context: true</code> marks a hidden field. Its value goes in <code>context</code>, never in <code>prefill</code>; a hidden field's
    key in <code>prefill</code> is rejected with <code>UNKNOWN_FIELD_KEY</code>.
  </li>
  <li>
    <code>calculated: true</code> marks a calculated field. The form works out its value, so nothing can send one; you read it back under
    its key in <code>answers</code>.
  </li>
  <li>
    <code>prefillable: false</code> marks a field nobody can supply a value for: file upload, signature, payment, appointment booking, and
    Documents blocks. The recipient fills in the questions themselves. Hidden fields and calculated fields also show{' '}
    <code>prefillable: false</code>: hidden fields take <code>context</code>, and calculated fields take nothing.
  </li>
  <li>
    <code>options</code> lists the choices for a choice question. Send the option's <strong>key</strong>, not its label; the label is there
    so you can match the choice you know to its key. A matrix lists its <code>rows</code> and <code>columns</code> the same way.
  </li>
  <li>
    Repeating groups come back as one entry with <code>type: "group"</code>, <code>repeating: true</code>, and a list of{' '}
    <code>members</code>.
  </li>
</ul>

<h2 id="create">Step 2 — Create the request</h2>

```
curl -X POST https://api.formbase.so/api/v1 \
  -H "Authorization: Bearer $FORMBASE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"method":"requests.create","params":{
        "formId":"j57...",
        "recipient":{"email":"ada@acme.com","name":"Ada"},
        "context":{"case_id":"CASE-9"},
        "prefill":{"company_name":"Acme","company_size":"51_200"},
        "readonly":["company_name"],
        "delivery":"email",
        "externalId":"run-42",
        "callbackUrl":"https://automation.example/webhook/resume-abc",
        "idempotencyKey":"run-42"}}'
```

```
{
  "ok": true,
  "data": {
    "id": "kd7...",
    "status": "pending",
    "url": "https://form.formbase.so/r/rq_...",
    "deliveryStatus": "queued",
    "expiresAt": 1794787200000,
    "createdAt": 1789379200000,
    "externalId": "run-42",
    "deduplicated": false
  }
}
```

<p>
  <code>deliveryStatus</code> is <code>"queued"</code> when formbase emails the invitation and <code>"not_requested"</code> when you deliver
  the link yourself.
</p>

<h2 id="three-buckets">Prefill, locked fields, and context</h2>

<p>Three different things can be attached to a request, and mixing them up is the most common first mistake.</p>

<h3 id="prefill">Prefill</h3>

<p>
  Initial answers for the visible questions, so the recipient reviews and corrects instead of typing from scratch. Everything you already
  know about them belongs here — the company name from your CRM, the amount from the invoice, last year's answers.
</p>

<h3 id="locked-fields">Locked fields</h3>

<p>
  List a prefilled key in <code>readonly</code> and the recipient sees the value but cannot change it. Use it for the facts they are
  confirming rather than supplying — the contract number, the agreed price. Locking is per request: the form itself is untouched, and the
  same field is freely editable on the next request.
</p>

<p>
  Every locked key must also be prefilled, and a locked <em>required</em> field must be prefilled with something non-empty — otherwise the
  recipient would face a form they could never submit, and formbase rejects the call instead of creating that trap.
</p>

<h3 id="context">Context</h3>

<p>
  Trusted values for the form's <a href="/building-forms/hidden-fields">hidden fields</a> — a case number, a workflow run id, an amount.
  Context feeds variables, conditional logic, calculated fields, and email copy, comes back unchanged in the callback, and the recipient
  cannot alter it. That last part is the difference from seeding a hidden field through a URL on a public link, where anyone can edit the
  query string; request links ignore URL query parameters entirely. Context values must be a string, number, or boolean.
</p>

<p>
  Context is not free-form: every key must be a hidden field on the form's published version, and any other key is rejected with{' '}
  <code>UNKNOWN_FIELD_KEY</code>. Bookkeeping that has no hidden field, like an execution id, belongs in <a href="#metadata">metadata</a>.
</p>

<p>
  Hidden fields are not shown on the form, but a context value is not secret from the recipient. They see it wherever the form or the
  invitation shows it: a <a href="/building-forms/answer-piping">mention</a> in the form content or email copy, or a visible question that
  uses that hidden field as its <a href="/building-forms/field-configuration#default-values">default value</a>. In that last case the
  recipient sees the context value pre-filled in that question and can edit the answer. The context value itself stays unchanged. A{' '}
  <code>prefill</code> for that question's own key wins over the default.
</p>

<h3 id="metadata">Metadata</h3>

<p>
  Your own bookkeeping — an execution id, a CRM record id. It never reaches the form at all, so it cannot be piped into copy or read by
  logic; it just rides along and comes back in every callback and status read.
</p>

<h2 id="value-shapes">Value shapes</h2>

<p>
  Send values in the shape the <code>type</code> from <code>fields.list</code> asks for. A wrong shape comes back as a validation error
  naming the key, the expected type, and — for choice questions — the values that would have been accepted.
</p>

<h2 id="documents">Documents</h2>

<p>
  A <a href="/building-forms/documents-block">Documents block</a> hands files to the respondent. Its authored files are the same for
  everyone and always stay; a request adds files for its one recipient below them — the customer's own lease contract, an ID copy to check.
  Bytes never travel through the API call itself: upload first, then reference.
</p>

```
"documents": [
  { "documentId": "kn7...", "name": "Your lease contract" },
  { "documentId": "kn8..." }
]
```

<p>
  <code>name</code> overrides the display name stored on the upload. When the form has more than one Documents block, name the target with{' '}
  <code>field</code>, the block's field key (<code>fields.list</code> lists it, together with the authored documents every respondent
  already gets). The files land below those authored documents — a request adds files, it never replaces one. One upload can be referenced
  by as many requests as you like — a price list uploaded once serves five hundred requests.
</p>

<p>
  Limits: PDF and images only, 25 MB per document, 100 MB per request (<code>DOCUMENTS_TOO_LARGE</code>), and at most 20 documents per block
  counting the authored ones (<code>DOCUMENTS_TOO_MANY</code>). The files count against your workspace storage and are freed once the
  requests referencing them fall out of the form's retention window. The submission records the list the recipient saw under the block's
  field key, so the callback tells you exactly which files this person was given.
</p>

<h2 id="options">The rest of the options</h2>

<h3 id="test-mode">Test mode</h3>

<p>
  Pass <code>test: true</code> to exercise the whole shape before a real run. A test request is real in every way that matters for wiring:
  the link opens and can be completed, the <a href="/requests/callbacks">callback</a> fires as usual, and <code>requests.get</code> returns
  the answers. What it never does is reach anyone or anything you would have to clean up afterwards:
</p>

<ul>
  <li>
    No invitation and no reminder is sent, whatever <code>delivery</code> says. <strong>Send reminder</strong> is refused on it, and it
    spends none of your monthly allowance.
  </li>
  <li>
    The callback carries <code>"test": true</code>, so your workflow can branch or ignore it.
  </li>
  <li>
    The submission is stored but does not count: not against your monthly quota (a test completes even when the quota is used up), and it
    never appears in the form's submission counts, the submissions tab, exports, or your integrations. Nobody is notified.
  </li>
  <li>
    The request is hidden from the <a href="/requests/managing-requests">Requests page</a> behind <strong>Show test requests</strong>, left
    out of the request funnel in Analytics, and left out of <code>requests.list</code> unless you pass <code>includeTest: true</code>.
  </li>
  <li>
    The link closes within 24 hours, even when <code>expiresAt</code> asks for longer; the <code>expiresAt</code> in the response says when.
    On Free, a workspace may create 10 test requests a day. The next one fails with <code>RATE_LIMITED</code> and reason{' '}
    <code>TEST_REQUEST_LIMIT_REACHED</code>, and <code>retryAfterMs</code> says when you can try again. Pro and Business have no daily cap.
  </li>
</ul>

<p>
  <strong>Try it yourself</strong> on the Share sheet is this mode with a single click: it takes the Manual tab's draft — prefill, locks,
  context, callback, expiry — addresses the request to your own account, sends no email, and hands you the link to open yourself.
</p>

<h3 id="allowance">What a request costs</h3>

<p>
  Every plan has one <strong>monthly allowance</strong> shared by both channels: a share-link submission spends one unit, and so does every
  request you create — whether the recipient answers, ignores it, or you cancel it. The submission a request collects is already paid for
  and counts nowhere. Free includes 1,000 units a month, Pro and Business 50,000; the count resets on the 1st of each month, UTC. At the
  cap, <code>requests.create</code> fails with <code>UPGRADE_REQUIRED</code> and reason <code>MONTHLY_ALLOWANCE_REACHED</code>; requests you
  already created stay answerable.
</p>

<p>
  On Free, a request created with <code>"delivery": "email"</code> also spends one of the account's{' '}
  <a href="/subscription-billing/limits-quotas#free-invitations">10 free invitations</a>. They never reset; once they are gone, email
  delivery fails with <code>UPGRADE_REQUIRED</code> and reason <code>FREE_INVITATIONS_USED</code>.
</p>

<h3 id="idempotency">Idempotency</h3>

<p>
  Pass the same <code>idempotencyKey</code> with the same body and you get the original request back, with <code>deduplicated: true</code>{' '}
  and the original link — no second request, no second email. Reuse the key with a <em>different</em> body and formbase refuses with{' '}
  <code>IDEMPOTENCY_CONFLICT</code> rather than guessing which one you meant. Keys are scoped to the workspace and honoured for 30 days;
  after that the same key starts a new request.
</p>

<p>
  In a workflow tool, the execution id is the natural key: a run that is retried after a network blip picks up the request it already
  created.
</p>

<h3 id="rate-limit">Rate limit</h3>

<p>
  <code>requests.create</code> and <code>documents.create</code> share a budget of <strong>60 calls a minute</strong>, counted per API token
  (or per user, for a call made without one). A backlog you are draining should pace itself; a burst over the budget is refused and can be
  retried.
</p>

<h3 id="custom-domains">Custom domains</h3>

<p>
  If the form is already published on one of your <a href="/branding-domains/custom-domains">custom domains</a>, request links are minted
  there automatically — <code>https://forms.yourcompany.com/r/rq_…</code>. Name <code>domainId</code> explicitly when the form is published
  on more than one. The domain must belong to the same workspace as the form.
</p>

<h2 id="what-the-recipient-sees">What the recipient sees</h2>

<p>
  Exactly the form you authored — same theme, same logo, same language — with their values in place, locked fields read-only, and no captcha
  to solve. When they submit, they get your thank-you page. If they come back to the link afterwards, they get the outcome page instead of a
  blank form.
</p>

<p>
  There is no message from your automation on the page. Anything the recipient needs to be told belongs in the form itself, where you can
  personalise it by <a href="/building-forms/answer-piping">mentioning</a> a context value or a prefilled field.
</p>

<p>
  An AI agent runs the same two steps as <code>fields_list</code> and <code>request_create</code>, with the same options — documents and{' '}
  <code>domainId</code> included. See <a href="/developers/mcp-server#requests">Requests on the MCP server</a>.
</p>

<div class="not-prose grid gap-3 sm:grid-cols-2">
  - [Field keys](/requests/field-keys) — Where those keys come from and how to keep them stable.
  - [Callbacks & signing](/requests/callbacks) — What arrives when the recipient is done.
  - [Troubleshooting](/requests/troubleshooting) — Every rejection reason and what to do about it.
  - [API reference](/developers/rest-api) — Full parameter list for every request method.
</div>


# Field keys

The stable names automations use to address your fields — where they come from, and how to keep them stable.

## Field keys

A field key is the developer-facing name of one field in one form — company_name, contacts. It is how an automation prefills a field, locks it, or reads the answer back, and it survives retitling and duplicating.

<h2 id="why">Why they exist</h2>

<p>
  Without field keys, an automation has to address your questions by internal ids that mean nothing to anyone — and a webhook payload
  arrives full of them. With field keys, both sides read the same way:
</p>

```
{ "company_name": "Acme", "employees": 120, "contacts": [{ "name": "Ada" }] }
```

<p>
  Field keys are used in two places: <code>prefill</code>, <code>context</code>, and <code>readonly</code> when creating a request; and the{' '}
  <code>answers</code> and <code>display</code> maps in every callback and every webhook payload.
</p>

<h2 id="where">Where to find them</h2>

<p>
  Every key the form publishes lives in one place, the <strong>Keys</strong> table: each field, and under a choice question each option,
  under a matrix each row and column. It ends with a preview of the <code>answers</code> object your callback will carry, with your keys in
  it.
</p>

<p>
  Each option's hint row — the grey line under the option you are editing — ends with its key as a chip. Click the chip to edit the key in
  place, press Enter or Escape when done. A matrix shows the chip for the row or column whose label you are editing. A chip turns amber when
  the form is published and the key you typed would move the one automations already use.
</p>

<p>
  The same table appears read-only where you wire an integration: under the webhook's field mapping, and under the request snippets in the
  Share sheet's Requests card. You can also read every key at once with <code>fields.list</code>.
</p>

<h2 id="derivation">How a key is derived</h2>

<p>
  You do not have to set anything. Until you edit it, a key is derived from the question's title: accents are folded and letters such as ø,
  æ and ß become o, ae and ss, everything is lowercased, each run of anything that is not a letter or digit becomes a single underscore,
  leading and trailing underscores are dropped, and the result is cut to 64 characters.
</p>

<p>
  A title written only in a non-Latin script, such as Arabic, Hebrew, Cyrillic, Greek, Chinese or Japanese, has nothing to fold, so its key
  is <code>field_</code> plus its position, and an option's is <code>option_</code> plus its position. These keys are as stable as any other
  once published, but they say nothing about the question. When an automation reads the answers, set a readable key yourself in the Keys
  table.
</p>

<p>
  If two fields would end up with the same key, formbase breaks the tie in document order by appending <code>_2</code>, <code>_3</code>, and
  so on. A key you set yourself always wins its claim, and the derived one yields.
</p>

<h2 id="setting">Setting your own key</h2>

<p>
  Type a name in the Field key input to override the derived one. Clearing the input goes back to the derived key. Allowed characters are
  letters, digits, <code>_</code>, <code>.</code> and <code>-</code>, up to 64 characters — anything else is refused with{' '}
  <em>"Use only letters, numbers and _ . - (max 64 characters)."</em> Spaces are turned into underscores as you type, so "contact name"
  becomes <code>contact_name</code>.
</p>

<p>
  Keys are case-sensitive and must be unique within one form. Reusing one already taken by another question, repeating group, hidden field,
  or calculated field is refused with <em>"Another field already uses this key."</em>
</p>

<h2 id="freeze">Keys freeze at first publish</h2>

> ⚠️ **Renaming a published key breaks automations**
> <p>
>     On a published form, the Field key input warns you that the form is published and automations using the current key will break. Nothing
>     stops you, but every workflow that prefills or reads that key stops matching the moment you publish the change. Update the automation in
>     the same sitting. Publish <a href="#removed-keys">warns you again</a> before the change goes live.
>   </p>

<p>
  The first time you publish, every field's key is written into that published version. Every later publish carries the same keys forward,
  which means:
</p>

<ul>
  <li>
    <strong>Retitling never moves a key.</strong> Rename "Company name" to "Legal entity name" and the key stays <code>company_name</code>.
    Your automations keep working; only the words on the page change.
  </li>
  <li>
    <strong>Changing a question's type never moves a key.</strong> Turning a text question into a dropdown keeps its key — though the value
    shape your automation must send changes with it.
  </li>
  <li>
    <strong>Moving a question never moves its key.</strong> Position only matters for the positional fallback on a field that has no usable
    title.
  </li>
  <li>
    <strong>Duplicating a block gives the copy a new key.</strong> A key you set yourself is copied and re-keyed to the next free{' '}
    <code>_2</code>; derived keys are made unique at publish.
  </li>
  <li>
    <strong>Forms built before field keys existed</strong> get theirs at their next publish.
  </li>
</ul>

<p>Publishing is also where key collisions are caught, and both of them stop the publish rather than silently renaming a field:</p>

<ul>
  <li>
    Two fields claiming one key — <em>Field key "…" is used by more than one field.</em>
  </li>
  <li>
    A key you typed onto one field that another field already published —{' '}
    <em>
      Field key "…" is already published on "…". Giving it to "…" would rename that field's key to "…_2" and break automations using "…".
    </em>{' '}
    Free the key on one of the two and publish again.
  </li>
</ul>

<p>
  Both show up beside the Publish button with every other pre-flight finding — see{' '}
  <a href="/building-forms/publish-checks">Publish checks</a>.
</p>

<h2 id="removed-keys">When a published key is about to disappear</h2>

<p>
  A key belongs to the field it was published on, not to its title. So there are two ways to lose one without meaning to: type a different
  key on a published field, or <strong>delete a question and add a new one in its place</strong>. The new question is a new field — it gets
  a fresh key derived from its own title, and the old key is gone.
</p>

<p>
  Nothing fails on formbase's side when that happens. The webhook still fires and the callback still arrives, only without that answer, and{' '}
  <code>requests.create</code> starts refusing the old key with <code>UNKNOWN_FIELD_KEY</code>. So publish checks for it first. When a key
  the live version publishes would not exist in the next one, the publish dialog and the issue indicator beside the Publish button show a
  warning:
</p>

```
Field key "company_name" will no longer exist after this publish. Integrations and requests that use it will stop receiving that answer. A new field "Company" publishes as "company". Set its field key to "company_name" to keep them working.
```

<ul>
  <li>
    <strong>To keep your automations working</strong>, open the <strong>Keys</strong> table, find the field the warning names, and type the
    old key. The warning clears and the key carries on as if nothing happened.
  </li>
  <li>
    <strong>If you removed the field on purpose</strong>, publish anyway — it is a warning, not an error — and update the automations that
    read the key.
  </li>
  <li>
    The warning names a field only when the choice is obvious: the field whose key you retyped, or the single new field standing where a
    single deleted one was. Otherwise it names just the key.
  </li>
  <li>
    Deleting a repeating group warns for the group's key and for the key of every field inside it. Publishing through the MCP server's{' '}
    <code>form_publish</code> returns the same messages in <code>warnings</code>.
  </li>
  <li>
    Option, row and column keys get the same treatment: delete an option or retype its key on a published form and publish warns{' '}
    <em>Option key "pro" of "Plan" will no longer exist after this publish</em>, naming the key the option publishes as now when it still
    exists. The publish dialog lists every key change, at both levels, under <strong>Keys changing in this publish</strong>.
  </li>
</ul>

<h2 id="groups">Repeating groups and hidden fields</h2>

<p>
  A <a href="/building-forms/repeating-groups">repeating group</a> has its own key, and so does each field inside it. Automations address
  the group as a whole and nest the members:
</p>

```
{ "contacts": [{ "name": "Ada", "email": "ada@acme.com" }, { "name": "Grace", "email": "grace@acme.com" }] }
```

<p>
  A field inside a group is only reachable through its group — there is no top-level <code>name</code> here, only{' '}
  <code>contacts[0].name</code>. Rename the group's key and the whole array moves; rename one member's key and only that name inside each
  object changes.
</p>

<p>
  A <a href="/building-forms/hidden-fields">hidden field</a>'s parameter name is its field key. That is the key you put in{' '}
  <code>context</code> when creating a request, and the same name you would use in a URL parameter on a public link.
</p>

<p>
  A <a href="/building-forms/calculated-fields">calculated field</a>'s name is its field key, and it shares the form's one set of keys with
  everything else. It is read-only: the form works out its value, so you can never send one — <code>fields.list</code> lists it with{' '}
  <code>calculated: true</code> and <code>requests.create</code> refuses it in <code>prefill</code> and <code>context</code>. You do read it
  back: it arrives in <code>answers</code> under its name (<code>answers.total</code>). Renaming a published calculated field renames its
  key, with the same publish warning as any other field.
</p>

<h2 id="option-keys">Option keys: the choices inside a question</h2>

<p>
  The parts of a question that an answer names get keys too. Every option of a radio, select, checkbox, picture choice or ranking question,
  and every row and column of a matrix, has an <strong>option key</strong>: derived from its label the same way a field key is derived from
  a title, editable, and frozen at publish. So an answer reads as a name on both sides:
</p>

```
{ "plan": "pro", "interests": ["billing", "api"], "satisfaction": { "delivery_speed": "very_good" } }
```

<p>
  A workflow branches on <code>answers.plan == "pro"</code> whatever language the respondent answered in, and a request prefills a choice
  with <code>{'{ "plan": "pro" }'}</code>. The keys are listed by <code>fields.list</code> under each field's <code>options</code>, or a
  matrix's <code>rows</code> and <code>columns</code>, and edited in the <a href="#where">Keys table</a> or on the option's chip. Each
  question's options are their own namespace, so two questions may both have a <code>yes</code>, and a matrix row and column may share a
  key. Two options of one question that would derive the same key are told apart with <code>_2</code>, like fields.
</p>

<p>
  The <a href="/requests/decisions-and-approvals">decision question</a> is an ordinary radio whose three options carry the keys{' '}
  <code>approve</code>, <code>decline</code> and <code>changes</code>; that is what makes a request's <code>outcome</code> a closed set.
</p>

<div class="not-prose grid gap-3 sm:grid-cols-2">
  - [Create a request](/requests/creating-requests) — Put those keys to work.
  - [Webhook reference](/developers/webhooks-reference) — How field keys shape every event payload.
</div>


# Decisions & approvals

Ask for a verdict — approve, decline, or changes — and let your workflow branch on it without reading labels.

## Decisions & approvals

Plenty of requests exist to get one thing: a yes, a no, or a 'not like that'. The decision question captures that verdict in a shape your automation can branch on directly, without guessing which field held the answer.

<h2 id="why">Why a special question</h2>

<p>
  Every choice already has an <a href="/requests/field-keys#option-keys">option key</a>, so a workflow can branch on any radio. What a
  workflow cannot do is know, across every form that has an approval, which key means "yes": one author's radio says <code>sign_off</code>,
  another's <code>approved</code>.
</p>

<p>
  The <strong>decision question</strong> fixes the names. It is a single-choice question with the field key <code>decision</code>, whose
  three options carry the option keys <code>approve</code>, <code>decline</code> and <code>changes</code>. Its labels stay yours to reword
  and translate; the keys underneath never move. When the recipient picks one, the request ends with an <strong>outcome</strong>. Any radio
  keyed <code>decision</code> whose options carry those three keys counts — the preset just sets them for you.
</p>

<h2 id="insert">Add one to your form</h2>

/</strong> in the editor and pick <strong>Decision</strong> — "Approve, decline, or request changes".',
    },
    {
      title: 'Reword the labels',
      description:
        'You get a question titled "Do you approve?" with the choices Approve, Decline, and Request changes. Rewrite any of them to your own wording — "Sign off", "Reject", "Send back for edits". Translate them like any other content.',
    },
    {
      title: 'Ask why, if you need to',
      description:
        'A verdict alone rarely explains itself. Add a long-text question after it, and show it only for decline and changes with <a href="/building-forms/conditional-logic/">conditional logic</a>.',
    },
    {
      title: 'Publish',
      description:
        'Requests created from this published version end with an outcome. Like every field key, decision only exists once the form is published.',
    },
  ]}
/>

> ⚠️ **Only the preset produces an outcome**
> <p>
>     The outcome is read from the question whose field key is <code>decision</code>, and only when the chosen option's key is one of the
>     three. A radio you build by hand with options labelled Approve, Decline and Changes derives exactly those keys, so it works without the
>     preset; a radio with other labels works once you type the three keys in the <a href="/requests/field-keys#option-keys">Keys table</a>.
>   </p>
>   <ul>
>     <li>
>       Delete one of the three options, or retype its key, and publishing warns you: "The decision question is missing an approve, decline or
>       changes option, so requests can complete without an outcome." All three must be there — a decision that can only approve cannot
>       decline.
>     </li>
>     <li>
>       A decision question inside a <a href="/building-forms/repeating-groups">repeating group</a> is an ordinary question and is ignored for
>       the outcome.
>     </li>
>   </ul>

<h2 id="values">The three values</h2>

<p>
  These three values are a public contract, like a field key: an automation, an agent, and a spreadsheet formula all read them.{' '}
  <strong>Add a fourth option and picking it is an ordinary answer, not a verdict</strong> — that request completes with no outcome, rather
  than with a value your receiver has never heard of.
</p>

<h2 id="where">Where the outcome shows up</h2>

```
{
  "id": "evt_...",
  "type": "request.completed",
  "test": false,
  "data": {
    "request": {
      "id": "kd7...",
      "externalId": "run-42",
      "status": "completed",
      "outcome": "changes",
      "context": { "case_id": "CASE-9" }
    },
    "answers": {
      "decision": "changes",
      "reason": "Please split the line items per site."
    }
  }
}
```

<p>
  Branch on <code>data.request.outcome</code>. <strong>status</strong> says whether the request finished; <strong>outcome</strong> says what
  the recipient decided. An expired or canceled request has a status and no outcome, and so does a completed request on a form without a
  decision question.
</p>

<h2 id="agents">From an agent</h2>

<p>
  The MCP tool is <code>editor_insertDecisionQuestion</code>, taking a title and the three labels in the form's language. An agent that
  cannot host a callback endpoint polls <code>request_get</code> instead and reads <code>outcome</code> when the status leaves{' '}
  <code>pending</code>. <a href="/developers/mcp-server#requests">Requests on the MCP server →</a>
</p>

<div class="not-prose grid gap-3 sm:grid-cols-2">
  - [Callbacks & signing](/requests/callbacks) — The full payload and how to verify it.
  - [The Requests page](/requests/managing-requests) — Filter by outcome and follow one request.
  - [Conditional logic](/building-forms/conditional-logic) — Ask for a reason only when it is needed.
  - [Question types](/building-forms/field-types) — Every other question you can ask.
</div>


# Invitations, reminders & expiry

Let formbase email the request link, chase people who go quiet, and close requests that go stale.

> ✅ **Pro feature** — included in the Pro plan.


## Invitations, reminders & expiry

Delivering the link, chasing the people who go quiet, and deciding when a request stops being worth waiting for.

> ℹ️ **Two ways to deliver a link**
> <p>
>     Every request comes back with a <code>url</code>. You can send it yourself — in your own email, a Slack message, an SMS — or ask
>     formbase to email it. Sending it yourself works on every plan. Letting formbase send invitations and reminders requires a{' '}
>     <strong>Pro or Business plan</strong>; a Free account can have formbase email its first 10 invitations to try it (see{' '}
>     <a href="/subscription-billing/limits-quotas#free-invitations">free invitations</a>).
>   </p>

<h2 id="invitation">The request invitation</h2>

<p>
  Create a request with <code>delivery: "email"</code> and a recipient email address, and formbase sends the{' '}
  <strong>request invitation</strong>: a short email carrying a button that opens the request link.
</p>

<p>
  You author it once per form, not once per request. In the editor, open <strong>Settings → E-mail Notifications</strong> and scroll to the{' '}
  <strong>Requests</strong> group at the bottom:
</p>

<p>
  There is no free-text message from the caller — an automation cannot inject a paragraph into this email. What it <em>can</em> do is supply
  values: mention a context field or a prefilled field in the subject or body and each recipient gets their own version. That keeps the copy
  under your control while still reading as if it were written for them.
</p>

> 💡 **Translate it**
> <p>
>     If the form has published languages, the invitation follows the request's <code>language</code>. Translate the email copy alongside the
>     rest of the form — see <a href="/building-forms/translating-form-content">Translating form content</a>.
>   </p>

<h3 id="delivery-status">Delivery status</h3>

<ul>
  <li>
    The invitation is sent at most once. It is queued, and dropped unsent if the request ends before the queue reaches it — nobody is
    invited to a dead link.
  </li>
  <li>
    If it never arrived, send a reminder. <code>requests.remind</code> carries the same link and counts against the eight-reminder cap. On
    Free, where reminders aren't available, copy the request's link and send it yourself.
  </li>
  <li>Invitations and reminders share a budget of 10 emails per day per form and recipient address.</li>
</ul>

<h2 id="reminders">Reminders</h2>

<p>
  A <strong>reminder schedule</strong> is an ordered list of idle offsets — 1 day, 3 days, 1 week — after which formbase emails someone who
  has gone quiet. One schedule serves two kinds of target: a public-link respondent sitting on an unfinished draft, and a request recipient
  who has not completed.
</p>

<p>
  It lives in <strong>Settings → E-mail Notifications → Send reminders</strong>. That switch is the master: turn it off and neither channel
  gets the form's schedule.
</p>

<h3 id="how-the-schedule-runs">How the schedule runs</h3>

<ul>
  <li>
    Offsets are measured from the target's <strong>last activity</strong>, not from creation. A schedule of 1 day then 3 days means "one day
    idle, then three days idle".
  </li>
  <li>
    <strong>New activity restarts it.</strong> If the recipient opens the request and answers something, the clock goes back to the first
    step.
  </li>
  <li>
    <strong>It stops on its own</strong> once the request is completed, expired, or canceled.
  </li>
  <li>
    <strong>A sweep runs every 15 minutes</strong>, so a reminder lands within a quarter of an hour of the step falling due, not to the
    second. A schedule that fell far behind is not fired as a burst: the gap you put between two steps is honoured.
  </li>
  <li>
    <strong>Eight reminders is the hard ceiling</strong> for one request, scheduled and manual together. Activity can restart a schedule
    forever; this is what stops that becoming a loop.
  </li>
</ul>

<h3 id="per-request">Overriding per request</h3>

<p>
  An automation can pass its own <code>reminders</code> when creating a request — <code>["2d","5d"]</code> for a schedule of its own, or an
  empty list to switch reminders off for that one request. An explicit schedule wins even over the form's master switch, because a caller
  that passed one asked for reminders on this request specifically.
</p>

<p>
  Leave <code>reminders</code> out and the request inherits the form's schedule, if the master switch is on and the request has a recipient
  address.
</p>

<h3 id="manual">Chasing someone now</h3>

<p>
  You do not have to wait for the next step. <strong>Send reminder</strong> in the request drawer — or <code>requests.remind</code> from an
  automation — queues one immediately. The automatic schedule is untouched: the next scheduled reminder still lands when it was going to.
</p>

<p>Two floors apply: at least ten minutes between manual reminders, and the same overall ceiling of eight per request.</p>

<h2 id="expiry">Expiry</h2>

<p>
  Every request has a hard end. When it passes, the link stops working, the status becomes <strong>expired</strong>, and your automation is
  told through the callback — so a workflow that has been parked for a month resumes instead of hanging forever.
</p>

<ul>
  <li>
    Default: <strong>30 days</strong> after creation.
  </li>
  <li>
    Maximum: <strong>365 days</strong>. Pass <code>expiresAt</code> as a millisecond timestamp to set your own.
  </li>
  <li>After expiry the link shows the outcome page — a plain notice, never the form.</li>
  <li>
    A sweep flips lapsed requests every 15 minutes, so the status and the callback follow <code>expiresAt</code> within a quarter of an
    hour. The link itself stops opening the moment the expiry passes.
  </li>
</ul>

```
{
  "method": "requests.create",
  "params": {
    "formId": "j57...",
    "recipient": { "email": "ada@acme.com", "name": "Ada" },
    "delivery": "email",
    "reminders": ["1d", "3d"],
    "expiresAt": 1795392000000,
    "callbackUrl": "https://automation.example/webhook/resume-abc"
  }
}
```

<div class="not-prose grid gap-3 sm:grid-cols-2">
  - [The Requests page](/requests/managing-requests) — Remind and cancel by hand.
  - [Troubleshooting](/requests/troubleshooting) — When an invitation fails or bounces.
  - [Custom email domains](/branding-domains/custom-email-domains) — Send from your own domain.
</div>


# Callbacks & signing

Resume your workflow when a request ends, and prove the call really came from formbase.

## Callbacks & signing

When a request reaches its end — completed, expired, or canceled — formbase POSTs a signed notification to the URL your automation gave it. That call is what resumes the run.

<h2 id="what-fires">What fires, and when</h2>

<p>
  All three arrive at the same URL, so branch on <code>type</code> before assuming there are answers. That is the whole point of firing on
  every ending: a workflow parked on a customer resumes whether they answered, ignored you, or were called off.
</p>

<h2 id="payload">What arrives</h2>

```
{
  "id": "evt_kj7...",
  "type": "request.completed",
  "createdAt": "2026-03-04T09:31:40.000Z",
  "apiVersion": "2026-09-24",
  "test": false,
  "data": {
    "request": {
      "id": "kd7...",
      "externalId": "run-42",
      "status": "completed",
      "outcome": "approve",
      "language": "en",
      "recipient": { "email": "ada@acme.com", "name": "Ada" },
      "metadata": { "runId": "run-42" },
      "context": { "case_id": "CASE-9" },
      "createdAt": "2026-03-04T09:20:00.000Z",
      "completedAt": "2026-03-04T09:31:40.000Z"
    },
    "form": { "id": "j57...", "name": "Vendor onboarding", "snapshotId": "kx2..." },
    "submission": {
      "id": "jd7...",
      "respondentEmail": "ada@acme.com",
      "submittedAt": "2026-03-04T09:31:40.000Z",
      "updatedAt": null,
      "editCount": 0,
      "pdfUrl": null,
      "language": "en"
    },
    "answers": { "company_name": "Acme", "contacts": [{ "name": "Ada" }] },
    "display": { "company_name": "Acme", "contacts": "Ada" }
  }
}
```

<ul>
  <li>
    <code>data.request</code> is always there — including your <code>externalId</code>, <code>metadata</code>, and <code>context</code>,
    unchanged. It carries the timestamp of whichever ending happened (<code>completedAt</code>, <code>expiredAt</code>, or{' '}
    <code>canceledAt</code> with an optional <code>cancelReason</code>).
  </li>
  <li>
    <code>outcome</code> is present only when the recipient answered a <a href="/requests/decisions-and-approvals">decision question</a> —{' '}
    <code>approve</code>, <code>decline</code>, or <code>changes</code>.
  </li>
  <li>
    <code>form</code>, <code>submission</code>, <code>answers</code> and <code>display</code> appear on completion only, in exactly the
    shape a <a href="/developers/webhooks-reference#payload">submission webhook</a> carries. <code>form.snapshotId</code> is the exact
    published version the recipient answered; <code>submission.pdfUrl</code> is a URL only when the form retains a submission PDF, and null
    otherwise.
  </li>
  <li>
    <code>test</code> is <code>true</code> when the request was created in <a href="/requests/creating-requests#test-mode">test mode</a> —
    branch on it, or drop the event.
  </li>
  <li>
    <code>answers</code> is keyed by <a href="/requests/field-keys">field key</a>, with repeating groups nested as one object per instance.
    A choice answer is the option <strong>key</strong> from <code>fields.list</code>, not its label; the label is in <code>display</code>,
    under the same key.
  </li>
  <li>
    The POST arrives as <code>Content-Type: application/json</code> with <code>User-Agent: formbase</code>, and carries{' '}
    <code>X-formbase-Event-Id</code>, <code>X-formbase-Event-Type</code> and <code>X-formbase-Signature</code> — so you can dedupe and route
    before parsing.
  </li>
</ul>

> ⚠️ **Deduplicate on id**
> <p>
>     <code>id</code> is stable across every retry and every replay of the same event. If your receiver might act twice on the same id — a
>     duplicate invoice, a duplicate ticket — remember the ids you have already handled.
>   </p>

<h2 id="verify">Verify the signature</h2>

<p>
  Every callback carries a signature header, <code>X-formbase-Signature: t=&#123;unix seconds&#125;,sha256=&#123;hex&#125;</code>. The hex
  is an HMAC-SHA256 of the timestamp, a dot, and the raw request body, computed with your workspace's{' '}
  <strong>request signing secret</strong>.
</p>

<p>Two rules, whatever language you use:</p>

<ol>
  <li>
    Hash the <strong>raw</strong> body, before any parsing or re-serializing. Re-encoded JSON is not the same bytes.
  </li>
  <li>
    Compare in constant time — <code>crypto.timingSafeEqual</code>, <code>hmac.compare_digest</code> — never with <code>==</code>.
  </li>
</ol>

  
    
```
import crypto from 'node:crypto'

  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
  const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex')
  const a = Buffer.from(expected, 'hex')
  const b = Buffer.from(parts.sha256 ?? '', 'hex')
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false
  return Math.abs(Date.now() / 1000 - Number(parts.t)) <= toleranceSeconds
}
```

  
  
    
```
import hashlib, hmac, time
def verify_formbase_callback(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
  parts = dict(p.split('=', 1) for p in header.split(',') if '=' in p)
  t, received = parts.get('t'), parts.get('sha256')
  if not t or not received:
    return False
  expected = hmac.new(secret.encode(), f'{t}.'.encode() + raw_body, hashlib.sha256).hexdigest()
  if not hmac.compare_digest(expected, received):
    return False
  return abs(time.time() - int(t)) <= tolerance_seconds
```

  

<h2 id="secret">The request signing secret</h2>

<p>
  One secret per workspace signs every callback from it. Find it on <strong>OAuth and API Keys</strong> in the workspace sidebar, in the{' '}
  <strong>Request signing secret</strong> card. It is masked by default; <strong>Reveal secret</strong> shows it and the copy button copies
  it. It is not a one-time value — you can come back and read it again. The secret is minted the first time it is needed, so a workspace
  that has never opened that card and never created a request with a <code>callbackUrl</code> has none yet.
</p>

> ❗ **Regenerating has no grace period**
> <p>
>     Only the workspace owner can regenerate the secret, and the moment they do, the old one stops working — including for callbacks that are
>     already being retried. <strong>Update your receiver first, then regenerate.</strong> There is no window where both secrets are accepted.
>   </p>

<h2 id="retries">Retries</h2>

<p>
  A callback gets <strong>eight attempts</strong>: the first, then seven retries at least 1, 2, 4, 8, 16, 32 and 60 minutes apart. formbase
  looks for due retries every 30 minutes, so a retry can come up to half an hour after its gap ends, and the last attempt comes about four
  hours after the request ended. Every attempt carries the same bytes and the same <code>id</code>: the payload is frozen at the moment the
  request ended, so a retry describes what happened then, not what the request looks like now. The destination URL and the signing secret
  are read at each attempt, not frozen with it.
</p>

<p>
  If the budget runs out — your receiver was down for the afternoon — the callback is not lost. The request gets a{' '}
  <strong>Callback failed</strong> badge, the workspace owner is emailed once with the host, the reason and the attempt count, and the
  answers stay readable through <code>requests.get</code>. To push it again, open the request in the{' '}
  <a href="/requests/managing-requests">Requests page</a> and press <strong>Replay</strong>, or call <code>requests.replayCallback</code>.
  It re-sends the same frozen payload with the same <code>id</code>, which is exactly what a receiver that deduplicates wants.
</p>

<h2 id="subscriptions">Subscriptions hear the same events</h2>

<p>
  A callback URL belongs to one request. When every request on a form should reach the same receiver, subscribe once instead: the formbase
  apps for Zapier and n8n do this for you, and <code>webhooks.create</code> does it from code with <code>request_completed</code>,{' '}
  <code>request_expired</code> or <code>request_canceled</code> as the event type. A subscription receives this same envelope, signed with
  its own secret rather than the workspace request signing secret, with its own event id and its own retry budget. A request that has both a
  callback URL and a matching subscription fires twice, once to each. <strong>Replay</strong> re-sends the callback only; a subscription
  retries on its own and pauses after five failed attempts.
</p>

> 💡 **A resume URL is not authentication**
> <p>
>     Workflow tools hand you a hard-to-guess resume URL and it is tempting to treat that as proof. It is a bearer secret — it can leak into
>     logs, and it does not tell you the body was not tampered with. Verify the signature in the resumed branch as well.
>   </p>

<div class="not-prose grid gap-3 sm:grid-cols-2">
  - [Troubleshooting](/requests/troubleshooting) — When a callback keeps failing.
  - [Webhook reference](/developers/webhooks-reference) — The answers and display maps a completion carries, in full.
</div>


# The Requests page

See everything you are waiting on, follow one request through its timeline, and act on it.

## The Requests page

However a request was created — by hand in the Share sheet or by an automation — it is yours to watch. The Requests page in the sidebar shows everything you are waiting on from customers, across every form in the workspace.

<h2 id="the-list">The list</h2>

<p>
  Click <strong>Requests</strong> in the sidebar, between Forms and Trash. Each card is one request: the recipient, the form it belongs to,
  its status, and — for a pending one — where it has got to.
</p>

<p>
  <strong>Sort</strong> by Created, Last activity, or Name — click the field again to flip the direction. <strong>Search</strong> matches
  the recipient name, the recipient email address, and the external id. <strong>Filter</strong> by status (Pending, Completed, Expired,
  Canceled), by <a href="/requests/decisions-and-approvals">outcome</a> (Approved, Declined, Changes requested), by form, or by{' '}
  <strong>Callback failed</strong> when you are hunting for deliveries to replay. Test requests are hidden until you tick{' '}
  <strong>Show test requests</strong> in the same filter.
</p>

<p>
  Two shortcuts land you here pre-filtered: <strong>Open Requests</strong> on the Share sheet's Requests card, and the "pending requests"
  link above a form's submissions list.
</p>

<h2 id="drawer">One request in detail</h2>

<p>Click a card to open the drawer. Its address bar keeps the request id, so you can share the link with a colleague or bookmark it.</p>

<h3 id="sections">What the drawer shows</h3>

<ul>
  <li>
    <strong>Request link</strong> — the URL, with a copy button. The caption says how long it stays open, or, once terminal, that it now
    shows the outcome page instead of the form.
  </li>
  <li>
    <strong>Prefilled for them</strong> — every value the automation supplied, by field key, with a lock icon on the ones the recipient
    could not change and a <em>context</em> marker on the hidden-field values. This is the fastest way to answer "what did we actually send
    them?". If the form's <a href="/submissions-analytics/submission-retention#retention-and-requests">retention window</a> has passed since
    the request ended, this section says the data was removed instead.
  </li>
  <li>
    <strong>Timeline</strong> — newest first: every callback attempt, completed or expired or canceled, started, opened, reminders,
    invitation queued or delivered or failed, and created at the bottom. Entries that failed are shown in red, and repeated attempts are
    counted.
  </li>
  <li>
    <strong>Callback</strong> — the URL your automation gave us and how the delivery went. A delivery that used up its attempts shows a red{' '}
    <em>Callback failed after N attempts</em> alert with the <strong>Replay</strong> button beside it.
  </li>
  <li>
    <strong>Answers</strong> — <strong>View submission</strong> once they have completed, and <strong>Draft in progress</strong> while they
    are still filling it in.
  </li>
</ul>

> ℹ️ **Where the timeline comes from**
> <p>
>     It is derived from the request's own timestamps plus its delivery attempts. Delivery rows are cleaned up after 30 days, so an old
>     request's timeline thins back to the milestones — created, opened, completed — while the email and callback lines drop away.
>   </p>

<h3 id="actions">What you can do</h3>

<p>
  Cancel, replay, and copy link work on every plan. Cancelling asks first; after it, the recipient sees a withdrawn notice instead of the
  form. Everything else acts immediately and confirms with a toast.
</p>

<h2 id="test">Sending yourself a test</h2>

<p>
  <strong>Try it yourself</strong> on the Share sheet's Requests card creates a request in{' '}
  <a href="/requests/creating-requests#test-mode">test mode</a> from the Manual tab's draft and shows you its link. Nothing is emailed: open
  the link yourself to walk through the form as the recipient would. The request shows in this list, with a <strong>Test</strong> badge,
  once you tick <strong>Show test requests</strong>.
</p>

<div class="not-prose grid gap-3 sm:grid-cols-2">
  - [Troubleshooting](/requests/troubleshooting) — What to do when something failed.
  - [Invitations & reminders](/requests/invitations-and-reminders) — Set the schedule these actions work with.
</div>


# Troubleshooting requests

Rejected calls, invitations that never arrived, and callbacks that never landed.

## Troubleshooting requests

Where to look when a request was refused, an invitation never arrived, or a workflow is still waiting for a callback that already happened.

<h2 id="reading-errors">Reading an error</h2>

<p>
  Every rejection carries two things: a <code>code</code> for the kind of failure, and a <code>details.reason</code> for the specific cause.
  Branch on the code; read the reason to know what to fix. Where it helps, <code>details</code> also names the offending <code>field</code>,
  the keys that would have been accepted, or the option keys a choice question takes.
</p>

<p>
  The request methods use four codes: <code>VALIDATION_ERROR</code> (the call was wrong), <code>CONFLICT</code> (the request is in the wrong
  state, or an idempotency key was reused), <code>NOT_FOUND</code>, and <code>UPGRADE_REQUIRED</code> (a plan gate or the monthly
  allowance). Calling too fast returns <code>RATE_LIMITED</code> instead, with <code>retryAfterMs</code> — see{' '}
  <a href="/requests/creating-requests#rate-limit">the rate limit</a>.
</p>

```
{
  "ok": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "No field with key \"company\".",
    "details": { "reason": "UNKNOWN_FIELD_KEY", "field": "prefill.company", "validKeys": ["company_name", "company_size"] }
  }
}
```

<h2 id="reasons">Reasons you might see</h2>

<h3 id="reasons-setup">Getting the call right</h3>

<h3 id="reasons-state">Acting on an existing request</h3>

<h2 id="invitation-problems">The invitation never arrived</h2>

<p>Open the request in the Requests page and read the timeline. The first invitation line tells you which case you are in.</p>

> ℹ️ **No timeline entry at all**
> <p>
>     Then no email was ever asked for. The request was created with <code>delivery: "none"</code> — deliver the link yourself, or create a
>     new request with <code>delivery: "email"</code>.
>   </p>

<p>
  Invitations and reminders share a budget of ten emails per day per form and recipient address, and a request never emails its recipient
  more than nine times in its life — one invitation and up to eight reminders.
</p>

<h2 id="callback-problems">The callback never landed</h2>

<p>
  The request drawer's <strong>Callback</strong> section shows the URL and the outcome. <em>Callback failed after N attempts</em> means
  formbase tried and gave up — eight attempts over about four hours.
</p>

<ol>
  <li>
    <strong>Check the URL.</strong> It is shown in the drawer. A workflow tool's resume URL belongs to one run, and a run that was deleted
    or re-created no longer answers on it.
  </li>
  <li>
    <strong>Check what your endpoint returned.</strong> Anything outside 2xx is a failure. A 4xx other than 408 or 429 stops the retries
    immediately — formbase reads it as "your endpoint rejected this", and re-sending identical bytes cannot change that.
  </li>
  <li>
    <strong>Fix the receiver, then press Replay.</strong> The same payload goes out again with the same event id, so a receiver that
    deduplicates is safe.
  </li>
</ol>

> ⚠️ **Signature check failing?**
> <p>
>     Almost always the raw body. If you parse the JSON and re-serialize it before hashing, the bytes differ and the signature will never
>     match. Hash the body exactly as it arrived. The other common cause is a regenerated signing secret that the receiver has not picked up —
>     there is no grace period.
>   </p>

<h2 id="other">Other things people hit</h2>

<ul>
  <li>
    <strong>The recipient says the link shows a notice, not the form.</strong> The request is terminal — completed, expired, or canceled.
    That is the outcome page. Create a new request if they need another go.
  </li>
  <li>
    <strong>An automation stopped matching after an edit</strong> — an answer went missing from the callback, or{' '}
    <code>requests.create</code> began refusing a key with <code>UNKNOWN_FIELD_KEY</code>. A published field key disappeared: someone
    retyped it, or deleted the question and added a new one in its place. Retitling is safe; both of those are not. Type the old key on the
    field (toolbar key icon → <strong>Keys</strong>) and publish again. Publish warns before this happens — see{' '}
    <a href="/requests/field-keys#removed-keys">When a published key is about to disappear</a>.
  </li>
  <li>
    <strong>Prefill for a file upload or a signature is refused.</strong> Those cannot be supplied by a caller — <code>fields.list</code>{' '}
    marks them <code>prefillable: false</code>.
  </li>
  <li>
    <strong>Two requests appeared for one workflow run.</strong> The run retried without an <code>idempotencyKey</code>. Pass the execution
    id as the key.
  </li>
</ul>

<div class="not-prose grid gap-3 sm:grid-cols-2">
  - [Callbacks & signing](/requests/callbacks) — Retries, replay, and how to verify.
  - [The Requests page](/requests/managing-requests) — Where the timeline and the actions live.
</div>

