# 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>
