# formbase Docs — Developers

# API overview

Authentication, endpoint, errors, and rate limits.

## API overview

A single JSON-RPC endpoint authenticated with API tokens. Call any method by name.

> ℹ️ **No code needed?**
> <p>
>     The <a href="/guides/overview">Guides</a> set formbase up in Zapier click by click, without calling the API yourself.
>   </p>

<h2 id="endpoint">Endpoint</h2>
<p>
  All requests go to a single URL via <code>POST</code>. Pass the method name and parameters as JSON.
</p>

```
POST https://api.formbase.so/api/v1
```

<h2 id="auth">Authentication</h2>
<p>
  Pass your API token as a bearer token in the <code>Authorization</code> header. Tokens are scoped to a workspace — create them from{' '}
  <strong>OAuth and API Keys</strong> in the sidebar.
</p>

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

  
  
    
```
const res = await fetch('https://api.formbase.so/api/v1', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.FORMBASE_TOKEN}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ method: 'forms.list', params: {} }),
})
const data = await res.json()
```

  
  
    
```
import os, requests
res = requests.post(
    "https://api.formbase.so/api/v1",
    headers={"Authorization": f"Bearer {os.environ['FORMBASE_TOKEN']}"},
    json={"method": "forms.list", "params": {}},
)
data = res.json()
```

  

> ⚠️ **Keep tokens server-side**
> <p>Never embed tokens in browser code. Use a backend proxy for client-side calls.</p>

<h2 id="request-format">Request format</h2>
<p>Every request is a JSON object with two fields:</p>

```
{
  "method": "forms.list",
  "params": {
    "workspaceId": "abc123..."
  }
}
```

<h2 id="response-format">Response format</h2>
<p>
  Every response is a JSON object with an <code>ok</code> field. On success:
</p>

```
{
  "ok": true,
  "data": { ... }
}
```

<h2 id="errors">Errors</h2>
<p>
  On failure, <code>ok</code> is <code>false</code> and an <code>error</code> object contains a machine-readable code and human-readable
  message:
</p>

```
{
  "ok": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Field 'name' is required."
  }
}
```

<p>Common error codes:</p>
<ul>
  <li>
    <code>VALIDATION_ERROR</code> (400) — invalid or missing parameters
  </li>
  <li>
    <code>UNAUTHORIZED</code> (401) — missing or invalid API token
  </li>
  <li>
    <code>UPGRADE_REQUIRED</code> (402) — the feature requires a higher subscription tier
  </li>
  <li>
    <code>FORBIDDEN</code> (403) — token lacks access to the resource
  </li>
  <li>
    <code>CONFLICT</code> (409) — resource state conflict, such as a duplicate name
  </li>
  <li>
    <code>NOT_FOUND</code> (404) — resource does not exist
  </li>
  <li>
    <code>METHOD_NOT_FOUND</code> (404) — unknown method name
  </li>
  <li>
    <code>RATE_LIMITED</code> (429) — too many requests
  </li>
  <li>
    <code>INTERNAL_ERROR</code> (500) — unexpected server error
  </li>
</ul>

<h2 id="rate-limits">Rate limits</h2>
<p>
  API requests are rate-limited to <strong>120 requests per minute</strong> per token. Exceeding the limit returns{' '}
  <code>429 Too Many Requests</code>.
</p>

<h2 id="next-steps">Next steps</h2>
<div class="not-prose grid gap-3 sm:grid-cols-2">
  - [API methods](/developers/rest-api) — Full list of available methods
  - [API tokens](/developers/api-tokens) — Create and manage tokens
  - [Webhooks reference](/developers/webhooks-reference) — Payload schema and signing
  - [MCP server](/developers/mcp-server) — Use formbase from AI agents
</div>


# API methods

Complete reference for every REST API method with parameters, examples, and responses.

## API methods

Complete reference for every method exposed by the formbase REST API. Each method shows its parameters, example requests, and response shapes.

<p>
  The same reference is available as an <a href="/openapi.json">OpenAPI 3.1 description</a> for code generators, API clients, and agents.
</p>

> ℹ️ **Single endpoint, many methods**
> <p>
>     Every method is <code>POST https://api.formbase.so/api/v1</code> with a JSON body <code>{`{"method": "...", "params": {...}}`}</code>{' '}
>     and an <code>Authorization: Bearer fb_...</code> header. See <a href="/developers/overview">API overview</a> for auth and error
>     handling, and <a href="/developers/api-tokens">API tokens</a> for the token itself.
>   </p>

<h2 id="conventions">Conventions</h2>

<ul>
  <li>
    <code>params</code> may be omitted; it defaults to <code>{`{}`}</code>. An unknown method is <code>404 METHOD_NOT_FOUND</code>.
  </li>
  <li>
    A token is bound to <strong>one workspace</strong>. Naming another workspace, or a form in one, is <code>403 FORBIDDEN</code> even when
    you belong to both.
  </li>
  <li>
    <strong>Pagination.</strong> List methods return <code>{`{ items, nextCursor, hasMore }`}</code>; most also return{' '}
    <code>canPaginate</code>, which is <code>false</code> when <code>hasMore</code> is true but no cursor can resume (fuzzy search). Pass{' '}
    <code>nextCursor</code> back as <code>cursor</code>. <code>limit</code> is 1–100, default 20 — except <code>requests.list</code>, whose
    default is 25.
  </li>
  <li>
    <strong>Rate limits.</strong> 120 calls per minute per token, shared with the <a href="/developers/mcp-server">MCP server</a>;{' '}
    <code>requests.create</code> has its own 60 per minute. Failed authentication is limited separately, 30 per 15 minutes per IP, after
    which bad tokens see <code>RATE_LIMITED</code> instead of <code>UNAUTHORIZED</code>.
  </li>
  <li>
    <strong>Body size.</strong> 1 MiB. Larger bodies are rejected with <code>VALIDATION_ERROR</code>.
  </li>
  <li>
    <strong>Versioning.</strong> The path carries the version. Breaking changes ship as <code>/api/v2</code>; new methods and new response
    fields do not.
  </li>
</ul>

{/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}

<h2 id="forms">Forms</h2>

{/* ── forms.list ──────────────────────────────────────────────────────────── */}

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

  
    Workspace ID.
  
  
    Filter by folder. Pass <code>null</code> for root-level forms only. Omit to list all.
  
  
    Fuzzy name search. Results capped at <code>limit</code>; not cursor-paginated.
  
  
    Page size (1–100).
  
  
    Pagination cursor from a previous response.
  

  
```
curl -X POST https://api.formbase.so/api/v1 \
  -H "Authorization: Bearer fb_..." \
  -H "Content-Type: application/json" \
  -d '{
    "method": "forms.list",
    "params": { "workspaceId": "ws_abc123" }
  }'
```

  
    
```
{
  "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
  }
}
```

  

{/* ── forms.get ───────────────────────────────────────────────────────────── */}

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

  
    Form ID.
  

  
```
curl -X POST https://api.formbase.so/api/v1 \
  -H "Authorization: Bearer fb_..." \
  -H "Content-Type: application/json" \
  -d '{
    "method": "forms.get",
    "params": { "formId": "frm_abc123" }
  }'
```

  
    
```
{
  "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://formbase.so/preview/abc..."
  }
}
```

  

{/* ── forms.create ────────────────────────────────────────────────────────── */}

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

  
    Form name (1–255 characters).
  
  
    Workspace ID.
  
  
    Place the form in a folder. Omit to create at workspace root.
  

  
    
      
```
curl -X POST https://api.formbase.so/api/v1 \
  -H "Authorization: Bearer fb_..." \
  -H "Content-Type: application/json" \
  -d '{
    "method": "forms.create",
    "params": {
      "name": "Contact",
      "workspaceId": "ws_abc123"
    }
  }'
```

    
    
      
```
const res = await fetch('https://api.formbase.so/api/v1', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.FORMBASE_TOKEN}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    method: 'forms.create',
    params: { name: 'Contact', workspaceId: 'ws_abc123' },
  }),
})
const { ok, data } = await res.json()
```

    
    
      
```
import os, requests
res = requests.post(
  "https://api.formbase.so/api/v1",
  headers={"Authorization": f"Bearer {os.environ['FORMBASE_TOKEN']}"},
  json={
    "method": "forms.create",
    "params": {
      "name": "Contact",
      "workspaceId": "ws_abc123",
    },
  },
)
data = res.json()
```

  

  
    
```
{
  "ok": true,
  "data": {
    "id": "frm_new123",
    "name": "Contact",
    "workspaceId": "ws_abc123",
    "folderId": null,
    "isPublished": false,
    "createdAt": 1714041851000,
    "previewUrl": "https://formbase.so/preview/abc..."
  }
}
```

  

{/* ── forms.update ────────────────────────────────────────────────────────── */}

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

  
    Form ID.
  
  
    New form name (1–255 characters).
  
  
    Move form to a folder. Pass <code>null</code> to move to workspace root.
  
  
    Form emoji (max 10 characters). Pass <code>null</code> to clear.
  
  
    Cover. <code>{`{"type": "color", "color": "#ffffff"}`}</code>, <code>{`{"type": "image", "url": "https://...", "offsetY": 50}`}</code> (
    <code>offsetY</code> 0–100, default 50), or <code>{`{"type": "none"}`}</code> to remove. Image URLs must be <code>http(s)</code> or a{' '}
    <code>data:image</code> URI.
  
  
    Logo. <code>{`{"type": "icon", "name": "HeartIcon"}`}</code>, <code>{`{"type": "image", "url": "https://..."}`}</code>, or{' '}
    <code>{`{"type": "none"}`}</code> to remove. Icon names are fixed: <code>QuestionMarkIcon</code>, <code>ListBulletsIcon</code>,{' '}
    <code>ChartBarIcon</code>, <code>ClockCountdownIcon</code>, <code>HeartIcon</code>, <code>LightbulbIcon</code>,{' '}
    <code>CheckCircleIcon</code>, <code>MagnifyingGlassIcon</code>, <code>TrendUpIcon</code>, <code>EnvelopeIcon</code>,{' '}
    <code>PhoneIcon</code>, <code>CalendarIcon</code>, <code>LinkIcon</code>, <code>UsersIcon</code>.
  

<p>Pass at least one of the five updatable fields. It does not change form content — use the MCP editor tools for that.</p>

  
```
curl -X POST https://api.formbase.so/api/v1 \
  -H "Authorization: Bearer fb_..." \
  -H "Content-Type: application/json" \
  -d '{
    "method": "forms.update",
    "params": {
      "formId": "frm_abc123",
      "name": "Updated Name",
      "emoji": "📋"
    }
  }'
```

  
    
```
{
  "ok": true,
  "data": {
    "id": "frm_abc123",
    "name": "Updated Name",
    "folderId": null,
    "emoji": "📋"
  }
}
```

  

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

{/* ── forms.publish ───────────────────────────────────────────────────────── */}

Publish a form so it can accept responses, and freeze its [field keys](/requests/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.

  
    Form ID.
  

<p>
  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 <a href="#share-links-create">shareLinks.create</a> for that.
</p>

{/* ── 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`.

  
    Form ID.
  

{/* ── forms.delete ────────────────────────────────────────────────────────── */}

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

  
    Form ID.
  

> ⚠️ **Restoring does not bring the links back**
> <p>
>     <code>forms.restore</code> returns the form, but the share links it revoked stay revoked. Mint new ones with{' '}
>     <code>shareLinks.create</code>. A form already in trash returns <code>alreadyTrashed: true</code> and keeps its original trash date.
>   </p>

{/* ── forms.restore ───────────────────────────────────────────────────────── */}

Restore a form from trash.

  
    Form ID.
  
  
    Where to restore it. Omit for its original folder, <code>null</code> for workspace root, or a folder ID.
  

<p>
  A form that is not in trash returns <code>alreadyRestored: true</code>.
</p>

{/* ── formSettings.get ────────────────────────────────────────────────────── */}

Read a form's behavior settings.

  
    Form ID.
  

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

{/* ── formSettings.update ─────────────────────────────────────────────────── */}

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

  
    Form ID.
  
  
    <code>language</code> (BCP-47, default <code>"en"</code>), <code>requireAuthentication</code>, <code>showBranding</code>,{' '}
    <code>captchaEnabled</code>, <code>passwordEnabled</code>, <code>password</code> (4 characters or more; a string implies{' '}
    <code>passwordEnabled: true</code>, <code>null</code> clears the gate).
  
  
    <code>notifyOnSubmission</code>, <code>notificationEmails</code> (array), <code>selfNotificationSubject</code>,{' '}
    <code>selfNotificationEmailBody</code>, <code>pdfGenerationEnabled</code>, <code>showViewSubmissionButton</code>. Owner emails are not
    translatable — write them in the language you want.
  
  
    <code>respondentNotificationEnabled</code>, <code>respondentNotificationTo</code> (the field id of an email question, or{' '}
    <code>null</code>), <code>respondentNotificationSubject</code>, <code>respondentNotificationBody</code>,{' '}
    <code>respondentNotificationPdfEnabled</code>.
  
  
    <code>respondentReminderEnabled</code>, <code>respondentReminderTo</code>, <code>respondentReminderSubject</code>,{' '}
    <code>respondentReminderBody</code>, <code>respondentReminderRequiredFieldIds</code>, and <code>reminderSteps</code> — idle offsets like{' '}
    <code>["1d","3d","1w"]</code>, at most 5, sorted and deduplicated on save, <code>[]</code> for none. The schedule applies to abandoned
    public-link responses and to requests alike. Pro.
  
  
    <code>redirectUrl</code> (<code>http(s)</code>; <code>null</code> or <code>""</code> clears), <code>redirectQueryParams</code> (
    <code>{`[{ paramName, fieldId }]`}</code>), <code>allowAnotherResponse</code> (mutually exclusive with a redirect),{' '}
    <code>maxSubmissionsPerRespondent</code> (0 = unlimited, max 1000), <code>editAfterSubmit</code>, <code>maxEdits</code> (max 3; 0 means
    unlimited on Pro and Business, 3 on Free).
  
  
    <code>draftRetentionDays</code> and <code>submissionRetentionDays</code> (0–36500, <code>null</code> reverts to the default). Submission
    retention is Business, and setting it clears any fixed deletion date configured in the builder.
  
  
    A verified email-domain id from <code>formSettings.get</code>, for a custom From address. <code>null</code> resets to the default
    sender.
  

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

{/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}

<h2 id="submissions" class="border-t border-border pt-8">
  Submissions
</h2>

{/* ── submissions.list ────────────────────────────────────────────────────── */}

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

  
    Form ID.
  
  
    Include responses that were started but never submitted. Drafts are a Pro feature: on Free only completed submissions are listed.
  
  
    Attach stored AI translations of the answers under <code>items[].translation.display</code>, keyed like <code>display</code>.{' '}
    <code>items[].answers</code> and <code>items[].display</code> always stay the original.
  
  
    Page size (1–100).
  
  
    Pagination cursor from a previous response.
  

  
    
```
{
  "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**
> <p>
>     Each item carries <code>answers</code> keyed by <a href="/requests/field-keys">field key</a> and <code>display</code> with the same keys
>     as readable text — the shape a <a href="/developers/webhooks-reference">webhook payload</a>, a{' '}
>     <a href="/requests/callbacks">request callback</a> and <code>requests.get</code> carry. A choice answer is its option key, a repeating
>     group an array of instances. Call <code>fields.list</code> for each key's title and option labels. This method returns no totals.
>   </p>

{/* ── 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.

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

  
    
```
{
  "ok": true,
  "data": {
    "url": "https://api.formbase.so/api/storage/...",
    "filename": "formbase-submission-sub_xyz789.pdf",
    "contentType": "application/pdf",
    "byteLength": 148213
  }
}
```

  

{/* ── 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 <code>request.completed</code> instead, sampled by{' '}

<a href="#requests-sample">requests.sample</a>. <code>data.form.snapshotId</code> is the form's current published version, the same id live
events carry, or <code>null</code> while the form is unpublished.

  
    Form ID.
  

  
    
```
{
  "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" }
    }
  }
}
```

  

<p>
  Field and payload semantics are documented once, in the <a href="/developers/webhooks-reference#payload">webhooks reference</a>.
</p>

{/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}

<h2 id="share-links" class="border-t border-border pt-8">
  Share links
</h2>

{/* ── shareLinks.list ─────────────────────────────────────────────────────── */}

List share links for a form.

  
    Form ID.
  
  
    Include revoked links in the result.
  
  
    Page size (1–100).
  
  
    Pagination cursor.
  

  
    
```
{
  "ok": true,
  "data": {
    "items": [
      {
        "id": "sl_abc123",
        "code": "RPjNes52",
        "url": "https://formbase.so/RPjNes52",
        "customDomainUrl": null,
        "formId": "frm_abc123",
        "createdAt": 1714041851000,
        "expiresAt": null,
        "maxClaims": null,
        "claimedCount": 7,
        "isRevoked": false,
        "revokedAt": null,
        "customDomainId": null,
        "customSlug": null
      }
    ],
    "availableCustomDomains": [],
    "nextCursor": null,
    "hasMore": false,
    "canPaginate": false
  }
}
```

  

{/* ── shareLinks.create ───────────────────────────────────────────────────── */}

Create a share link for a form. The form must be published first.

  
    Form ID. A form that is unpublished, or was published and then unpublished, is rejected — call <code>forms.publish</code> first.
  
  
    Expiration as a future Unix timestamp in milliseconds. Unlike on update, <code>0</code> is not accepted here.
  
  
    Maximum number of times this link can be used. Must be positive; use <code>shareLinks.update</code> to clear it later.
  

<p>
  The response is the share link (same shape as a <code>shareLinks.list</code> item) plus <code>availableCustomDomains</code>, so you can
  follow up with <code>shareLinks.update</code> to attach one.
</p>

  
```
curl -X POST https://api.formbase.so/api/v1 \
  -H "Authorization: Bearer fb_..." \
  -H "Content-Type: application/json" \
  -d '{
    "method": "shareLinks.create",
    "params": {
      "formId": "frm_abc123",
      "maxClaims": 100
    }
  }'
```

{/* ── shareLinks.update ───────────────────────────────────────────────────── */}

Update a share link. Can change expiration, max claims, custom domain, slug, or revoke the link.

  
    Share link ID.
  
  
    New expiration timestamp in milliseconds. Pass <code>0</code> to remove expiration.
  
  
    New max claims. Pass <code>-1</code> to remove the limit.
  
  
    Attach a custom domain. Pass <code>null</code> to detach.
  
  
    Custom URL slug (3–64 characters, lowercase alphanumeric and hyphens). Required together with <code>customDomainId</code>; pass both{' '}
    <code>null</code> to detach. <code>login</code>, <code>auth-callback</code>, <code>preview</code>, <code>payment</code>,{' '}
    <code>api</code>, <code>admin</code> and <code>health</code> are reserved.
  
  
    Set to <code>true</code> to permanently revoke the link. Cannot be combined with other fields, and cannot be undone — this is the only
    delete path for a share link.
  

{/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}

<h2 id="fields" class="border-t border-border pt-8">
  Fields
</h2>

{/* ── 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 [Field keys](/requests/field-keys).

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

  
```
curl -X POST https://api.formbase.so/api/v1 \
  -H "Authorization: Bearer fb_..." \
  -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": "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
  }
}
```

  

> ℹ️ **Reading the flags**
> <p>
>     <code>context: true</code> is a hidden field — its value belongs in <code>context</code>, never in <code>prefill</code>.{' '}
>     <code>prefillable: false</code> marks a field nobody can supply a value for (file, signature, payment, appointment, documents). For a
>     choice question, send the option <strong>key</strong>, not its label; a matrix lists its <code>rows</code> and <code>columns</code> the
>     same way and takes <code>{'{ "row_key": "column_key" }'}</code>. <code>calculated: true</code> is a calculated field: the form works out
>     its value, you read it back in <code>answers</code>, and nothing can send it.
>   </p>

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

{/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}

<h2 id="requests" class="border-t border-border pt-8">
  Requests
</h2>

A request assigns one published form to one person and calls you back when it ends. The conceptual guide lives in
[Creating a request](/requests/creating-requests); this is the parameter list.

{/* ── requests.create ─────────────────────────────────────────────────────── */}

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

  
    The published form to assign.
  
  
    <code>{`{ email?, name? }`}</code>. An email is required when <code>delivery</code> is <code>"email"</code>; otherwise it only
    identifies the person in the Requests page and on their answers.
  
  
    Initial answers by field key. The recipient sees them and can change them.
  
  
    Prefilled keys the recipient cannot change. Every key here must also appear in <code>prefill</code>, and a locked required field must be
    prefilled with a non-empty value.
  
  
    Values for the form's hidden fields, by field key. Trusted, unchangeable, and echoed back in the callback. An unknown key is rejected
    with <code>UNKNOWN_FIELD_KEY</code>.
  
  
    Your own bookkeeping. Never reaches the form; comes back in callbacks and reads.
  
  
    One of the form's published languages. Defaults to the form's own default.
  
  
    <code>"email"</code> to have formbase send the invitation (needs a recipient email, and Pro or Business or one of a Free account's 10
    free invitations), or <code>"none"</code> to deliver the link yourself.
  
  
    Override the form's reminder schedule for this request. An empty array switches reminders off.
  
  
    Epoch milliseconds. Defaults to 30 days out; 365 days is the maximum.
  
  
    Where formbase POSTs the callback when the request ends. HTTPS only, and the host must resolve to a public address.
  
  
    Your own id for this request. Filterable in <code>requests.list</code>.
  
  
    Repeating it with the same body returns the original request with <code>deduplicated: true</code>. A different body is rejected. Keys
    live 30 days.
  
  
    Mint the link on one of your custom domains. REST API only.
  
  
    <code>{`[{ documentId, field?, name? }]`}</code> — files handed to this one recipient, uploaded first with{' '}
    <a href="#documents-create">documents.create</a>.
  
  
    A dry run: nothing is emailed, the callback carries <code>"test": true</code>, and the submission counts nowhere. The link closes within
    24 hours, and on Free a workspace may create 10 test requests a day.
  

  
```
curl -X POST https://api.formbase.so/api/v1 \
  -H "Authorization: Bearer fb_..." \
  -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"
    }
  }'
```

  
    
```
{
  "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
  }
}
```

  

> ⚠️ **Keep the url**
> <p>
>     <code>url</code> carries the one-time token. <code>requests.get</code> can usually rebuild it, but it comes back <code>null</code> for a
>     request created before the deployment had a request-token key. If you deliver the link yourself, store it when you create it.
>   </p>

<p>
  <code>deliveryStatus</code> is <code>not_requested</code> until an invitation is queued, then <code>queued</code> → <code>sent</code> or{' '}
  <code>failed</code>, and <code>bounced</code> once the mail provider reports a hard bounce or a complaint.
</p>

{/* ── requests.get ────────────────────────────────────────────────────────── */}

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

  
    Request ID.
  

  
    
```
{
  "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.formbase.so/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**
> <p>
>     <code>status</code> says whether the request finished; <code>outcome</code> says what the recipient decided — <code>approve</code>,{' '}
>     <code>decline</code>, <code>changes</code>, or <code>null</code> on anything but a completed request whose recipient picked one of the
>     three — including a form with no <a href="/requests/decisions-and-approvals">decision question</a>. The callback URL itself is never
>     returned; <code>hasCallback</code> only says whether one is set.
>   </p>

<p>
  The example above is trimmed. A full response also carries <code>workspaceId</code>, <code>formSnapshotId</code>, <code>createdVia</code>,{' '}
  <code>documents</code>, <code>reminderStep</code>, <code>remindersSent</code>, <code>reminderDueAt</code>, <code>dataPurgedAt</code>, and
  the rest of the timestamps (<code>updatedAt</code>, <code>openedAt</code>, <code>startedAt</code>, <code>lastActivityAt</code>,{' '}
  <code>expiredAt</code>, <code>canceledAt</code>, <code>canceledBy</code>, <code>cancelReason</code>).
</p>
<p>
  Two fields tell you when the copy in front of you is the only copy. <code>callbackFailedAt</code> is set while this request&apos;s
  callback has run out of attempts, and cleared once one gets through or you replay it. <code>dataPurgedAt</code> is set once retention
  stripped the request: <code>context</code>, <code>prefill</code> and <code>metadata</code> come back empty, <code>readonlyKeys</code> and{' '}
  <code>documents</code> are <code>[]</code>, and <code>submissionId</code>, <code>answers</code> and <code>display</code> are{' '}
  <code>null</code>.
</p>
<p>
  <code>timeline</code> is derived, oldest first. Each entry has an <code>id</code>, an <code>at</code>, and a <code>type</code> —{' '}
  <code>created</code>, <code>invitation</code>, <code>reminder</code>, <code>opened</code>, <code>started</code>, <code>completed</code>,{' '}
  <code>expired</code>, <code>canceled</code>, <code>callback</code>. Delivery entries add <code>deliveryStatus</code> and{' '}
  <code>attemptCount</code>, and callbacks add <code>eventType</code>. Delivery rows are kept 30 days, so older timelines thin back to the
  timestamps.
</p>

{/* ── requests.list ───────────────────────────────────────────────────────── */}

List requests in a workspace or on one form, newest first. Test requests are left out unless you ask for them.

  
    Scope to a workspace. Give this or <code>formId</code>.
  
  
    Scope to one form.
  
  
    <code>pending</code>, <code>completed</code>, <code>expired</code>, or <code>canceled</code>.
  
  
    <code>approve</code>, <code>decline</code>, or <code>changes</code>. Implies completed requests only.
  
  
    Your own id, to find the request a run created.
  
  
    Include requests created with <code>test: true</code>.
  
  
    Page size (1–100).
  
  
    Pagination cursor from a previous response.
  

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

  

<p>
  List items carry the same fields as <code>requests.get</code> minus <code>url</code>, <code>answers</code>, <code>display</code>, and{' '}
  <code>timeline</code>, and each one carries <code>isTest</code>. Give <code>workspaceId</code> or <code>formId</code> — neither is{' '}
  <code>400 VALIDATION_ERROR</code> with reason <code>SCOPE_REQUIRED</code>. <code>outcome</code> overrides <code>status</code>, since only
  a completed request has a verdict.
</p>

{/* ── requests.cancel ─────────────────────────────────────────────────────── */}

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

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

{/* ── requests.remind ─────────────────────────────────────────────────────── */}

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

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

<p>
  Two floors apply: at least 10 minutes between manual reminders, and at most 8 reminders per request in total, manual and scheduled
  together. The automatic schedule is untouched — <code>reminderStep</code> and <code>reminderDueAt</code> stay where they were.
</p>

{/* ── requests.replayCallback ─────────────────────────────────────────────── */}

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

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

  
    
```
{
  "ok": true,
  "data": { "dispatchId": "kf9...", "eventId": "evt_kf9..." }
}
```

  

{/* ── requests.sample ─────────────────────────────────────────────────────── */}

Build a sample request event for a form, without any real request. It is the exact envelope a <code>request\_\*</code> subscription created
with <a href="#webhooks-create">webhooks.create</a> receives, so connectors use it for field discovery. A completed sample carries the
same example answers <a href="#submissions-sample">submissions.sample</a> shows; an expired or canceled sample carries the request block
alone.

  
    Form ID.
  
  
    Which ending to sample, in the <code>webhooks.create</code> spelling. The envelope's <code>type</code> is the dotted form.
  

  
    
```
{
  "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" }
    }
  }
}
```

  

<p>
  The request block and the outcome are documented on the <a href="/requests/callbacks#payload">callbacks page</a>; the submission half on
  the <a href="/developers/webhooks-reference#payload">webhooks reference</a>. Sample ids are the fixed placeholders shown above and{' '}
  <code>test</code> is <code>true</code>, so a receiver can tell a sample from a live event.
</p>

{/* ── documents.create ────────────────────────────────────────────────────── */}

Reserve an upload for a file you will hand to one recipient through the form's [Documents block](/building-forms/documents-block). Bytes
never travel through this API: you get a presigned `PUT`, you upload, and `requests.create` verifies the object before the request exists.

  
    The form whose Documents block will show the file. Scopes the upload to that workspace.
  
  
    Display name the recipient sees (1–200 characters). Overridable per request.
  
  
    <code>application/pdf</code> or an image type: <code>image/png</code>, <code>image/jpeg</code>, <code>image/webp</code>,{' '}
    <code>image/gif</code>, <code>image/svg+xml</code>, <code>image/avif</code>, <code>image/bmp</code>, <code>image/tiff</code>. Office
    documents are not accepted.
  
  
    Exact byte length. Maximum 25 MB (26,214,400).
  
  
    Hex digest of the bytes. Verified after upload when given.
  

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

  

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

```
{
  "method": "requests.create",
  "params": {
    "formId": "j57...",
    "documents": [
      { "documentId": "kn7...", "name": "Your lease contract" },
      { "documentId": "kn8...", "field": "attachments" }
    ]
  }
}
```

<ul>
  <li>
    <code>field</code> is the Documents block's field key. Optional when the form has exactly one block; required with two or more.
  </li>
  <li>The block's authored documents stay; yours appear below them, for this one recipient.</li>
  <li>Caps: 25 MB per document, 100 MB of documents per request, 20 documents shown per block including the authored ones.</li>
  <li>
    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.
  </li>
</ul>

<p>
  Every failure here is <code>400 VALIDATION_ERROR</code> with a <code>details.reason</code>: <code>DOCUMENT_TYPE_NOT_ALLOWED</code>,{' '}
  <code>DOCUMENT_TOO_LARGE</code>, <code>INVALID_DOCUMENT_NAME</code> or <code>INVALID_DOCUMENT_SHA256</code> from this method, and{' '}
  <code>DOCUMENT_NOT_FOUND</code>, <code>DOCUMENT_NOT_UPLOADED</code> (you skipped the <code>PUT</code>), <code>DOCUMENT_INVALID</code>,{' '}
  <code>INVALID_DOCUMENT_TARGET</code>, <code>DOCUMENTS_TOO_LARGE</code> or <code>DOCUMENTS_TOO_MANY</code> from{' '}
  <code>requests.create</code>.
</p>

{/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}

<h2 id="webhooks" class="border-t border-border pt-8">
  Webhooks
</h2>

{/* ── webhooks.list ───────────────────────────────────────────────────────── */}

List webhook subscriptions for a form.

  
    Form ID.
  

  
    
```
{
  "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.

  
    Form ID.
  
  
    HTTPS URL to receive webhook payloads.
  
  
    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.
  
  
    Event type to subscribe to. The three <code>submission_*</code> types deliver the submission payload: <code>submission_created</code> a
    first submission, <code>submission_updated</code> a respondent's edit, and <code>submission_abandoned</code> an idle draft. The three{' '}
    <code>request_*</code> types deliver the matching <a href="/requests/callbacks#payload">request event</a> whenever a request on the form
    ends that way, signed with this subscription's secret; test requests reach no subscription.
  
  
    Required when <code>eventType</code> is <code>submission_abandoned</code>; rejected for every other type.
  
  
    Optional HMAC signing secret, 32–255 characters. When supplied, deliveries include <code>X-formbase-Signature</code>. Secret is stored
    but never returned by API.
  

  
```
curl -X POST https://api.formbase.so/api/v1 \
  -H "Authorization: Bearer fb_..." \
  -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"
    }
  }'
```

  
    
```
{
  "ok": true,
  "data": {
    "subscriptionId": "int_new123",
    "formId": "frm_abc123",
    "provider": "zapier",
    "targetUrl": "https://hooks.zapier.com/hooks/catch/...",
    "eventType": "submission_created"
  }
}
```

  

<p>
  Abandoned-submission subscriptions return the selected <code>idleWindow</code> from both <code>webhooks.create</code> and{' '}
  <code>webhooks.list</code>. Every other subscription omits it.
</p>

<p>
  A request subscription hears the same events a <a href="/requests/callbacks">callback</a> 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{' '}
  <code>callbackUrl</code> on a form with a <code>request_completed</code> subscription therefore fires twice, once to each receiver.{' '}
  <code>requests.replayCallback</code> re-sends the callback only. Use <a href="#requests-sample">requests.sample</a> to see the payload
  before any request has ended.
</p>

{/* ── webhooks.delete ─────────────────────────────────────────────────────── */}

Remove a webhook subscription.

  
    Subscription ID from <code>webhooks.list</code> or <code>webhooks.create</code>.
  

  
    
```
{
  "ok": true,
  "data": {
    "subscriptionId": "int_abc123",
    "deleted": true
  }
}
```

  

{/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}

<h2 id="analytics" class="border-t border-border pt-8">
  Analytics
</h2>

{/* ── 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.

  
    Form ID.
  
  
    Start of date range as Unix timestamp in milliseconds. Must be less than or equal to <code>to</code> when both are set.
  
  
    End of date range as Unix timestamp in milliseconds. Omit both for all time — <code>period</code> then comes back as{' '}
    <code>{`{ "from": null, "to": null }`}</code>.
  
  
    Filter by device type.
  
  
    Filter by traffic source (e.g. <code>"Direct"</code>, <code>"Google"</code>).
  
  
    Filter by 2-letter country code (e.g. <code>"US"</code>, <code>"DE"</code>).
  
  
    Also return the sanitized analytics events behind the metrics, for your own analysis. No visitor ids.
  

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

  
    
```
{
  "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": 17 },
      "byCountry": { "US": 10, "DE": 7 },
      "byDevice": { "desktop": 14, "mobile": 3 },
      "bySource": { "Direct": 12, "Google": 5 }
    }
  }
}
```

  

{/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}

<h2 id="workspaces" class="border-t border-border pt-8">
  Workspaces
</h2>

{/* ── 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.

  
    
```
{
  "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.

  
    Workspace ID.
  
  
    Expiration as a future Unix timestamp in milliseconds.
  
  
    Maximum number of times the invite can be used.
  

  
    
```
{
  "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`.

  
    Invite ID.
  

{/* ── workspaces.updateInvite ─────────────────────────────────────────────── */}

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

  
    Invite ID.
  
  
    New expiration timestamp in milliseconds.
  
  
    New max uses limit.
  

{/* ── workspaces.revokeInvite ─────────────────────────────────────────────── */}

Permanently revoke a workspace invite.

  
    Invite ID.
  

  
    
```
{
  "ok": true,
  "data": {
    "inviteId": "inv_abc123",
    "revoked": true
  }
}
```

  

{/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}

<h2 id="folders" class="border-t border-border pt-8">
  Folders
</h2>

{/* ── folders.list ────────────────────────────────────────────────────────── */}

List folders in a workspace.

  
    Workspace ID.
  
  
    Page size (1–100).
  
  
    Pagination cursor.
  

  
    
```
{
  "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.

  
    Workspace ID.
  
  
    Folder name (1–255 characters).
  
  
    Parent folder ID for nesting. Omit for root level.
  

  
    
```
{
  "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.

  
    Folder ID.
  
  
    New folder name (1–255 characters).
  
  
    New parent folder. Pass <code>null</code> to move to root.
  

{/* ── folders.delete ──────────────────────────────────────────────────────── */}

Permanently delete a folder and all its contents (subfolders and forms).

  
    Folder ID.
  

> ⚠️ **Destructive operation**
> <p>This permanently deletes all subfolders and forms inside the folder. This action cannot be undone.</p>

  
    
```
{
  "ok": true,
  "data": {
    "folderId": "fld_abc123",
    "deletedFolderIds": ["fld_abc123"],
    "deletedFormIds": ["frm_in_folder"],
    "message": "Folder and 1 form deleted."
  }
}
```

  

<h2 id="translations" class="border-t border-border pt-8">
  Translations
</h2>

{/* ── translations.listLanguages ──────────────────────────────────────────── */}

List all languages configured on a form.

  
    Form ID.
  

  
    
```
{
  "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.

  
    Form ID.
  
  
    BCP-47 language tag (e.g. <code>"es"</code>, <code>"pt-BR"</code>).
  

  
    
```
{
  "ok": true,
  "data": {
    "formId": "frm_abc123",
    "language": "es",
    "rowId": "tl_new123"
  }
}
```

  

{/* ── translations.removeLanguage ─────────────────────────────────────────── */}

Remove a language and all its translations from a form.

  
    Form ID.
  
  
    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.

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

  
    
```
{
  "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
  }
}
```

  

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

{/* ── translations.setEntry ───────────────────────────────────────────────── */}

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

  
    Form ID.
  
  
    BCP-47 language tag.
  
  
    A key from <code>translations.listEntries</code>. Do not construct one by hand.
  
  
    The translated fragment, JSON-stringified. Its mark structure must match the source fragment.
  

  
```
curl -X POST https://api.formbase.so/api/v1 \
  -H "Authorization: Bearer fb_..." \
  -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\"}]"
    }
  }'
```

> ⚠️ **Writes here are live**
> <p>
>     The API has no draft-then-publish step: a <code>setEntry</code> or <code>deleteEntry</code> reaches respondents immediately. The
>     dashboard and the MCP translation tools use a draft instead.
>   </p>

<p>
  Returns <code>{`{ formId, language, key }`}</code>.
</p>

{/* ── 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.

  
    Form ID.
  
  
    BCP-47 language tag.
  
  
    Translation key to delete.
  

{/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}

<h2 id="me" class="border-t border-border pt-8">
  Account
</h2>

{/* ── me.get ──────────────────────────────────────────────────────────────── */}

Get information about the authenticated user.

  
    
```
{
  "ok": true,
  "data": {
    "id": "usr_abc123",
    "email": "you@example.com",
    "name": "Jane Doe"
  }
}
```

  

{/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}

<h2 id="meta" class="border-t border-border pt-8">
  Meta
</h2>

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

  
    
```
{
  "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",
      "..."
    ]
  }
}
```

  

{/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}

<h2 id="error-reference" class="border-t border-border pt-8">
  Error reference
</h2>

Every error response has the same shape. The top-level `code` set is closed on purpose: a new failure mode never adds a code, it adds a
`reason`. Branch on `code` for the HTTP-level outcome and on `details.reason` for the fix.

```
{
  "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"] }
  }
}
```

<p>
  <code>details</code> is present whenever the server can name the cause. Besides <code>reason</code>, it may carry <code>field</code> (the
  offending parameter, dotted for nesting), <code>validKeys</code>, <code>validValues</code> (the option <strong>values</strong> a choice
  question accepts), <code>expectedType</code>, <code>feature</code> (on <code>UPGRADE_REQUIRED</code>), and <code>retryAfterMs</code> (on a
  throttled call). Reasons for the request surface are listed with each method above.
</p>

<p>These are all the codes:</p>

  
    Invalid or missing parameters in the request.
  
  
    Missing or invalid API token.
  
  
    Token lacks access to the requested resource.
  
  
    Resource does not exist.
  
  
    Unknown method name. Use <code>methods.list</code> to see available methods.
  
  
    The resource is not in a state that allows this call — a request that is no longer pending, an idempotency key reused with a different
    body.
  
  
    Over 120 calls a minute on this token, over 60 <code>requests.create</code> calls a minute, or too many failed authentications from this
    IP.
  
  
    Feature requires a higher subscription tier, the workspace has spent its monthly allowance (reason{' '}
    <code>MONTHLY_ALLOWANCE_REACHED</code>), or a Free account has spent its 10 free invitations (reason <code>FREE_INVITATIONS_USED</code>
    ).
  
  
    Unexpected server error. Try again later.
  

<h2 id="next-steps">Next steps</h2>
<div class="not-prose grid gap-3 sm:grid-cols-2">
  - [API tokens](/developers/api-tokens) — Create and manage tokens
  - [MCP server](/developers/mcp-server) — Use formbase from AI agents
  - [Webhooks reference](/developers/webhooks-reference) — Payload schema and signing
</div>


# API tokens

Create, manage, and rotate tokens for the API and MCP server.

## API tokens

An API token authenticates the REST API and the MCP server. It acts as you, inside one workspace. Treat it like a password.

<h2 id="format">Format</h2>
<p>
  A token is <code>fb_</code> followed by 32 alphanumeric characters — 35 in total. Send it as a bearer token:
</p>

```
Authorization: Bearer fb_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

<p>
  The same header works for <a href="/developers/rest-api">the API</a> and the <a href="/developers/mcp-server">MCP server</a>. Only a
  SHA-256 hash is stored, so a lost token cannot be recovered — create a new one.
</p>

<h2 id="limits">Limits</h2>
<ul>
  <li>
    <strong>10 tokens</strong> per person per workspace. Access tokens issued when you connect an OAuth app (<code>fbo_...</code>) sit on
    the same page but do not count.
  </li>
  <li>
    <strong>Expires 30 days after creation.</strong> There is no extend button — create a new token and delete the old one.
  </li>
  <li>
    <strong>One workspace.</strong> A token is bound to the workspace it was created in. A call that names another workspace, or a form in
    one, fails with <code>FORBIDDEN</code>, even when you are a member of both.
  </li>
  <li>
    <strong>No scopes.</strong> A token does everything you can do in that workspace. There is no read-only token.
  </li>
</ul>

> ℹ️ **Every plan has API access**
> <p>
>     Creating and using tokens is not gated by plan. Individual calls still are — scheduling request reminders needs Pro or Business, for
>     example, and answers with <code>UPGRADE_REQUIRED</code> otherwise.
>   </p>

<h2 id="create">Create a token</h2>

<p>
  The list then shows the name, the first 11 characters, when it was created, and when it expires. An expired token is marked{' '}
  <strong>Expired</strong> and stops authenticating.
</p>

<h2 id="rotate">Rotate a token</h2>

<h2 id="revoke">Revoke a token</h2>
<p>
  Deleting a token removes it permanently and it stops working on the next call — there is no grace period, and anything still using it
  starts getting <code>401 UNAUTHORIZED</code>. You can also rename a token with the pencil icon (up to 64 characters); renaming does not
  change its value.
</p>
<p>
  Only you see your own tokens — workspace admins cannot list or delete them. Leaving the workspace, or being removed from it, deletes them
  for you.
</p>

> ⚠️ **A token is full access**
> <p>Anyone holding it can act as you inside that workspace. If one leaks, delete it first and investigate second.</p>

<h2 id="oauth">OAuth instead</h2>
<p>
  A third-party app connecting on behalf of a user should use the OAuth flow instead of asking for a token. It gets an <code>fbo_...</code>{' '}
  access token, valid one hour and refreshable for 30 days, bound to the one workspace the user picked at consent. The user sees and
  disconnects those apps in <strong>Connected apps</strong> on the same page. See <a href="/developers/mcp-server#oauth">MCP server</a>.
</p>

<h2 id="next-steps">Next steps</h2>
<div class="not-prose grid gap-3 sm:grid-cols-2">
  - [MCP server](/developers/mcp-server) — Use formbase from AI agents
  - [Webhooks reference](/developers/webhooks-reference) — Payload schema and signing
</div>


# MCP server

Use formbase from Claude, Cursor, and other MCP-compatible tools.

## MCP server

The Model Context Protocol (MCP) server lets AI agents read and edit your formbase forms with rich, schema-driven tools.

<h2 id="what">What it is</h2>
<p>
  MCP is an open standard for AI tools to connect to external services. formbase exposes a hosted MCP endpoint that any MCP-compatible
  client can connect to, including Claude Code, Claude desktop, and Cursor.
</p>

<h2 id="connection">Connection</h2>

```
URL:  https://api.formbase.so/api/mcp   (POST, streamable HTTP)
Auth: Bearer <token>
```

<p>Two kinds of bearer token work:</p>
<ul>
  <li>
    <strong>API token</strong> (<code>fb_...</code>) — created from <a href="/developers/api-tokens">API tokens</a>. Best for personal use
    and quick setup.
  </li>
  <li>
    <strong>OAuth access token</strong> (<code>fbo_...</code>) — issued by the <a href="#oauth">OAuth flow</a>. Best for third-party apps
    connecting on behalf of a user.
  </li>
</ul>
<p>
  Both are bound to exactly one workspace and reach the same tools. A tool call that names another workspace, or a form in one, fails with{' '}
  <code>FORBIDDEN</code>. OAuth tokens also carry scopes (<code>mcp:read</code>, <code>mcp:write</code>, <code>offline_access</code>), but
  no tool is gated on them today — treat any token as full access inside its workspace.
</p>
<p>
  Tool calls are limited to 120 per minute per token, shared with the <a href="/developers/rest-api">API</a>: only <code>tools/call</code>{' '}
  spends the budget, while <code>initialize</code>, <code>tools/list</code>, <code>prompts/*</code> and <code>resources/*</code> are free.
  Over budget, the call still returns HTTP 200 with a failed tool result carrying <code>RATE_LIMITED</code> and a <code>retryAfterMs</code>{' '}
  — poll on a timer, never in a loop.
</p>

> 💡 **Where to get a token**
> <p>
>     Open <strong>OAuth and API Keys</strong> in your workspace sidebar to create API tokens and view connected OAuth apps. See{' '}
>     <a href="/developers/api-tokens">API tokens</a> for a step-by-step guide.
>   </p>

<h2 id="core-tools">Core tools</h2>
<p>
  Every tool is advertised on <code>tools/list</code> when a client connects. Clients that load schemas on demand, like Claude Code, fetch a
  tool's full schema when a task needs it. The table below covers the core tools most tasks start with; <code>load_tools</code> (catalogs)
  and <code>load_skill</code> (domain guides) document the rest.
</p>

<p>
  More insert variants — time, file upload, signature, payment, matrix/grid, ranking, picture choice, toggle switch, table, list, row,
  calculated field, hidden field, inline variable, embedded content (<code>editor_insertEmbedded</code> for YouTube, Google Maps, or iframe
  embeds), and a conditional-logic block (<code>editor_insertLogic</code>) — are on <code>tools/list</code> too. Load{' '}
  <code>load_skill("question-types")</code> for the full set, each with its tool name and fields. Conditional logic is authored with{' '}
  <code>editor_setLogic</code> in the <a href="#tool-catalogs">editor-actions</a> catalog.
</p>

<h2 id="tool-catalogs">Tool catalogs</h2>
<p>
  These tools are on <code>tools/list</code> as well. Run <code>load_tools</code> with a catalog name to get enriched documentation (intro,
  full schemas, usage patterns, edge cases) for the grouped tools, then call them directly.
</p>

<p>
  The <code>request-lifecycle</code> catalog lists all eight request tools because the in-app chat advertises a smaller core set. Over this
  endpoint all eight are already on <code>tools/list</code>, so what the catalog adds is documentation.
</p>

<h2 id="skills">Skills (domain knowledge)</h2>
<p>
  Skills are built-in guides the agent can load via <code>load_skill</code>. They provide domain knowledge that helps the agent make better
  decisions — not tool schemas, but design advice and field semantics.
</p>

<h2 id="requests">Requests</h2>

<p>
  A <a href="/requests/overview">request</a> assigns one published form to one named recipient, with its own link, its own prefilled
  answers, and its own outcome. It is how an agent asks a real person for something and finds out what they said.
</p>

<h3 id="requests-create">Creating a request</h3>

<p>
  Always start with <code>fields_list(formId)</code>. It returns the addressable keys of the form's current published version, each with a{' '}
  <code>usage</code> line saying which argument the key belongs in — visible questions go in <code>prefill</code>, hidden fields in{' '}
  <code>context</code>. Never derive a key from a question title, and re-read after <code>form_publish</code>.
</p>

```
{
  "formId": "j57...",
  "recipient": { "email": "ada@acme.com", "name": "Ada" },
  "prefill": { "company_name": "Acme", "plan": "pro" },
  "readonly": ["company_name"],
  "context": { "crm_id": "A-42" },
  "metadata": { "run_id": "exec_918" },
  "delivery": "email",
  "expiresAt": 1780000000000,
  "callbackUrl": "https://hooks.acme.com/formbase",
  "idempotencyKey": "po-42"
}
```

<p>The result carries the link and the clock:</p>

```
{
  "id": "kd7...",
  "status": "pending",
  "url": "https://form.formbase.so/r/rq_...",
  "deliveryStatus": "queued",
  "expiresAt": 1780000000000,
  "createdAt": 1747000000000,
  "deduplicated": false,
  "next": "..."
}
```

<p>
  <code>delivery</code> defaults to <code>"none"</code>, which hands you <code>url</code> to deliver yourself; <code>"email"</code> sends
  the invitation and needs <code>recipient.email</code> on a Pro or Business plan, or one of a Free account's 10 free invitations.{' '}
  <code>readonly</code> locks fields the recipient may not edit, and every locked key must also be prefilled. <code>context</code> only
  takes hidden-field keys, while <code>metadata</code> is opaque bookkeeping echoed back on <code>request_get</code> and in the callback.{' '}
  <code>expiresAt</code> is epoch milliseconds, defaulting to 30 days out and capped at 365 days. <code>idempotencyKey</code> is
  workspace-scoped for 30 days: the same key with the same body returns the original request with <code>deduplicated: true</code>, and a
  different body is a conflict. Every response also carries a <code>next</code> line telling the agent what to do from here.
</p>
<p>
  <code>deliveryStatus</code> is <code>not_requested</code> until an invitation is queued, then <code>queued</code> → <code>sent</code> or{' '}
  <code>failed</code>, and <code>bounced</code> once the mail provider reports a hard bounce or a complaint. Creating a request spends one
  unit of the workspace&apos;s monthly allowance whether or not the recipient ever answers; once it is gone, <code>request_create</code>{' '}
  fails with <code>MONTHLY_ALLOWANCE_REACHED</code>.
</p>

<h3 id="requests-callbacks">Callbacks or polling</h3>

<p>
  With a <code>callbackUrl</code>, formbase POSTs once per terminal event — completion, expiry, cancellation — signed with the workspace
  request signing secret. See <a href="/requests/callbacks">Callbacks and signing</a> for the payload and the verification recipe.
</p>

> ℹ️ **Autonomous agents should poll**
> <p>
>     The request signing secret only ever appears on the Credentials page in your workspace — it is never returned over MCP or the API. An
>     agent running on its own, with no human to stand up and configure a receiver, therefore cannot verify a callback. Leave{' '}
>     <code>callbackUrl</code> off and poll <code>request_get(requestId)</code> instead, on the order of minutes rather than seconds, until{' '}
>     <code>status</code> leaves <code>"pending"</code>. <code>expiresAt</code> bounds how long that is worth doing.
>   </p>

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

<p>
  Pass <code>test: true</code> to rehearse the whole shape before a real run. The link still opens and can be completed, and the callback
  fires with <code>"test": true</code> — but nothing is emailed whatever <code>delivery</code> says, the request stays hidden from the
  Requests page and the analytics funnel, and its submission counts nowhere: no quota, no exports, no integrations. Test requests only
  appear in <code>request_list</code> when you pass <code>includeTest: true</code>. The link closes within 24 hours, and on Free a workspace
  may create 10 test requests a day.
</p>

<h3 id="requests-documents">Per-request documents</h3>

<p>
  To hand one recipient a file — a contract draft, their own quote — the form needs a <strong>Documents block</strong>, which an author or
  an agent inserts with <code>editor_insertDocumentsBlock</code>. <code>fields_list</code> reports it as <code>type: "documents"</code>. The
  bytes never travel through a tool:
</p>

<ol>
  <li>
    Call <code>document_create</code> with <code>formId</code>, <code>name</code>, <code>contentType</code>, and the exact <code>size</code>{' '}
    in bytes. You get back <code>{'{ id, name, contentType, size, uploadUrl, expiresAt }'}</code>. PDF and images only (no Office
    documents), 25 MB per file, and 100 MB of documents per request.
  </li>
  <li>
    <code>PUT</code> the raw bytes to <code>uploadUrl</code> within the hour, with <code>Content-Type</code> set to the type you declared.
  </li>
  <li>
    Reference it from <code>request_create</code>: <code>documents: [{'{ documentId, field?, name? }'}]</code>. <code>field</code> is the
    Documents block's field key, optional only when the form has exactly one such block. <code>name</code> overrides the display name for
    this request.
  </li>
</ol>

<p>
  The block's authored documents stay put and yours appear below them, for this recipient only. <code>request_create</code> verifies the
  upload before the request exists, so <code>DOCUMENT_NOT_UPLOADED</code> means step 2 was skipped. One upload can be referenced by any
  number of requests, and its bytes count toward your workspace storage.
</p>

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

<p>
  Pass <code>domainId</code> to <code>request_create</code> to mint the link on one of the workspace's{' '}
  <a href="/branding-domains/custom-domains">custom domains</a>. The ids come from <code>formShareLink_list</code>, which returns them as{' '}
  <code>availableCustomDomains</code>. Omit it and the link takes whatever domain the form is already published under.
</p>

<h3 id="requests-reading">Reading results</h3>

<p>
  <code>request_get</code> returns the whole request. Once it is completed, <code>answers</code> holds the recipient's values keyed by field
  key, <code>display</code> the same keys as readable text, and <code>outcome</code> — approve, decline, or changes — is their verdict when
  the form has a <a href="/requests/overview">decision question</a>. A <code>callbackFailedAt</code> timestamp means delivery ran out of
  retries and nothing reached your endpoint; fix the receiver, then call <code>request_replayCallback</code>, which re-sends the original
  event id so your receiver dedupes instead of re-running. After the form's retention policy strips a request, <code>dataPurgedAt</code> is
  set and the answers are gone for good.
</p>

<p>
  <code>request_list</code> sweeps many at once, filtered by <code>status</code>, <code>outcome</code>, <code>externalId</code>, and{' '}
  <code>includeTest</code>. Page with <code>nextCursor</code>: a page can rarely come back with an empty <code>items</code> and{' '}
  <code>hasMore: true</code>, which is not the end of the list — pass the cursor back and keep going.
</p>

<h2 id="resources">Resources and prompts</h2>
<p>
  Every skill and tool catalog is also an MCP resource at <code>skill://&lt;name&gt;</code> — <code>skill://requests</code>,{' '}
  <code>skill://editor-inserts</code>. A client that supports <code>resources/list</code> can browse and read them without calling{' '}
  <code>load_skill</code> or <code>load_tools</code>. The server also serves four prompts on <code>prompts/list</code>:{' '}
  <code>identity</code>, <code>capabilities</code>, <code>data_tools</code>, and <code>editor_tools</code>.
</p>

<h2 id="confirmation">Tools that ask first</h2>
<p>
  Every tool carries the MCP hints <code>readOnlyHint</code> and <code>destructiveHint</code>, derived from its verb. These tools are marked
  destructive, because undoing them takes another call or is not possible: <code>form_delete</code>, <code>form_unpublish</code>,{' '}
  <code>workspaceFolder_delete</code>, <code>editor_deleteElement</code>, <code>translationLanguage_delete</code>, and{' '}
  <code>request_cancel</code>. Most clients ask the user before running them, but the prompt is the client's decision, so check its approval
  settings if you need a hard stop.
</p>

<h2 id="limitations">Limitations</h2>
<ul>
  <li>
    <strong>No binary uploads through a tool call.</strong> Images are set by URL: covers, logos, and image blocks accept{' '}
    <code>http(s)://</code> or <code>data:image</code> URIs. A per-request document is the exception: <code>document_create</code> returns
    an upload URL that a client with HTTP access can <code>PUT</code> the file to, as{' '}
    <a href="#requests-documents">Per-request documents</a> explains. To turn a PDF or screenshot into a form, use the{' '}
    <a href="/ai/ai-form-generation#files">built-in AI chat</a>.
  </li>
  <li>
    <strong>No workspace AI skills.</strong> <a href="/ai/ai-skills">Skills written in formbase</a> are only available in the built-in AI
    chat. The server's own skills (<code>load_skill</code>) are available over MCP.
  </li>
</ul>

<h2 id="api-token-clients">Connecting with an API token</h2>
<p>
  Most clients sign in with OAuth: add the URL with no header and follow <a href="/guides/ai-agents/connect">Connect an AI agent</a>. A
  client that cannot open a browser, such as a script, CI job, or headless agent, sends an <a href="/developers/api-tokens">API token</a> as
  a header instead.
</p>
<p>Claude Code, from the command line:</p>

```
claude mcp add --transport http formbase https://api.formbase.so/api/mcp \
  --header "Authorization: Bearer fb_YOUR_TOKEN"
```

<p>
  Or in a project's <code>.mcp.json</code>:
</p>

```
{
  "mcpServers": {
    "formbase": {
      "type": "http",
      "url": "https://api.formbase.so/api/mcp",
      "headers": {
        "Authorization": "Bearer fb_YOUR_TOKEN"
      }
    }
  }
}
```

<p>
  Cursor, in <code>.cursor/mcp.json</code> or <code>~/.cursor/mcp.json</code>:
</p>

```
{
  "mcpServers": {
    "formbase": {
      "url": "https://api.formbase.so/api/mcp",
      "headers": {
        "Authorization": "Bearer fb_YOUR_TOKEN"
      }
    }
  }
}
```

<p>
  To sign in with OAuth instead of a token, use{' '}
  <a href="https://cursor.com/install-mcp?name=formbase&config=eyJ1cmwiOiJodHRwczovL2FwaS5mb3JtYmFzZS5zby9hcGkvbWNwIn0=">Add to Cursor</a>.
  It adds the server URL with no header, and Cursor asks you to sign in to formbase.
</p>
<p>Other clients take the same URL and header; see their documentation for where.</p>

<h2 id="oauth">Using OAuth instead of API tokens</h2>
<p>
  A third-party app connecting on behalf of a user should use OAuth rather than ask for a pasted token. formbase is an OAuth 2.1
  authorization server with mandatory PKCE (S256) and opaque tokens — no JWTs, no implicit grant. Claude desktop, Claude Code, and the
  Claude.ai web connector discover all of it from the MCP endpoint, so pasting the URL with no header is enough: the 401 points at{' '}
  <code>/.well-known/oauth-protected-resource</code>, and the client takes it from there.
</p>
<p>The flow, for a client you are writing yourself:</p>
<ol>
  <li>
    <code>GET /.well-known/oauth-protected-resource</code>, then <code>GET /.well-known/oauth-authorization-server</code> for the endpoint
    URLs, scopes, and supported auth methods.
  </li>
  <li>
    <code>POST /oauth/register</code> with your <code>redirect_uris</code> (dynamic client registration, no credentials needed). You get a{' '}
    <code>client_id</code>, plus a <code>client_secret</code> if you asked for anything other than{' '}
    <code>token_endpoint_auth_method: "none"</code>. Redirect URIs must be HTTPS, or HTTP on <code>localhost</code>. Registration is capped
    at 20 per hour per IP.
  </li>
  <li>
    Send the user to <code>/oauth/authorize</code> with <code>response_type=code</code>, your <code>client_id</code>, the registered{' '}
    <code>redirect_uri</code>, <code>scope=mcp:read mcp:write offline_access</code>, <code>state</code>, and a <code>code_challenge</code>{' '}
    with <code>code_challenge_method=S256</code>. They sign in, pick one workspace, and authorize.
  </li>
  <li>
    Exchange the code at <code>POST /oauth/token</code> with <code>grant_type=authorization_code</code> and your <code>code_verifier</code>,
    within 60 seconds. Codes are single use.
  </li>
  <li>
    Call the MCP endpoint with <code>Authorization: Bearer fbo_...</code>. Access tokens last 1 hour; refresh tokens last 30 days and rotate
    on every use. Reusing a spent refresh token burns the whole chain, so store the newest one.
  </li>
</ol>
<p>
  <code>POST /oauth/revoke</code> (RFC 7009) revokes an access or refresh token. A user can also disconnect the whole app from{' '}
  <strong>Connected apps</strong> on the OAuth and API Keys page, which kills every token it holds for that workspace.
</p>

<p>If you do have a token in hand, it goes in the same place as an API key:</p>

```
{
  "mcpServers": {
    "formbase": {
      "type": "http",
      "url": "https://api.formbase.so/api/mcp",
      "headers": {
        "Authorization": "Bearer fbo_USER_OAUTH_TOKEN"
      }
    }
  }
}
```

<h3 id="connected-apps">Connected apps</h3>
<p>
  Every OAuth connection is listed under <strong>Connected apps</strong> on the <strong>OAuth and API Keys</strong> page, with when it was
  connected and last used. Connections are personal: only the user who authorized one sees it, and workspace admins cannot view or revoke
  another member's. Disconnecting takes effect at once. A user who leaves or is removed from the workspace loses all their connections to
  it.
</p>

<h2 id="next-steps">Next steps</h2>
<div class="not-prose grid gap-3 sm:grid-cols-2">
  - [Connect an AI agent](/guides/ai-agents/connect) — Step-by-step setup in any MCP client
  - [Webhooks reference](/developers/webhooks-reference) — Payload schema and signing
  - [REST API](/developers/rest-api) — API methods for programmatic access
  - [Plans & pricing](/subscription-billing/plans-pricing) — Compare plan API access and limits
</div>


# Webhook API reference

Payload schema, event types, signing, retries, and REST subscription endpoints.

## Webhook API reference

Payload schema, event types, signing, retry behavior, and the REST endpoints any automation tool subscribes through.

> ℹ️ **Looking for the setup guide?**
> <p>
>     This page documents the payload contract and REST subscription API. To set up a custom webhook for your form in the UI, see{' '}
>     <a href="/integrations/webhooks">Custom webhooks</a>.
>   </p>

<h2 id="request">Request</h2>
<p>
  <code>POST &lt;your-url&gt;</code> with <code>Content-Type: application/json</code>.
</p>

<h3 id="headers">Headers</h3>
<ul>
  <li>
    <code>Content-Type: application/json</code>
  </li>
  <li>
    <code>X-formbase-Signature: t=&#123;timestamp&#125;,sha256=&#123;hex&#125;</code> — present when a signing secret is set up (see below)
  </li>
  <li>
    <code>X-formbase-Event-Id</code> and <code>X-formbase-Event-Type</code> — the same values as <code>id</code> and <code>type</code> in
    the body, so you can dedupe and route before parsing. A <a href="/requests/callbacks">request callback</a> sends the same two.
  </li>
  <li>
    Any custom headers you add during setup. They are merged in as given, except <code>Content-Type</code>, which cannot be overridden.
    Native subscriptions created through <code>webhooks.create</code> have no custom headers.
  </li>
</ul>

<h2 id="event-types">Event types</h2>

<h3 id="subscription-events">Subscription events</h3>
<p>When you set up a webhook integration, you choose which event triggers deliveries:</p>

<p>
  The three <code>request_*</code> events are subscribed through <code>webhooks.create</code> and are what the formbase apps for Zapier,
  Make and n8n listen on. Each delivers the same envelope a <a href="/requests/callbacks">request callback</a> sends, signed with the
  subscription's own secret. Test requests never reach a subscription, and <code>requests.replayCallback</code> re-sends the callback only.
  One subscription, one event: <code>submission_created</code> is share-link traffic and <code>request_completed</code> is request traffic,
  so a completed request never fires <code>submission_created</code> and a form with both subscriptions receives one delivery per
  completion. An edit reaches a <code>submission_updated</code> subscription alone, never a <code>submission_created</code> one. The webhook
  you configure in Form settings has no event choice: it receives first submissions and edits alike, told apart by <code>type</code>.
</p>

<h3 id="payload-event-types">Payload event types</h3>
<p>
  The <code>type</code> field in the JSON body tells you what happened:
</p>
<ul>
  <li>
    <code>submission.completed</code> — a new completed submission
  </li>
  <li>
    <code>submission.updated</code> — an existing submission was edited
  </li>
  <li>
    <code>submission.abandoned</code> — a draft submission was abandoned after the configured idle window
  </li>
</ul>
<p>
  A <a href="/requests/callbacks">request callback</a> and a <code>request_*</code> subscription use the same envelope with{' '}
  <code>request.completed</code>, <code>request.expired</code> and <code>request.canceled</code>, so one parser reads all six.
</p>

<h2 id="payload">Payload shape</h2>

```
{
  "id": "evt_abc123",
  "type": "submission.completed",
  "createdAt": "2026-04-25T12:34:56.000Z",
  "apiVersion": "2026-09-24",
  "test": false,
  "data": {
    "form": { "id": "frm_...", "name": "Contact", "snapshotId": "snp_..." },
    "submission": {
      "id": "sub_...",
      "respondentEmail": "alice@example.com",
      "submittedAt": "2026-04-25T12:34:56.000Z",
      "updatedAt": null,
      "editCount": 0,
      "pdfUrl": null,
      "language": "en"
    },
    "answers": {
      "email": "alice@example.com",
      "plan": "pro",
      "attendees": [{ "attendee_name": "Grace Hopper" }, { "attendee_name": "Alan Turing" }]
    },
    "display": {
      "email": "alice@example.com",
      "plan": "Pro",
      "attendees": "Grace Hopper, Alan Turing"
    }
  }
}
```

<h3 id="fields-vs-answers">answers and display</h3>
<p>
  <code>answers</code> is the flat <code>{'{ field key: value }'}</code> object, keyed by the field keys frozen at publish. Read it when a
  workflow branches or stores a value: <code>answers.email</code>, no array to walk. A choice answer is the chosen option's{' '}
  <strong>key</strong> — the <code>key</code> that <code>fields.list</code> lists for that option — so it is the same whatever language the
  respondent answered in. A date is an ISO string, a number a number, a multi-select an array of option keys. Unanswered fields are left
  out, never sent as <code>null</code>.
</p>
<p>
  <code>display</code> carries the same keys with human-readable text: the option label instead of its key, a formatted date, a joined list.
  Read it when a person will see the value — a Slack message, a spreadsheet cell, an email.
</p>
<p>
  A repeating group appears in <code>answers</code> once, under the group's own field key, as an array of row objects keyed by each member's
  field key — <code>answers.attendees[0].attendee_name</code> above — and in <code>display</code> as one line with the rows joined. A member
  is never hoisted to the top level. <a href="/building-forms/calculated-fields">Calculated fields</a> appear in both maps under the
  calculated field's name as its key (<code>answers.total</code>).
</p>
<h3 id="bookings-and-payments">Bookings and payments</h3>
<p>
  A Schedule appointment question and a Payment question each carry an object in <code>answers</code>, under the question's field key, and
  one line of text in <code>display</code>. The times are ISO instants, so a spreadsheet or a workflow can parse them whatever language the
  respondent answered in:
</p>

```
{
  "book_a_call": {
    "status": "confirmed",
    "start": "2026-09-29T07:00:00.000Z",
    "end": "2026-09-29T07:30:00.000Z",
    "timeZone": "Europe/Oslo",
    "attendee": { "name": "Grace Hopper", "email": "grace@example.com" },
    "meetingUrl": "https://app.cal.com/video/...",
    "provider": "cal.com",
    "providerBookingId": "...",
    "eventTitle": "Intro call"
  },
  "pay_the_fee": {
    "status": "paid",
    "amount": 40,
    "currency": "USD",
    "amountRefunded": 0,
    "receiptUrl": "https://pay.stripe.com/receipts/...",
    "paidAt": "2026-09-24T10:12:00.000Z",
    "refundedAt": null,
    "disputedAt": null,
    "provider": "stripe",
    "providerPaymentIntentId": "pi_..."
  }
}
```

<p>
  A booking's <code>status</code> is <code>confirmed</code>, <code>rescheduled</code>, <code>cancelled</code>, <code>rejected</code> or{' '}
  <code>no_show</code>. A payment's is <code>paid</code>, <code>partially_refunded</code>, <code>refunded</code> or <code>disputed</code>,
  and <code>amount</code> is in the currency's main unit: <code>40</code> is $40.00. When Cal.com moves a booking or Stripe refunds a
  payment after the submission, formbase updates the object, so <code>submissions.list</code> and later events show the current state; no
  new event is sent for the change.
</p>
<p>
  Before <code>apiVersion</code> <code>2026-09-24</code>, a booking was sent as one sentence in <code>answers</code> and a payment was not
  sent at all.
</p>

<p>
  The webhook's field mapping applies to both maps at once: choose "selected" fields and the others are left out; rename a field's column
  and the new name is its key in <code>answers</code> and <code>display</code> alike. A field the form published before field keys existed
  goes out under its element id; publish the form again to give it a readable key.
</p>

<h3 id="schema">Field titles and types</h3>
<p>
  The event does not repeat each field's title and type. Read them from <code>fields.list</code>, which is stable per{' '}
  <code>data.form.snapshotId</code>, so you can cache the field list and refetch only when the snapshot id changes. A receiver that cannot
  make a second call can turn on <strong>Send the field list with every event</strong> in the webhook's settings; the event then carries{' '}
  <code>data.schema</code>, one entry per field. A choice question lists its <code>options</code> and a matrix its <code>rows</code> and{' '}
  <code>columns</code>, each as <code>{'{ key, label }'}</code>, so the keys in <code>answers</code> resolve to labels without a second
  call:
</p>

```
[
  { "key": "email", "title": "Email", "type": "email", "group": null },
  { "key": "plan", "title": "Plan", "type": "radio", "group": null, "options": [{ "key": "free", "label": "Free" }, { "key": "pro", "label": "Pro" }] },
  { "key": "attendee_name", "title": "Attendee name", "type": "text", "group": "attendees" }
]
```

<h3 id="request-block">The request block</h3>
<p>
  On the <a href="/integrations/webhooks">custom webhook</a> configured in form settings, a submission that answered a{' '}
  <a href="/requests/overview">request</a> carries one extra object inside <code>data</code>, <code>request</code>. It is absent on every
  public-link submission, so its presence is how that receiver tells the two channels apart. A Zapier, Make or n8n subscription never sees
  it: request traffic reaches a subscription as <code>request.completed</code>, which carries the full request block.
</p>

```
"request": {
  "id": "kd7...",
  "externalId": "run-42",
  "metadata": { "crm_record": "rec_88" }
}
```

<ul>
  <li>
    <code>id</code> — the request this submission answered. Pass it to <code>requests.get</code> for the full picture.
  </li>
  <li>
    <code>externalId</code> and <code>metadata</code> — your own bookkeeping, exactly as you supplied it on <code>requests.create</code>.
    Each is present only when it was set.
  </li>
</ul>

> ℹ️ **Webhooks are not callbacks**
> <p>
>     A submission webhook fires on a submission; the request block only names the request it answered. A{' '}
>     <a href="/requests/callbacks">callback</a> fires when a request ends — completed, expired, or canceled — and carries{' '}
>     <code>context</code> and <code>outcome</code>. Expiry and cancellation have no submission, so no submission webhook ever fires for them.
>     To hear a request end without a callback URL, subscribe to <code>request_completed</code>, <code>request_expired</code> or{' '}
>     <code>request_canceled</code> through <code>webhooks.create</code>.
>   </p>

<h3 id="submission-pdf">Submission PDF</h3>
<p>
  <code>data.submission.pdfUrl</code> is a link to the submission PDF. A form with a custom webhook or a Zapier, Make or n8n subscription
  keeps a PDF of every submission, so their events carry the link. It is <code>null</code> only when the submission has no PDF.
</p>

<h3 id="submission-language">Submission language</h3>
<p>
  <code>data.submission.language</code> is the BCP-47 code of the language the respondent submitted in (for translated forms). It is{' '}
  <code>null</code> for single-language forms. Use it to route or branch on the respondent's language without a separate lookup.
</p>

<h2 id="abandoned-submissions">Abandoned submission payloads</h2>
<p>
  When you subscribe to <code>submission_abandoned</code>, formbase checks for idle drafts every hour. If a draft has been inactive past the
  configured idle window, formbase fires a delivery.
</p>
<p>
  Native API integrations must send <code>idleWindow</code> to <code>webhooks.create</code>. Accepted values are <code>12h</code>,{' '}
  <code>1d</code>, <code>3d</code>, and <code>1w</code>. There is no implicit default; an abandoned subscription without a value is
  rejected.
</p>

<p>The payload shape is identical to a completed submission. Two differences:</p>
<ul>
  <li>
    <strong>Answers may be sparse</strong> — only questions the respondent answered appear in <code>answers</code> and <code>display</code>.
  </li>
  <li>
    <strong>
      <code>submittedAt</code>
    </strong>{' '}
    — falls back to the dispatch timestamp since the respondent never formally submitted.
  </li>
</ul>
<p>Each integration fires at most once per abandoned draft. After delivery, the draft is excluded from future sweeps.</p>

<h2 id="signing">Signing</h2>
<p>
  Each webhook has its own signing secret — set in the UI when you configure a custom webhook, or with the optional{' '}
  <code>signingSecret</code> parameter (32–255 characters) on <code>webhooks.create</code>. It is not the workspace request signing secret
  used for <a href="/requests/callbacks">request callbacks</a>, but the header and the algorithm are identical, so one verifier handles
  both.
</p>
<p>
  Every signed delivery carries <code>X-formbase-Signature: t=&#123;seconds&#125;,sha256=&#123;hex&#125;</code>. The digest is an
  HMAC-SHA256 of <code>&#123;t&#125;.&#123;raw body&#125;</code>, keyed with your secret. Two rules: hash the <strong>raw</strong> body
  before any parsing or re-serializing, and compare in constant time.
</p>

```
import crypto from 'node:crypto'

  if (!header) return false
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2)))
  if (!parts.t || !parts.sha256) return false

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_webhook(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
```

<p>
  formbase does not enforce a replay window, so the tolerance above is yours to choose. Changing the secret takes effect on the next
  attempt, including retries already in flight — update your receiver first.
</p>

> ⚠️ **Always verify in production**
> <p>Without verification, anyone who discovers your URL can post fake submissions.</p>

<h2 id="retries">Retries</h2>
<p>
  formbase makes up to 5 attempts per delivery — 1 initial and 4 retries — at least 1, 2, 4, and 8 minutes apart. formbase looks for due
  retries every 30 minutes, so a retry can come up to half an hour after its backoff ends, and the last attempt about two hours after the
  first. A <code>Retry-After</code> header on your response is honored when it asks for longer than the next backoff step. A delivery counts
  as failed if your endpoint:
</p>
<ul>
  <li>Returns a non-2xx status</li>
  <li>Times out</li>
  <li>Resets the connection</li>
</ul>
<p>
  One case is never retried: a destination that is blocked, unresolvable, or resolves to a private address. The URL is revalidated — DNS
  included — immediately before every attempt, so a host that stops being allowed fails the delivery at once rather than burning the budget.
</p>
<p>
  After 5 consecutive failed deliveries, the integration is paused. Fix the endpoint and re-enable it from Form settings → Integrations; a
  successful delivery resets the counter.
</p>
<p>
  Retried deliveries of the same event reuse the same <code>id</code>, so deduplicate by storing processed ids. A genuinely new event — a
  respondent editing their submission, say — arrives with a fresh <code>id</code> and <code>type: "submission.updated"</code>: at the Form
  settings webhook, or at a <code>submission_updated</code> subscription. The <code>createdAt</code> is when the event was queued, not when
  the attempt was made, so it stays the same across retries too. To tell edits apart, read <code>data.submission.editCount</code>: it counts
  up with each edit, and <code>data.submission.updatedAt</code> says when the latest one happened.
</p>

<h2 id="testing">Testing</h2>
<p>
  The integration setup and detail panel both have a <strong>Send test</strong> button. It fires a sample of the subscribed event to your
  URL so you can verify the connection without waiting for a real submission or request. The same samples are available over the API as{' '}
  <code>submissions.sample</code> and <code>requests.sample</code>.
</p>
<p>For local development, expose your dev server with a tunnel:</p>

```
# ngrok
ngrok http 3000

# cloudflare tunnel

cloudflared tunnel --url http://localhost:3000
```

> 💡 **Local development**
> <p>Use the tunnel URL as your webhook endpoint, then hit Send test to verify end-to-end.</p>

<h2 id="next-steps">Next steps</h2>
<div class="not-prose grid gap-3 sm:grid-cols-2">
  - [Webhooks setup](/integrations/webhooks) — Configure webhooks for your form
  - [Plans & pricing](/subscription-billing/plans-pricing) — Compare plan API features and limits
</div>

