# formbase Docs — Guides

# Guides

Step-by-step setup guides for the tools that drive formbase: connect, pick a trigger or action, map fields, test.

## Guides

One job in one tool, click by click. The reference pages explain what each feature is; a guide shows how to set it up.

<p>
  Each guide was walked through in the real tool against a test workspace, and shows the day it was last checked. When a step needs the
  details of a payload or a setting, the guide links to the reference page instead of repeating it.
</p>

<h2 id="zapier">Zapier</h2>

<p>
  The <a href="https://zapier.com/apps/formbase/integrations">formbase app for Zapier</a> listens for submissions and request outcomes, and
  creates, finds, reminds and cancels requests. Start with connecting your workspace; every other guide assumes it is done.
</p>

<div class="not-prose grid gap-3 sm:grid-cols-2 mb-8">
  - [Connect formbase to Zapier](/guides/zapier/connect) — Sign in, pick the workspace, and see your forms in Zapier.
  - [Send public link submissions to another app](/guides/zapier/public-link-submissions) — New, edited or abandoned submissions to email, Sheets, Slack or a CRM.
  - [Send a request from a Zap](/guides/zapier/send-a-request) — Assign a form to one recipient, prefilled and with locked fields.
  - [Act on a request's outcome](/guides/zapier/request-outcome) — Run the next step when a recipient approves, declines or asks for changes.
  - [Look up, remind and cancel requests](/guides/zapier/manage-requests) — Look a request up by your own ID and act on it.
</div>

<h2 id="n8n">n8n</h2>

<p>
  The formbase community node for self-hosted n8n does the same as the Zapier app: it listens for submissions and request outcomes, and
  creates, finds, reminds and cancels requests. It can also pause a workflow until the recipient answers. Start with installing the node.
</p>

<div class="not-prose grid gap-3 sm:grid-cols-2 mb-8">
  - [Install the formbase node in n8n](/guides/n8n/install-the-node) — Give n8n a public HTTPS address and install the community node.
  - [Connect formbase to n8n](/guides/n8n/connect) — Create the credential, sign in, and pick the workspace.
  - [Start an n8n workflow from public link submissions](/guides/n8n/public-link-submissions) — New, edited or abandoned submissions into the next node.
  - [Send a request from an n8n workflow](/guides/n8n/send-a-request) — Assign a form to one recipient, prefilled and with locked fields.
  - [Act on a request's outcome in n8n](/guides/n8n/request-outcome) — Branch on the verdict, from a trigger or by waiting in the same workflow.
  - [Look up, remind and cancel requests in n8n](/guides/n8n/manage-requests) — Look a request up by your own ID and act on it.
</div>

<h2 id="ai-agents">AI agents</h2>

<p>
  Claude, ChatGPT, Cursor and other AI tools reach formbase over MCP. Once connected, an agent builds and publishes forms, sends requests
  and reads the answers in one workspace.
</p>

<div class="not-prose grid gap-3 sm:grid-cols-2 mb-8">
  - [Connect an AI agent to formbase](/guides/ai-agents/connect) — Add the server URL to your AI tool, sign in, and pick the workspace.
  - [Build a form with an AI agent](/guides/ai-agents/build-a-form) — Describe the form, watch it in a preview, change it, and publish it.
  - [Send a request with an AI agent](/guides/ai-agents/send-a-request) — Ask one person to complete a form, prefilled, and read the answers back.
</div>

<h2 id="rest-api">REST API</h2>

<p>
  Any tool with an HTTP step, or your own code, can send requests through the <a href="/developers/rest-api">REST API</a> and receive the
  answers as a signed callback.
</p>

<div class="not-prose grid gap-3 sm:grid-cols-2 mb-8">
  - [Send a request with the REST API](/guides/rest-api/send-a-request) — Create a token, read the field keys, and create a prefilled request with curl.
  - [Receive and verify the callback](/guides/rest-api/verify-the-callback) — Run a receiver in Node or Python, check the signature, and read the answers.
</div>


# Connect formbase to Zapier

Sign in to formbase from a Zap, pick the workspace Zapier may use, and check that your forms show up.

## Connect formbase to Zapier

Zapier signs in to one formbase workspace. You do this once; every Zap that uses formbase can then pick the same connection.

<h2 id="before-you-start">Before you start</h2>

<ul>
  <li>A formbase account that is a member of the workspace you want to connect.</li>
  <li>
    At least one <strong>published</strong> form in that workspace. Zapier lists unpublished forms too, marked <em>(not published)</em>, but
    only a published form sends events and can be requested.
  </li>
  <li>A Zapier account. Any plan works for connecting.</li>
</ul>

<h2 id="add-formbase-to-a-zap">1. Add formbase to a Zap</h2>

<p>
  In Zapier, click <strong>Create</strong> › <strong>Zaps</strong>, then click the trigger or action step and search for{' '}
  <strong>formbase</strong>. Pick any event for now; you can change it later. The list shows what the app can do:
</p>

<h2 id="sign-in">2. Sign in to formbase</h2>

<p>
  Under <strong>Account</strong>, click <strong>Sign in</strong> (or <strong>Change</strong> › <strong>Connect a new account</strong> if you
  already have one). Zapier opens a window; click <strong>Connect</strong>.
</p>

<p>
  If you are not signed in to formbase in this browser, formbase asks you to sign in first. Then it shows what Zapier may do and asks which
  workspace to connect.
</p>

<h2 id="pick-the-workspace">3. Pick the workspace and authorize</h2>

<p>
  Choose the workspace under <strong>Workspace</strong> and click <strong>Authorize</strong>. The window closes and the account appears in
  the step, named after your formbase email.
</p>

> ℹ️ **One connection, one workspace**
> <p>
>     To use a second workspace, connect a new account and pick the other workspace on this screen. Zapier then shows both connections, and
>     each Zap step uses the one you choose.
>   </p>

<h2 id="check-the-connection">4. Check the connection</h2>

<p>
  Pick an event and continue to <strong>Configure</strong>. The <strong>Form</strong> dropdown lists the forms of the connected workspace:
</p>

<h2 id="disconnect">Disconnect</h2>

<p>
  In Zapier, open <strong>App connections</strong>, find formbase, and remove the connection. Zaps that use it stop until you connect
  formbase again and pick the new connection in each of them.
</p>

<p>
  Removing it in Zapier does not end Zapier's access on the formbase side. In formbase, open <strong>OAuth and API Keys</strong> and click
  <strong>Disconnect</strong> next to Zapier under <strong>Connected apps</strong> as well.
</p>

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

<div class="not-prose grid gap-3 sm:grid-cols-2 mb-8">
  - [Send public link submissions to another app](/guides/zapier/public-link-submissions)
  - [Send a request from a Zap](/guides/zapier/send-a-request)
</div>


# Send public link submissions to another app

Start a Zap when someone submits, edits or abandons a form through its share link, and send the answers to email, Sheets, Slack or a CRM.

## Send public link submissions to another app

This guide builds a two-step Zap: a new submission to the Approval form sends an email. Swap the email step for Google Sheets, Slack or your CRM; the trigger side stays the same.

<p>
  You need a <a href="/guides/zapier/connect">formbase connection in Zapier</a> and a published form with a share link. A submission to a
  request link does not start these Zaps; see <a href="/guides/zapier/request-outcome">Act on a request's outcome</a> for that.
</p>

<h2 id="pick-the-trigger">1. Pick the trigger</h2>

<p>
  Create a Zap, choose <strong>formbase</strong> as the trigger app, and pick one of the three public link events:
</p>

Edit after submit</a> turned on for the form.',
      ],
    },
    {
      label: 'Public Link Submission Abandoned',
      cells: [
        'A respondent starts the form and leaves it unfinished for the time you choose, from 12 hours to 1 week.',
        'A Pro or Business plan, which saves <a href="/submissions-analytics/partial-submissions">partial submissions</a>.',
      ],
    },
  ]}
/>

<h2 id="choose-the-form">2. Choose the form</h2>

<p>
  Pick your account, click <strong>Continue</strong>, and choose the form under <strong>Form</strong>. For the Abandoned trigger, also set{' '}
  <strong>Consider submission abandoned after</strong>. formbase checks once an hour, so the Zap can start up to an hour after that time.
</p>

<h2 id="test-the-trigger">3. Test the trigger</h2>

<p>
  Click <strong>Continue</strong>, then <strong>Test trigger</strong>. Zapier loads a record such as{' '}
  <strong>Public Link Submission A</strong>. Open it to see the fields you can map:
</p>

> ℹ️ **The test record is a sample**
> <p>
>     It carries every question of the form with made-up answers, so you can map fields before anyone has submitted. Real runs carry the
>     respondent's answers. The sample has <strong>Test Event</strong> set to <code>true</code>; real submissions have <code>false</code>.
>   </p>

<p>Every answer appears twice:</p>

<ul>
  <li>
    <strong>answers</strong> holds the value a workflow compares: the <a href="/requests/field-keys">option key</a> of a choice, like
    <code>approve</code>, a number, a date.
  </li>
  <li>
    <strong>display</strong> holds the text a person reads: the option's label (<code>Approve</code>), a formatted date.
  </li>
</ul>

<p>
  Where a field key reads differently from its question title, the label names the key in parentheses:{' '}
  <strong>Your Decision (decision)</strong> is the question whose <a href="/requests/field-keys">field key</a> is <code>decision</code>.
  Once mapped, a field shows its key path, such as <strong>Data › Display › Decision</strong>.
</p>

<p>
  Use <strong>display</strong> in messages and <strong>answers</strong> in filters and lookups. The full shape of both is in the{' '}
  <a href="/developers/webhooks-reference">webhooks reference</a>.
</p>

<h2 id="map-the-fields">4. Add the action and map the fields</h2>

<p>
  Click <strong>Continue with selected record</strong> and add the action. This guide uses <strong>Email by Zapier</strong> ›{' '}
  <strong>Send Outbound Email</strong>. In each field, type <code>/</code> or click <strong>+</strong> and search for the question by its
  title:
</p>

<h2 id="publish-and-check">5. Publish and check a real run</h2>

<p>
  Click <strong>Publish</strong>. Open the form's share link, submit it, and open <strong>Zap history</strong> in Zapier. The run appears
  within seconds:
</p>

<p>Open the run to see the real answers the Zap received.</p>

<h2 id="troubleshooting">If the Zap does not start</h2>

<ul>
  <li>
    <strong>The form was submitted through a request link.</strong> That fires Request Completed, not Public Link Submission Created.
  </li>
  <li>
    <strong>The Zap is off.</strong> A draft never runs. Check the toggle next to the Zap's name.
  </li>
  <li>
    <strong>The Zap uses another connection.</strong> The trigger listens to the form in the workspace its connection points at.
  </li>
</ul>

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

<div class="not-prose grid gap-3 sm:grid-cols-2 mb-8">
  - [Send a request from a Zap](/guides/zapier/send-a-request)
  - [Native integrations](/integrations/overview) — Slack, Sheets and Notion without Zapier.
</div>


# Send a request from a Zap

Create a formbase request from any Zapier trigger, prefill and lock the answers you already know, and send the link by email or through your own channel.

## Send a request from a Zap

A request asks one named recipient to complete a form. This guide adds a Create Request step to a Zap, prefills the recipient's name so they cannot change it, and hands the link to the next step.

<p>
  You need a <a href="/guides/zapier/connect">formbase connection in Zapier</a> and a published form. What a request is, and every option it
  takes, is in <a href="/requests/creating-requests">Creating requests</a>; this guide shows where those options sit in Zapier.
</p>

<h2 id="add-the-step">1. Add the Create Request step</h2>

<p>
  Start with the trigger that should send the request: a deal marked won in your CRM, a new row in Google Sheets, a scheduled date. Add an
  action, choose <strong>formbase</strong>, and pick <strong>Create Request</strong>.
</p>

<h2 id="recipient-and-delivery">2. Pick the form, the recipient and the delivery</h2>

<ul>
  <li>
    <strong>Form</strong> is the form the recipient completes. It must be published.
  </li>
  <li>
    <strong>Recipient Email</strong> and <strong>Recipient Name</strong> usually come from the trigger. The email is needed for email
    delivery and for reminders.
  </li>
  <li>
    <strong>Language</strong> is one of the form's published languages, like <code>de</code>. Leave it empty for the form's default.
  </li>
  <li>
    <strong>Delivery</strong> decides who sends the link. <strong>Email</strong> lets formbase send the
    <a href="/requests/invitations-and-reminders">invitation and reminders</a>, and needs a Pro or Business plan. <strong>None</strong>, the
    default, sends no invitation: a later step of your Zap sends the <strong>Request URL</strong>, by Slack, SMS or your own email tool. If
    the form has <strong>Send reminders</strong> on and you fill in <strong>Recipient Email</strong>, formbase still emails the scheduled
    reminders. To send none at all, leave Recipient Email empty.
  </li>
</ul>

<h2 id="prefill-and-lock">3. Prefill and lock what you already know</h2>

<p>
  Once you pick the form, each question you can prefill appears below as a field, named after its
  <a href="/requests/field-keys">field key</a>. Signature, file upload, payment, appointment and calculated questions have none. Fill in any
  answer you already have; the recipient sees it filled in. Add a question to <strong>Read-only fields</strong> to lock it, so the recipient
  can see it but not change it. A locked question must also be prefilled.
</p>

<p>The recipient opens the link and finds their name filled in and locked:</p>

<p>
  Hidden fields of the form show up as <strong>(context)</strong> fields: the recipient cannot change them and sees them only where the form
  mentions them, and they come back both in the request's Context and among the answers. If the form has a Documents block, a{' '}
  <strong>Documents</strong> field takes files from earlier steps. See{' '}
  <a href="/requests/creating-requests#three-buckets">Prefill, locked fields, and context</a> for the difference.
</p>

<h2 id="external-id">4. Set an External ID</h2>

<p>
  Put your own id for this piece of work in <strong>External ID</strong>, like the deal number <code>deal-1042</code>. It does two jobs:
</p>

<ul>
  <li>
    A later Zap can look the request up with <a href="/guides/zapier/manage-requests">Find Request</a>, without storing formbase's request
    ID.
  </li>
  <li>
    It is the <a href="/requests/creating-requests#idempotency">idempotency key</a>. If Zapier replays the step with the same External ID
    and the same inputs, you get the same request back instead of a second one. <strong>Deduplicated</strong> is then <code>true</code>. The
    same External ID with any input changed, within 30 days, fails with <em>Idempotency key … was already used for a different request</em>.
    Use a new External ID for each request, like <code>deal-1042-onboarding</code>, and a fresh one when you change a test step's inputs,
    including <strong>Test Request</strong>.
  </li>
</ul>

<p>
  <strong>Reminders</strong>, <strong>Expires At</strong> and <strong>Metadata</strong> are optional. A request expires after 30 days unless
  you set another date, at most 365 days away.
</p>

<h2 id="test-the-step">5. Test the step</h2>

<p>
  Click <strong>Test step</strong>. formbase creates the request and returns it. <strong>Request URL</strong> is the link for the recipient;
  map it into the step that sends it.
</p>

> ⚠️ **Testing the step creates a real request**
> <p>
>     Unless <strong>Test Request</strong> is on, the test is a real request: with Email delivery, the recipient gets the invitation. Turn
>     Test Request on while you build the Zap. A test request sends no email and counts nowhere, and it does not start any Zap with a
>     <a href="/guides/zapier/request-outcome">request trigger</a>. Turn it off before you publish.
>   </p>

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

<div class="not-prose grid gap-3 sm:grid-cols-2 mb-8">
  - [Act on a request's outcome](/guides/zapier/request-outcome) — Start a Zap when the recipient approves, declines or asks for changes.
  - [Look up, remind and cancel requests](/guides/zapier/manage-requests) — Find a request by External ID, nudge the recipient, or withdraw it.
</div>


# Act on a request's outcome

Start a Zap when a request is completed, expires or is canceled, and branch on whether the recipient approved, declined or asked for changes.

## Act on a request's outcome

This guide builds a Zap that emails your team when a recipient answers an approval request, with their decision in the subject line. A filter then limits it to approvals.

<p>
  You need a <a href="/guides/zapier/connect">formbase connection in Zapier</a> and requests to act on, sent from a Zap as in
  <a href="/guides/zapier/send-a-request">Send a request from a Zap</a> or from anywhere else: the dashboard, the API or an agent. To branch
  on a verdict, the form needs a <a href="/requests/decisions-and-approvals">Decision question</a>.
</p>

<h2 id="pick-the-trigger">1. Pick the trigger</h2>

<p>
  Create a Zap, choose <strong>formbase</strong> as the trigger app, and search for <strong>Request</strong>:
</p>

<p>
  Pick <strong>Request Completed</strong>, pick your account, and choose the form. The Zap fires for requests to that form only.
</p>

<h2 id="test-the-trigger">2. Test the trigger</h2>

<p>
  Click <strong>Test trigger</strong>. As with public link triggers, Zapier loads a sample built from the form's questions, so you can map
  fields before any request is completed:
</p>

<p>
  Where a field key reads differently from its question title, the label names the key in parentheses.{' '}
  <strong>Your Decision (decision)</strong> is the answer whose field key is <code>decision</code>, and a mapped field shows it as{' '}
  <strong>Data › Answers › Decision</strong>.
</p>

<p>
  Besides <strong>answers</strong> and <strong>display</strong>, a request event carries a <strong>request</strong> group:
</p>

<ul>
  <li>
    <strong>Recipient Email</strong>, <strong>Recipient Name</strong>, <strong>Request External ID</strong>, <strong>Metadata</strong>
    and <strong>Context</strong>: what you set when you created the request. Use the External ID to find the record the request was about.
  </li>
  <li>
    <strong>Outcome</strong>: <code>approve</code>, <code>decline</code> or <code>changes</code>, the recipient's answer to the Decision
    question. On this form it is the same value as <strong>Answers › Decision</strong>, because the Decision question's key is
    <code>decision</code>. Outcome is missing when the form has no Decision question.
  </li>
</ul>

<p>
  Every field is described in the <a href="/requests/callbacks#payload">callback payload</a>; Zapier receives the same event.
</p>

<h2 id="send-the-email">3. Map the action</h2>

<p>
  Add the action. This guide uses <strong>Email by Zapier</strong> › <strong>Send Outbound Email</strong>, with the recipient's name and
  their decision in the subject and the External ID in the body:
</p>

<p>
  Publish the Zap. When the recipient completes the request, the email arrives with a subject like <em>Ada Lovelace answered: Approve</em>,
  and the run appears in <strong>Zap history</strong>.
</p>

<h2 id="branch-on-the-outcome">4. Continue only on approval</h2>

<p>
  To act on one verdict only, add a <strong>Filter</strong> step between the trigger and the action. Set it to continue only if{' '}
  <strong>Data › Answers › Decision</strong> exactly matches <code>approve</code>:
</p>

<p>
  The Decision question's field key is always <code>decision</code>, so <strong>Data › Answers › Decision</strong> has the same name on
  every form. <strong>Data › Request › Outcome</strong> holds the same value.
</p>

<p>
  Compare with the value from <strong>answers</strong>, not the label from <strong>display</strong>: the label changes when you rename the
  option or the recipient answers in another language, the value does not. To handle each verdict differently, use <strong>Paths</strong>{' '}
  instead of a filter, one path per value.
</p>

> ℹ️ **Filters and Paths need a paid Zapier plan**
> <p>
>     A Zap with a Filter or Paths step has more than two steps, and Zapier's Free plan publishes only two-step Zaps. On Free, keep the
>     trigger and one action, and branch in the app the action writes to.
>   </p>

<h2 id="troubleshooting">If the Zap does not start</h2>

<ul>
  <li>
    <strong>It was a test request.</strong> A request created with Test Request on, or with <strong>Try it yourself</strong>, never starts a
    Zap. Only its <a href="/requests/callbacks">callback</a> fires. Send a real request to check the Zap.
  </li>
  <li>
    <strong>The request was for another form.</strong> The trigger listens to the form you chose in step 1.
  </li>
  <li>
    <strong>The Zap is off, or uses a connection to another workspace.</strong> See the checks in
    <a href="/guides/zapier/public-link-submissions#troubleshooting">the public link guide</a>.
  </li>
</ul>

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

<div class="not-prose grid gap-3 sm:grid-cols-2 mb-8">
  - [Look up, remind and cancel requests](/guides/zapier/manage-requests) — Find a request by External ID, nudge the recipient, or withdraw it.
  - [Decisions and approvals](/requests/decisions-and-approvals) — The Decision question and its three values.
</div>


# Look up, remind and cancel requests

Find a request by your own External ID, read its status and answers, send the recipient a reminder, or cancel it, all from a Zap.

## Look up, remind and cancel requests

Four formbase actions work on a request that already exists. A typical Zap finds the request by the id your system knows it by, then reminds or cancels it.

<p>
  You need a <a href="/guides/zapier/connect">formbase connection in Zapier</a> and a request created with an External ID, as in{' '}
  <a href="/guides/zapier/send-a-request">Send a request from a Zap</a>. The same actions exist in the dashboard; see
  <a href="/requests/managing-requests">The Requests page</a>.
</p>

<h2 id="find-a-request">Find a request by External ID</h2>

<p>
  Your CRM knows the deal as <code>deal-1042</code>, not by formbase's request ID. Add a formbase action with the event{' '}
  <strong>Find Request</strong>
  and map your id into <strong>External ID</strong>:
</p>

<ul>
  <li>
    <strong>Form</strong> narrows the search to one form. Leave it empty to search the whole workspace.
  </li>
  <li>
    <strong>Include Test Requests</strong> is off by default, so a request created with Test Request on is not found. Turn it on while you
    build the Zap with test requests.
  </li>
  <li>
    If several requests share the External ID, the newest comes first, and Zapier uses it unless you change{' '}
    <strong>If multiple search results are found</strong>.
  </li>
</ul>

<p>The result is the whole request: its status, outcome, recipient, timestamps, and what you prefilled and locked.</p>

<h2 id="get-a-request">Read the answers with Get Request</h2>

<p>
  <strong>Get Request</strong> takes a <strong>Request</strong>: map the Request ID from a Create Request step, a Find Request step or a
  request trigger. It returns the request plus its link, and once the request is completed, the answers. Pick the request's form under{' '}
  <strong>Form</strong> so the answers are labelled with the question titles; without it they arrive under their field keys.
</p>

<h2 id="remind">Send a reminder with Remind Request</h2>

<p>
  <strong>Remind Request</strong> emails the recipient a reminder now. Scheduled reminders still go out as planned. It works when:
</p>

<ul>
  <li>the request is pending, has not passed its expiry date, and has a recipient email,</li>
  <li>the workspace is on Pro or Business,</li>
  <li>
    the last reminder went out at least ten minutes ago, and the request has had fewer than eight reminders in total. See{' '}
    <a href="/requests/invitations-and-reminders#manual">Chasing someone now</a>.
  </li>
</ul>

<p>A test request is never reminded.</p>

<h2 id="cancel">Withdraw a request with Cancel Request</h2>

<p>
  <strong>Cancel Request</strong> withdraws a pending request. The link stops working, and the recipient sees a withdrawn notice instead of
  the form. Fill in <strong>Reason</strong> to record why: it stays on the request and is carried by the
  <a href="/guides/zapier/request-outcome">Request Canceled</a> trigger, so another Zap can tell your team.
</p>

> ℹ️ **Only pending requests can be canceled**
> <p>
>     A completed, expired or already canceled request stays as it is, and the step fails with an error such as{' '}
>     <em>This request is already canceled and cannot be canceled.</em>
>   </p>

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

<div class="not-prose grid gap-3 sm:grid-cols-2 mb-8">
  - [All guides](/guides/overview)
  - [The Requests page](/requests/managing-requests) — The same actions in the dashboard.
</div>


# Install the formbase node in n8n

Give a self-hosted n8n a public HTTPS address, install the n8n-nodes-formbase community node, and check that its triggers and actions show up.

## Install the formbase node in n8n

formbase for n8n is a community node, published on npm as n8n-nodes-formbase. This guide installs it on a self-hosted n8n and sets the address formbase uses to reach your workflows.

<p>
  Every n8n guide was checked on self-hosted n8n 2.40, running in Docker, with version 0.10.1 of the node. Everything that depends on where
  n8n runs is on this page. The other guides work the same once the node is installed.
</p>

<h2 id="before-you-start">Before you start</h2>

<ul>
  <li>n8n 2.30 or later. The formbase credential needs it.</li>
  <li>
    A formbase account. Everything in these guides works on the Free plan except email invitations, reminders and abandoned submissions,
    which need Pro or Business.
  </li>
</ul>

<h2 id="self-hosted">Self-hosted n8n</h2>

<p>A self-hosted n8n needs two things before it can install the node:</p>

<ul>
  <li>The owner account or an admin account. Only the owner and admins can install community nodes.</li>
  <li>
    A public HTTPS address, from a reverse proxy or a tunnel. formbase sends events and request callbacks only to <code>https://</code> URLs
    on the public internet. An n8n that is reachable only on <code>localhost</code> can connect but never receives anything.
  </li>
</ul>

<h3 id="public-address">1. Tell n8n its public address</h3>

<p>
  n8n builds the URLs it hands to formbase from its own settings, not from the address in your browser. Set these environment variables on
  the n8n container or process:
</p>

<ul>
  <li>
    <code>WEBHOOK_URL</code>: the public HTTPS address, with a trailing slash, like <code>https://n8n.example.com/</code>. n8n builds the
    URL of a published trigger and the resume URL of <a href="/guides/n8n/request-outcome#wait-for-the-outcome">Wait for the Outcome</a>{' '}
    from it.
  </li>
  <li>
    <code>N8N_EDITOR_BASE_URL</code>: the same address. n8n builds a trigger's test URL for <strong>Execute step</strong> from it, and the{' '}
    <strong>OAuth Redirect URL</strong> of the formbase credential.
  </li>
  <li>
    <code>N8N_PROXY_HOPS</code>: set it to <code>1</code> when n8n runs behind one reverse proxy or tunnel, so n8n reads the proxy's
    forwarded headers.
  </li>
</ul>

<p>With Docker, that looks like this:</p>

```
docker run -d --name n8n -p 5678:5678 \
  -e WEBHOOK_URL=https://n8n.example.com/ \
  -e N8N_EDITOR_BASE_URL=https://n8n.example.com/ \
  -e N8N_PROXY_HOPS=1 \
  -v n8n_data:/home/node/.n8n \
  docker.n8n.io/n8nio/n8n
```

<p>
  With docker compose or an npm install, set the same variables in n8n's environment. Point your proxy or tunnel at port <code>5678</code>,
  pass <code>/webhook/</code>, <code>/webhook-test/</code> and <code>/webhook-waiting/</code> through unchanged, and open n8n through the
  public address from then on.
</p>

> ⚠️ **VALIDATION_ERROR: targetUrl must use https**
> <p>
>     formbase refuses to send events to an <code>http://</code> URL or a private network address. If a trigger shows this error on{' '}
>     <strong>Execute step</strong>, <code>N8N_EDITOR_BASE_URL</code> is not set; on <strong>Publish</strong>, <code>WEBHOOK_URL</code> is
>     not. Set that variable, restart n8n, and try again. If you change <code>WEBHOOK_URL</code> later, unpublish and publish each formbase
>     workflow again so its trigger registers the new address.
>   </p>

<h3 id="install">2. Install the community node</h3>

<p>
  Open <strong>Settings</strong> › <strong>Community nodes</strong> and click <strong>Install</strong>. Type <code>n8n-nodes-formbase</code>{' '}
  under <strong>npm Package Name</strong>, tick the box that says you understand the risks of installing unverified code, and click{' '}
  <strong>Install</strong>.
</p>

<p>After a few seconds the package appears in the list with its two nodes:</p>

<p>
  Update or uninstall the package from the same list. If <strong>Community nodes</strong> is missing from Settings, your n8n was started
  with <code>N8N_COMMUNITY_PACKAGES_ENABLED=false</code>; remove that setting.
</p>

<h2 id="n8n-cloud">n8n Cloud</h2>

> ℹ️ **Not available on n8n Cloud yet**
> <p>
>     n8n Cloud installs only community nodes that n8n has verified, and the formbase node is not verified yet. Until it is, use a self-hosted
>     n8n, or call the <a href="/guides/rest-api/send-a-request">REST API</a> from an <strong>HTTP Request</strong> node. A Cloud instance
>     already has a public HTTPS address, so once the node is available there, only the install steps on this page change.
>   </p>

<h2 id="check">Check the node</h2>

<p>
  Open a workflow, click <strong>+</strong> and search for <strong>formbase</strong>. The node details show six actions and six triggers:
</p>

<ul>
  <li>
    <strong>formbase Trigger</strong> starts a workflow on a public link submission that is created, updated or abandoned, or on a request
    that is completed, expires or is canceled.
  </li>
  <li>
    <strong>formbase</strong> creates a request, gets one or many, reminds the recipient, cancels a request, and replays its callback.
  </li>
</ul>

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

<div class="not-prose grid gap-3 sm:grid-cols-2 mb-8">
  - [Connect formbase to n8n](/guides/n8n/connect) — Create the credential, sign in, and pick the workspace.
  - [All guides](/guides/overview)
</div>


# Connect formbase to n8n

Create a formbase credential in n8n, sign in, pick the workspace n8n may use, and check that your forms show up.

## Connect formbase to n8n

An n8n credential signs in to one formbase workspace. You create it once; every formbase node in your workflows can then use it.

<h2 id="before-you-start">Before you start</h2>

<ul>
  <li>
    The formbase node installed on your n8n. See <a href="/guides/n8n/install-the-node">Install the formbase node in n8n</a>, which also
    covers what your n8n needs first.
  </li>
  <li>A formbase account that is a member of the workspace you want to connect.</li>
  <li>
    At least one <strong>published</strong> form in that workspace.
  </li>
</ul>

<h2 id="create-the-credential">1. Create the credential</h2>

<p>
  Add a formbase node to a workflow, open <strong>Credential</strong> and click <strong>Create new credential</strong>. You can also start
  from <strong>Overview</strong> › <strong>Credentials</strong> › <strong>Create</strong> and search for{' '}
  <strong>Formbase OAuth2 API</strong>.
</p>

<p>
  There is nothing to fill in. n8n registers itself with formbase when you click <strong>Connect</strong>, including the{' '}
  <strong>OAuth Redirect URL</strong> shown here. The note below that URL asks you to enter it in formbase; you can ignore it, because
  formbase has no place to paste it.
</p>

<h2 id="sign-in">2. Sign in and pick the workspace</h2>

<p>
  Click <strong>Connect</strong>. n8n opens a formbase window. If you are not signed in to formbase in this browser, sign in first. formbase
  then shows what n8n may do and asks which workspace to connect:
</p>

<p>
  Choose the workspace under <strong>Workspace</strong> and click <strong>Authorize</strong>. The window reports that the connection
  succeeded, and the credential in n8n shows as connected. Save it. If you connect more than one workspace, rename each credential after the
  workspace it reaches.
</p>

> ℹ️ **One credential, one workspace**
> <p>
>     To use a second workspace, create a second credential and pick the other workspace on this screen. Each formbase node then uses the
>     credential you choose in it.
>   </p>

<h2 id="check-the-connection">3. Check the connection</h2>

<p>
  Add a <strong>formbase Trigger</strong> and pick the credential. The form list shows the forms of the connected workspace. A form marked{' '}
  <em>(not published)</em> sends no events and takes no requests until you publish it.
</p>

<h2 id="disconnect">Disconnect</h2>

<p>
  In n8n, delete the credential under <strong>Overview</strong> › <strong>Credentials</strong>. Workflows that use it stop until you pick
  another credential in each formbase node.
</p>

<p>
  Deleting it in n8n does not end n8n's access on the formbase side. In formbase, open <strong>OAuth and API Keys</strong>, click the bin
  icon next to n8n under <strong>Connected apps</strong>, and confirm with <strong>Disconnect</strong>.
</p>

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

<div class="not-prose grid gap-3 sm:grid-cols-2 mb-8">
  - [Start an n8n workflow from public link submissions](/guides/n8n/public-link-submissions)
  - [Send a request from an n8n workflow](/guides/n8n/send-a-request)
</div>


# Start an n8n workflow from public link submissions

Run an n8n workflow when someone submits, edits or abandons a form through its share link, and pass the answers to the next node.

## Start an n8n workflow from public link submissions

This guide builds a two-node workflow: a new submission to a supplier onboarding form arrives in a formbase Trigger, and an Edit Fields node picks out the answers. Put your email, Sheets, Slack or CRM node after it.

<p>
  You need a <a href="/guides/n8n/connect">formbase credential in n8n</a> and a published form with a{' '}
  <a href="/sharing-publishing/sharing-embedding#share-links">share link</a>. Your n8n must be reachable from the internet over HTTPS; see{' '}
  <a href="/guides/n8n/install-the-node">Install the formbase node in n8n</a>. A submission to a request link does not start these
  workflows; see <a href="/guides/n8n/request-outcome">Act on a request's outcome in n8n</a> for that.
</p>

<h2 id="add-the-trigger">1. Add the trigger</h2>

<p>
  Create a workflow, click <strong>+</strong>, search for <strong>formbase</strong> and pick <strong>formbase Trigger</strong>. Choose the
  credential, the form, and one of the three public link events under <strong>Event</strong>:
</p>

Edit after submit</a> turned on for the form.',
      ],
    },
    {
      label: 'Public Link Submission Abandoned',
      cells: [
        'A respondent leaves the form unfinished for the time under <strong>Consider Abandoned After</strong> (12 hours to 1 week). formbase checks hourly, so the run can come up to an hour later.',
        'A Pro or Business plan, which saves <a href="/submissions-analytics/partial-submissions">partial submissions</a>.',
      ],
    },
  ]}
/>

<h2 id="listen-for-a-test-event">2. Listen for a test event</h2>

<p>
  Click <strong>Execute step</strong>. The node shows <em>Listening for test event</em>, and formbase starts sending this form's events to
  the node's test URL for two minutes. Open the form's share link in another tab and submit it within that time. The submission arrives in
  the node within seconds:
</p>

<p>
  n8n has no sample record: the test is a real submission, and <code>test</code> is <code>false</code>. Every answer appears twice:
</p>

<ul>
  <li>
    <strong>data.answers</strong> holds the value a workflow compares, under the question's <a href="/requests/field-keys">field key</a>: an
    option key like <code>yes</code>, a number, a date, or a list of files.
  </li>
  <li>
    <strong>data.display</strong> holds the text a person reads, like <code>Yes</code>.
  </li>
</ul>

<p>
  Use <strong>display</strong> in messages and <strong>answers</strong> in conditions. See the{' '}
  <a href="/developers/webhooks-reference#fields-vs-answers">webhooks reference</a> for both.
</p>

<p>
  Abandoned submissions arrive hours later, so <strong>Execute step</strong> cannot catch one. For that event, publish the workflow and
  check <strong>Executions</strong>.
</p>

<h2 id="map-the-fields">3. Pick out the answers</h2>

<p>
  Add an <strong>Edit Fields (Set)</strong> node after the trigger. Drag answers from the input panel into <strong>Fields to Set</strong>,
  or type the expressions yourself:
</p>

<p>
  The next node, say Gmail or Google Sheets, reads <code>{'{{ $json.company }}'}</code> and the other fields from here. When a question is
  renamed, its field key stays the same, so these expressions keep working.
</p>

<h2 id="publish-and-check">4. Publish and check a real run</h2>

<p>
  Click <strong>Publish</strong>, give the version a name, and confirm. formbase now sends every new submission to the workflow's production
  URL. Unpublishing the workflow stops that.
</p>

<p>
  Submit the form once more and open the <strong>Executions</strong> tab. The run appears within seconds. Runs you started with{' '}
  <strong>Execute step</strong> carry a flask icon; a production run has none:
</p>

<h2 id="troubleshooting">If the workflow does not start</h2>

<ul>
  <li>
    <strong>The form was submitted through a request link.</strong> That runs <strong>Request Completed</strong>, not Public Link Submission
    Created.
  </li>
  <li>
    <strong>The workflow is not published.</strong> Only <strong>Execute step</strong> listens while the workflow is a draft, and only until
    the first event arrives or two minutes pass.
  </li>
  <li>
    <strong>
      The trigger shows <em>targetUrl must use https</em>.
    </strong>{' '}
    Your n8n hands out an <code>http://</code> or private URL. See{' '}
    <a href="/guides/n8n/install-the-node#public-address">Tell n8n its public address</a>.
  </li>
  <li>
    <strong>The credential reaches another workspace.</strong> The trigger listens to the form in the workspace its credential points at.
  </li>
</ul>

> ℹ️ **Checking the signature**
> <p>
>     The formbase Trigger checks the <code>X-formbase-Signature</code> header of every event and rejects any that fails. Keep the n8n host's
>     clock accurate: an event signed more than five minutes earlier is rejected too.
>   </p>

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

<div class="not-prose grid gap-3 sm:grid-cols-2 mb-8">
  - [Send a request from an n8n workflow](/guides/n8n/send-a-request)
  - [Native integrations](/integrations/overview) — Slack, Sheets and Notion without n8n.
</div>


# Send a request from an n8n workflow

Create a formbase request from any n8n workflow, prefill and lock the answers you already know, and send the link by email or through your own channel.

## Send a request from an n8n workflow

A request asks one named recipient to complete a form. This guide adds a Create request node to a workflow, prefills the supplier's company name so they cannot change it, and hands the link to the next node.

<p>
  You need a <a href="/guides/n8n/connect">formbase credential in n8n</a> and a published form. What a request is, and every option it
  takes, is in <a href="/requests/creating-requests">Creating requests</a>; this guide shows where those options sit in n8n.
</p>

<h2 id="add-the-node">1. Add the Create request node</h2>

<p>
  Start with the trigger that should send the request: a new row in a sheet, a deal marked won in your CRM, a schedule. This guide uses a
  manual trigger and an <strong>Edit Fields</strong> node named <em>Supplier</em> that holds <code>supplier_id</code>,{' '}
  <code>company_name</code> and <code>contact_email</code>. Click <strong>+</strong> after it, search for <strong>formbase</strong>, and
  pick <strong>Create request</strong>.
</p>

<h2 id="form-and-recipient">2. Pick the form and the recipient</h2>

<ul>
  <li>
    <strong>Form</strong> is the form the recipient completes. Pick it from the list, or switch to <strong>ID</strong> and paste its ID. It
    must be published.
  </li>
  <li>
    <strong>Recipient Email</strong> usually comes from the input, like <code>{'{{ $json.contact_email }}'}</code>. It is needed for email
    delivery and for reminders.
  </li>
</ul>

<h2 id="prefill-and-lock">3. Prefill and lock what you already know</h2>

<p>
  Once you pick the form, <strong>Values to Send</strong> lists every question you can prefill by its title and{' '}
  <a href="/requests/field-keys">field key</a>, like <em>Company name (company_name)</em>. File upload, signature, payment and schedule
  appointment questions, calculated fields and Documents blocks are not listed: the recipient or the form supplies those. Map the answers
  you already have; fields left empty are not sent. The recipient sees the mapped ones filled in.
</p>

<p>
  <strong>Mapping Column Mode</strong> can also be <strong>Map Automatically</strong>: then every input field named after a field key of the
  form is sent. That suits an input you shaped for this form with an Edit Fields node.
</p>

<p>
  Hidden fields of the form are listed too, marked <em>· context</em>. The recipient cannot change them and sees them only where the form
  mentions them. See <a href="/requests/creating-requests#three-buckets">Prefill, locked fields, and context</a> for the difference.
</p>

<p>
  Pick a question under <strong>Read-Only Field Names or IDs</strong> to lock it, so the recipient can see it but not change it. A locked
  question must also be prefilled. If the form has a Documents block, <strong>Documents</strong> takes files from a binary field of the
  input item.
</p>

<h2 id="additional-fields">4. Add the options you need</h2>

<p>
  Click <strong>Add Field</strong> under <strong>Additional Fields</strong>:
</p>

<ul>
  <li>
    <strong>Delivery</strong> decides who sends the link. <strong>Email the Invitation</strong> lets formbase send the{' '}
    <a href="/requests/invitations-and-reminders">invitation and reminders</a>, and needs a Pro or Business plan. <strong>None</strong>, the
    default, sends no invitation: a later node sends the request's <code>url</code>, by Slack, SMS or your own email tool.
  </li>
  <li>
    <strong>Reminders</strong> left out follows the form's reminder schedule. With a Recipient Email, those reminders are emailed even when
    Delivery is None. Add <strong>Reminders</strong> and leave it empty to send none, or enter a schedule like <code>2d, 5d</code>, which
    needs Pro or Business.
  </li>
  <li>
    <strong>External ID</strong> is your own ID for this piece of work, like <code>supplier-{'{{ $json.supplier_id }}'}</code>. A later
    workflow can <a href="/guides/n8n/manage-requests#find-a-request">find the request by it</a>. It is also the{' '}
    <a href="/requests/creating-requests#idempotency">idempotency key</a>: run the node again with the same External ID and the same
    parameters, and you get the same request back with <code>deduplicated: true</code>, not a second one. Within 30 days, the same External
    ID with any parameter changed, Test Mode included, fails with{' '}
    <em>CONFLICT: Idempotency key … was already used for a different request</em>.
  </li>
  <li>
    <strong>Test Mode</strong> creates a <a href="/requests/creating-requests#test-mode">test request</a>: nothing is emailed and it counts
    nowhere.
  </li>
  <li>
    <strong>Recipient Name</strong>, <strong>Language</strong>, <strong>Expires At</strong> and <strong>Metadata</strong> are optional. A
    request expires after 30 days unless you set another date, at most 365 days away.
  </li>
  <li>
    <strong>Callback URL</strong> sends the outcome to a URL of your own. To get the outcome back into this workflow, turn on{' '}
    <a href="/guides/n8n/request-outcome#wait-for-the-outcome">Wait for the Outcome</a> instead.
  </li>
</ul>

<h2 id="run-the-node">5. Run the node</h2>

<p>
  Click <strong>Execute step</strong>. formbase creates the request and returns it: <code>id</code>, <code>status</code>{' '}
  <code>pending</code>, and <code>url</code>, the link for the recipient. Map <code>{'{{ $json.url }}'}</code> into the node that sends it.{' '}
  <code>deliveryStatus</code> is <code>not_requested</code> when Delivery is None, because formbase sent nothing.
</p>

> ⚠️ **Running the node creates a real request**
> <p>
>     Unless <strong>Test Mode</strong> is on, every run, including <strong>Execute step</strong> in the editor, is a real request: with email
>     delivery the recipient gets the invitation. Turn Test Mode on while you build the workflow. A test request does not start any workflow
>     with a <a href="/guides/n8n/request-outcome">request trigger</a>. Before you publish, turn Test Mode off and change the External ID,
>     because the old one belongs to the test request.
>   </p>

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

<div class="not-prose grid gap-3 sm:grid-cols-2 mb-8">
  - [Act on a request's outcome in n8n](/guides/n8n/request-outcome) — Branch when the recipient approves, declines or asks for changes.
  - [Look up, remind and cancel requests in n8n](/guides/n8n/manage-requests) — Find a request by External ID, nudge the recipient, or withdraw it.
</div>


# Act on a request's outcome in n8n

Start a workflow when a request is completed, expires or is canceled, or pause the workflow that sent it until the recipient answers, and branch on the verdict.

## Act on a request's outcome in n8n

n8n can hear about a request's outcome in two ways: a formbase Trigger that runs for every request to a form, or a Wait node that pauses the workflow that created the request. Both end in a Switch on approve, decline or changes.

<p>
  You need a <a href="/guides/n8n/connect">formbase credential in n8n</a>, and your n8n must be reachable from the internet over HTTPS; see{' '}
  <a href="/guides/n8n/install-the-node">Install the formbase node in n8n</a>. To branch on a verdict, the form needs a{' '}
  <a href="/requests/decisions-and-approvals">Decision question</a>. This guide uses an Approval form with one.
</p>

<h2 id="trigger">Start a workflow from the trigger</h2>

<h3 id="pick-the-event">1. Pick the event</h3>

<p>
  Add a <strong>formbase Trigger</strong>, pick the credential and the form, and choose one of the request events under{' '}
  <strong>Event</strong>:
</p>

<h3 id="test-the-trigger">2. Test it with a real request</h3>

<p>
  A test request never starts a request trigger, so send a real one with Delivery set to None, as in{' '}
  <a href="/guides/n8n/send-a-request">Send a request from an n8n workflow</a>, and open its link yourself. Then click{' '}
  <strong>Execute step</strong> and submit the form within two minutes.
</p>

<p>
  The event carries a <strong>data.request</strong> object next to the answers. Its <code>outcome</code> is <code>approve</code>,{' '}
  <code>decline</code> or <code>changes</code>, and its <code>externalId</code> is the ID you set, so you can find the record the request
  was about. Every field is in the <a href="/requests/callbacks#payload">callback payload</a>.
</p>

<p>
  Expired and canceled events are hard to catch with <strong>Execute step</strong>. Publish the workflow and check{' '}
  <strong>Executions</strong> instead.
</p>

<h3 id="branch-on-the-outcome">3. Branch on the outcome</h3>

<p>
  Add a <strong>Switch</strong> node with one routing rule per verdict. Each rule compares <code>{'{{ $json.data.request.outcome }}'}</code>{' '}
  with a value; turn on <strong>Rename Output</strong> to name the branch after it:
</p>

<p>
  Branch on <code>data.request.outcome</code> rather than <code>data.answers.decision</code>: it is missing, not some other value, when the
  request expired or was canceled. Compare values, never the labels in <code>data.display</code>: a label changes when you rename the option
  or the recipient answers in another language.
</p>

<p>
  Connect a node to each branch, then <strong>Publish</strong> the workflow. From then on every completed request to the form runs it.
</p>

<h2 id="wait-for-the-outcome">Wait for the outcome in the same workflow</h2>

<p>
  Here the workflow that sends the request pauses until the recipient answers, then carries on. It takes three changes to the workflow from{' '}
  <a href="/guides/n8n/send-a-request">Send a request from an n8n workflow</a>.
</p>

<h3 id="turn-on-wait">1. Turn on Wait for the Outcome</h3>

<p>
  In the <strong>Create request</strong> node, turn on <strong>Wait for the Outcome</strong>. The node sends formbase this execution's
  resume URL as the request's callback. Leave <strong>Callback URL</strong> empty; the node refuses both at once.
</p>

<p>
  Each execution has its own resume URL, so an External ID reused from an earlier execution fails. Include{' '}
  <code>{'{{ $execution.id }}'}</code> in it, or leave it empty.
</p>

<h3 id="add-a-wait-node">2. Add a Wait node</h3>

<p>
  Add a <strong>Wait</strong> node right after it, with <strong>Resume</strong> set to <strong>On Webhook Call</strong> and the HTTP method
  left at <code>POST</code>.
</p>

<h3 id="branch-on-the-event">3. Branch on the event</h3>

<p>
  Add a <strong>Switch</strong> after the Wait node. The Wait node puts the event under <code>body</code>, so compare{' '}
  <code>{'{{ $json.body.data.request.outcome }}'}</code>. To tell a completed request from an expired or canceled one, compare{' '}
  <code>{'{{ $json.body.type }}'}</code> with <code>request.completed</code>, <code>request.expired</code> or <code>request.canceled</code>.
</p>

<p>Run the workflow. The execution stops at the Wait node until the recipient submits, then resumes down the matching branch:</p>

<ul>
  <li>
    A run from the editor waits for a real answer too. Test Mode works here: the callback of a test request still resumes the workflow.
  </li>
  <li>
    A request expires after 30 days unless you set <strong>Expires At</strong>, and expiry resumes the execution with{' '}
    <code>request.expired</code>. Set <strong>Expires At</strong> to end the wait sooner.
  </li>
  <li>
    The Wait node does not check the <code>X-formbase-Signature</code> header; it relies on the resume URL being hard to guess. If that is
    not enough, use the formbase Trigger, which checks every event.
  </li>
</ul>

<h2 id="troubleshooting">If nothing runs</h2>

<ul>
  <li>
    <strong>It was a test request.</strong> A request created with Test Mode on, or with <strong>Try it yourself</strong>, never starts a
    request trigger. Only its <a href="/requests/callbacks">callback</a> fires.
  </li>
  <li>
    <strong>The request was for another form.</strong> The trigger listens to the form you chose in it.
  </li>
  <li>
    <strong>The workflow is not published, or its credential reaches another workspace.</strong> See the checks in{' '}
    <a href="/guides/n8n/public-link-submissions#troubleshooting">the public link guide</a>.
  </li>
  <li>
    <strong>
      Create request fails with <em>callbackUrl is not allowed</em>.
    </strong>{' '}
    Your n8n hands out an <code>http://</code> or private resume URL. See{' '}
    <a href="/guides/n8n/install-the-node#public-address">Tell n8n its public address</a>.
  </li>
  <li>
    <strong>The Wait node never resumes.</strong> formbase cannot reach the resume URL, for example because the tunnel is down or the proxy
    blocks <code>/webhook-waiting/</code>.
  </li>
</ul>

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

<div class="not-prose grid gap-3 sm:grid-cols-2 mb-8">
  - [Look up, remind and cancel requests in n8n](/guides/n8n/manage-requests) — Find a request by External ID, nudge the recipient, or withdraw it.
  - [Decisions and approvals](/requests/decisions-and-approvals) — The Decision question and its three values.
</div>


# Look up, remind and cancel requests in n8n

Find a request by your own External ID, read its status and answers, send the recipient a reminder, cancel it, or replay its callback, all from an n8n workflow.

## Look up, remind and cancel requests in n8n

Five formbase actions work on requests that already exist. A typical workflow finds the request by the ID your system knows it by, then reads, reminds or cancels it.

<p>
  You need a <a href="/guides/n8n/connect">formbase credential in n8n</a> and a request created with an External ID, as in{' '}
  <a href="/guides/n8n/send-a-request">Send a request from an n8n workflow</a>. The same actions exist in the dashboard; see{' '}
  <a href="/requests/managing-requests">The Requests page</a>.
</p>

<h2 id="find-a-request">Find a request with Get many requests</h2>

<p>
  Your system knows the supplier as <code>SUP-1042</code>, not by formbase's request ID. Add a formbase node, pick{' '}
  <strong>Get many requests</strong>, and filter by the External ID you set when you created the request:
</p>

<ul>
  <li>
    <strong>Scope</strong> is <strong>Form</strong>, the default, which then asks for the form, or <strong>Workspace</strong>, every request
    the credential can reach.
  </li>
  <li>
    <strong>Filters</strong> narrow the list by <strong>External ID</strong>, <strong>Status</strong> or <strong>Outcome</strong>.{' '}
    <strong>Include Test Requests</strong> is off by default, so a request created with Test Mode on is not found. Turn it on while you
    build the workflow with test requests.
  </li>
  <li>
    The newest request comes first. <strong>Limit</strong> caps the number of items; <strong>Return All</strong> fetches every page.
  </li>
</ul>

<p>
  Each request is one output item, so the nodes after it run once per request. When nothing matches, the node outputs no items and the
  workflow stops there; turn on <strong>Always Output Data</strong> in the node's settings to carry on. Every node below takes the request
  as <strong>Request</strong> › <strong>By ID</strong> <code>{'{{ $json.id }}'}</code>, or pick one of the newest requests under{' '}
  <strong>From list</strong>.
</p>

<h2 id="get-a-request">Read a request with Get request</h2>

<p>
  <strong>Get request</strong> returns the request: its status, outcome, recipient, timestamps, what you prefilled and locked, and its link.
  Once it is completed, it also carries <code>answers</code> and <code>display</code> at the top level, as{' '}
  <code>{'{{ $json.answers }}'}</code>. Unlike the events, its timestamps are Unix time in milliseconds.
</p>

<h2 id="remind">Send a reminder with Remind request recipient</h2>

<p>
  <strong>Remind request recipient</strong> emails the recipient a reminder now. Scheduled reminders still go out as planned. It works when:
</p>

<ul>
  <li>the request is pending, has not passed its expiry date, and has a recipient email,</li>
  <li>the workspace is on Pro or Business,</li>
  <li>
    the last reminder went out at least ten minutes ago, and the request has had fewer than eight reminders in total. See{' '}
    <a href="/requests/invitations-and-reminders#manual">Chasing someone now</a>.
  </li>
</ul>

<p>
  A test request is never reminded. The node fails with{' '}
  <em>CONFLICT: This is a test request, so nothing is emailed for it; a reminder has nobody to write to.</em>
</p>

<h2 id="cancel">Withdraw a request with Cancel request</h2>

<p>
  <strong>Cancel request</strong> withdraws a pending request. The link stops working, and the recipient sees a withdrawn notice instead of
  the form. Fill in <strong>Reason</strong> to record why: it comes back as <code>cancelReason</code> on the request and in the{' '}
  <a href="/guides/n8n/request-outcome">Request Canceled</a> event, so another workflow can tell your team.
</p>

> ℹ️ **Only pending requests can be canceled**
> <p>
>     A completed, expired or already canceled request stays as it is, and the node fails with an error such as{' '}
>     <em>This request is already canceled and cannot be canceled.</em>
>   </p>

<h2 id="replay">Send the callback again with Replay request callback</h2>

<p>
  If a request had a callback URL and your receiver missed the delivery, <strong>Replay request callback</strong> sends the callback of a
  completed, expired or canceled request again. It returns an <code>eventId</code>, the same ID as the first delivery, so a receiver that
  deduplicates on it acts only once. See <a href="/requests/callbacks#retries">retries</a>.
</p>

<ul>
  <li>A replay goes to the request's callback URL only. A formbase Trigger subscription retries on its own and is not replayed.</li>
  <li>
    A request created with <a href="/guides/n8n/request-outcome#wait-for-the-outcome">Wait for the Outcome</a> has the resume URL of one
    execution as its callback. Once that execution has finished, the URL is gone, so a replay helps only while the execution is still
    waiting.
  </li>
</ul>

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

<div class="not-prose grid gap-3 sm:grid-cols-2 mb-8">
  - [All guides](/guides/overview)
  - [The Requests page](/requests/managing-requests) — The same actions in the dashboard.
</div>


# Connect an AI agent to formbase

Add formbase to Claude, ChatGPT, Cursor or any tool that supports remote MCP servers, sign in, and pick the workspace the agent may use.

## Connect an AI agent to formbase

An AI agent reaches formbase over MCP, the protocol AI tools use to call other apps. You paste one URL, sign in, and pick a workspace; the agent can then build forms, send requests and read answers in that workspace.

<h2 id="before-you-start">Before you start</h2>

<ul>
  <li>A formbase account that is a member of the workspace you want to connect.</li>
  <li>
    An AI tool that supports remote MCP servers. Some tools limit this to certain plans; the table below links to each tool's own guide.
  </li>
</ul>

<p>
  An agent's work spends no formbase AI credits; your AI tool bills you under its own pricing. See{' '}
  <a href="/ai/ai-credits-usage#mcp">AI credits & usage</a>.
</p>

<h2 id="add-the-url">1. Add the formbase server to your AI tool</h2>

<p>Every tool asks for the same thing, the server URL:</p>

```
https://api.formbase.so/api/mcp
```

<p>
  Leave any client ID, secret or token field empty. The tool finds the sign-in page from the server and registers itself. Where to paste the
  URL depends on the tool:
</p>

Customize</strong> › <strong>Connectors</strong> › <strong>+</strong> › <strong>Add custom connector</strong>. A connector added on claude.ai also works in the desktop and mobile apps. On Team and Enterprise, an owner adds it first under <strong>Organization settings</strong> › <strong>Connectors</strong>; on Free you can add one custom connector.',
        '<a href="https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp">Anthropic</a>',
      ],
    },
    {
      label: 'Claude Code',
      cells: [
        'Run <code>claude mcp add --transport http formbase https://api.formbase.so/api/mcp</code>, then type <code>/mcp</code> in Claude Code to sign in.',
        '<a href="https://code.claude.com/docs/en/mcp">Anthropic</a>',
      ],
    },
    {
      label: 'ChatGPT',
      cells: [
        'Turn on developer mode, then <strong>Settings</strong> › <strong>Apps</strong> › <strong>Create</strong>. Creating and changing forms needs ChatGPT Business, Enterprise or Edu, on the web. On Business, a workspace admin turns on developer mode first.',
        '<a href="https://help.openai.com/en/articles/12584461-developer-mode-and-mcp-apps-in-chatgpt">OpenAI</a>',
      ],
    },
    {
      label: 'Codex',
      cells: [
        'Run <code>codex mcp add formbase --url https://api.formbase.so/api/mcp</code>, then <code>codex mcp login formbase</code>.',
        '<a href="https://learn.chatgpt.com/docs/extend/mcp">OpenAI</a>',
      ],
    },
    {
      label: 'Cursor',
      cells: [
        'Click <a href="https://cursor.com/install-mcp?name=formbase&config=eyJ1cmwiOiJodHRwczovL2FwaS5mb3JtYmFzZS5zby9hcGkvbWNwIn0=">Add to Cursor</a>. Or add <code>{"mcpServers": {"formbase": {"url": "https://api.formbase.so/api/mcp"}}}</code> to <code>.cursor/mcp.json</code> in your project, or to <code>~/.cursor/mcp.json</code> for every project.',
        '<a href="https://cursor.com/docs/mcp">Cursor</a>',
      ],
    },
    {
      label: 'VS Code',
      cells: [
        'Run <strong>MCP: Add Server</strong> from the Command Palette and choose HTTP.',
        '<a href="https://code.visualstudio.com/docs/agent-customization/mcp-servers">Microsoft</a>',
      ],
    },
  ]}
/>

<p>Any other tool that supports remote MCP servers with sign-in works the same way: look for where it adds a server by URL.</p>

<h2 id="authorize">2. Sign in and pick the workspace</h2>

<p>
  The first time the agent uses formbase, your AI tool opens a formbase page in the browser. Sign in if you are asked to. formbase then
  shows which tool is asking, what it may do, and the workspaces you belong to:
</p>

<p>
  Pick the workspace and click <strong>Authorize</strong>. The browser returns to your AI tool. The connection reaches that one workspace
  only; to use another workspace, add formbase to your tool a second time and pick the other one.
</p>

<h2 id="check">3. Check that it works</h2>

<p>Ask the agent something only formbase can answer:</p>

```
List my formbase forms and whether each one is published.
```

<p>
  The agent calls formbase and answers with the forms in the workspace you picked. Some tools, like Claude Code, first say they are loading
  tool definitions: formbase offers all its tools at once, and the tool fetches the details of the ones a task needs.
</p>

> ℹ️ **Some actions ask first**
> <p>
>     Deleting a form, unpublishing it, canceling a request and a few other actions are marked for confirmation, and most AI tools ask you
>     before running them. The prompt comes from your AI tool, so check its own approval settings. The full list is in the{' '}
>     <a href="/developers/mcp-server#confirmation">MCP server reference</a>.
>   </p>

<h2 id="disconnect">See or remove the connection</h2>

<p>
  In formbase, open <strong>OAuth and API Keys</strong> in the workspace sidebar. Your connection is listed under{' '}
  <strong>Connected apps</strong>, with when it was connected and last used:
</p>

<p>
  Click the <strong>Disconnect</strong> button and confirm. The agent loses access at once, and asks you to sign in again the next time it
  needs formbase.
</p>

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

<div class="not-prose grid gap-3 sm:grid-cols-2">
  - [MCP server reference](/developers/mcp-server) — Every tool, limits, and how to connect with an API token instead.
  - [Build a form with an AI agent](/guides/ai-agents/build-a-form) — Describe the form, watch the preview, and publish it.
</div>


# Build a form with an AI agent

Ask Claude, ChatGPT, Cursor or another connected agent to build a formbase form, watch it take shape in a preview, change it, and publish it.

## Build a form with an AI agent

You describe the form in plain words; the agent builds it in your workspace as a draft and gives you a preview link. You ask for changes the same way, and publish when it looks right.

<p>
  You need an AI tool with <a href="/guides/ai-agents/connect">formbase connected</a>. The prompts below are the ones this guide was tested
  with; any wording works, as long as it says what to ask and whether to publish.
</p>

<h2 id="describe">1. Describe the form</h2>

<p>Name the form, list what it should ask, and say that it stays a draft for now:</p>

```
Build a formbase form called "[Guide] Supplier onboarding". Ask for the company name, a contact email, the company's VAT number, and whether they accept our 30-day payment terms (yes or no). Keep it as a draft and send me the preview link.
```

<p>
  The agent creates the form in the workspace you connected, adds one question at a time, and reads the form back to check the order. It
  picks a question type for each item: an email field for the contact email, a single choice for yes or no. When it is done, it replies with
  the questions it added and a preview link.
</p>

<h2 id="preview">2. Open the preview</h2>

<p>
  The preview link shows the draft as the recipient will see it, and it follows the agent's edits live, so keep it open while you ask for
  changes.
</p>

<p>
  The same form is in formbase under <strong>Forms</strong>, where you can open it in the editor and change anything by hand. What the agent
  builds is an ordinary form.
</p>

<h2 id="change">3. Ask for changes</h2>

<p>Say what to change in the same conversation, naming the questions by their titles:</p>

```
Make the VAT number optional and add a file upload for their certificate of incorporation at the end.
```

<p>The open preview shows the change as soon as the agent makes it:</p>

<p>
  Most of what you set in the editor, you can ask an agent for: required or optional, a description, choice options, conditional logic, the
  theme, translations. For long forms, ask for one part at a time and check the preview in between.
</p>

<h2 id="publish">4. Publish and get the field keys</h2>

<p>When the preview looks right, ask the agent to publish:</p>

```
Looks good. Publish it, give me the link to share, and list each question's field key.
```

<p>
  The agent publishes the form and lists the <a href="/requests/field-keys">field keys</a>. The first publish always creates a
  <a href="/sharing-publishing/sharing-embedding#share-links">share link</a> anyone can open, and the agent gives it to you. An agent reads
  the keys from the published form, so asked before you publish, it says it cannot list them yet.
</p>

<p>
  Each key comes from the question's title: <em>Company name</em> becomes <code>company_name</code>. Keys are how a request prefills a
  question and how the answers come back, and they <a href="/requests/field-keys#freeze">stay the same</a> when you later rename a question.
  An agent cannot choose a key. To shorten a long one, edit it in the editor's
  <a href="/requests/field-keys#where">Keys table</a>, best before the first publish, while no automation uses it yet.
</p>

> ℹ️ **Publishing makes the form live**
> <p>
>     Once published, anyone with the share link can submit the form. To take answers only from named people, ask the agent to revoke the
>     share link and <a href="/guides/ai-agents/send-a-request">send requests</a> instead.
>   </p>

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

<div class="not-prose grid gap-3 sm:grid-cols-2 mb-8">
  - [Send a request with an AI agent](/guides/ai-agents/send-a-request) — Ask one person to complete the form, prefilled, and read their answers back.
  - [Field types](/building-forms/field-types) — Every question type an agent can add.
</div>


# Send a request with an AI agent

Ask a connected agent to send a formbase request to one person, prefill and lock what you already know, and read the answers back when they are done.

## Send a request with an AI agent

A request asks one named recipient to complete a form. This guide has an agent send one with the company name filled in and locked, then read the answers once the recipient submits.

<p>
  You need an AI tool with <a href="/guides/ai-agents/connect">formbase connected</a> and a published form. This guide uses the one built in{' '}
  <a href="/guides/ai-agents/build-a-form">Build a form with an AI agent</a>. What a request is, and every option it takes, is in{' '}
  <a href="/requests/creating-requests">Creating requests</a>.
</p>

<h2 id="send">1. Ask for the request</h2>

<p>Say which form, who it goes to, what you already know, and how the link reaches them:</p>

```
Send a formbase request with the "[Guide] Supplier onboarding" form to Ada Lovelace (ada@acme.example). Fill in the company name "Analytical Engines Ltd" and lock it so she cannot change it. Use supplier-2041 as the external ID. Don't email her; give me the link and I will send it myself.
```

<p>
  The agent finds the form, reads its <a href="/requests/field-keys">field keys</a>, and creates the request. It replies with the link for
  the recipient and when the request expires.
</p>

<ul>
  <li>
    <strong>Fill in and lock.</strong> A filled-in answer is <a href="/requests/creating-requests#prefill">prefilled</a>; a locked one is
    shown but <a href="/requests/creating-requests#locked-fields">cannot be changed</a>.
  </li>
  <li>
    <strong>External ID</strong> is your own id for this piece of work, like a supplier or deal number. You can refer to the request by it
    later, also in a new conversation, instead of by formbase's request ID.
  </li>
  <li>
    <strong>Email or link.</strong> Ask the agent to email the recipient and formbase sends the
    <a href="/requests/invitations-and-reminders">invitation and reminders</a>; that needs a Pro or Business plan. Otherwise you get the
    link and send it yourself. If the form has <strong>Send reminders</strong> on, formbase still emails the recipient its scheduled
    reminders; to prevent that, also say <em>no reminders</em>.
  </li>
</ul>

> ℹ️ **Try it with a test request first**
> <p>
>     Say <em>send a test request</em> while you try this out. A <a href="/requests/creating-requests#test-mode">test request</a> never emails
>     anyone and counts nowhere, so open the link yourself. The Requests page lists it only with the <strong>Show test requests</strong>
>     filter on. To look it up by external ID, tell the agent it is a test request. A test request cannot be reminded.
>   </p>

<h2 id="recipient">2. The recipient completes the form</h2>

<p>The recipient opens the link and finds the company name filled in and locked:</p>

<p>
  The request shows in formbase under <strong>Requests</strong>, pending until the recipient submits and completed after:
</p>

<h2 id="answers">3. Read the answers</h2>

<p>The agent is not told when the recipient submits. Ask it, in the same conversation or a new one:</p>

```
Has the formbase request supplier-2041 been answered? Show me the answers.
```

<p>
  The agent looks the request up by its external ID and, once it is completed, lists each answer. A file answer comes with a download link.
  If the form has a <a href="/requests/decisions-and-approvals">decision question</a>, the reply also says whether the recipient approved,
  declined or asked for changes.
</p>

<p>
  To act on answers without asking, have a workflow receive a <a href="/requests/callbacks">callback</a> when the request finishes, or start
  one from <a href="/guides/zapier/request-outcome">Zapier</a>.
</p>

<h2 id="remind-or-cancel">Remind or cancel</h2>

<p>
  While a request is pending, ask the agent to <em>remind Ada about supplier-2041</em> to send a reminder email now, or to <em>cancel</em>{' '}
  it. Reminders need the recipient's email and a Pro or Business plan. A request gets at most eight reminders, scheduled and manual
  together, and manual ones at least ten minutes apart; see{' '}
  <a href="/requests/invitations-and-reminders">Invitations, reminders & expiry</a>. Canceling is permanent: the link stops working at once,
  and most AI tools ask you to confirm first.
</p>

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

<div class="not-prose grid gap-3 sm:grid-cols-2 mb-8">
  - [The Requests page](/requests/managing-requests) — The Requests page: filters, one request in detail, and what you can do with it.
  - [MCP server reference](/developers/mcp-server) — Every request tool an agent can call, and the limits.
</div>


# Send a request with the REST API

Create an API token, read a form's field keys, and create a prefilled request with curl, from any tool that can make an HTTP call.

## Send a request with the REST API

Any tool with an HTTP step can send a request: Pipedream, a serverless function, a script. This guide does it with curl, so you can see each call before you move it into your own code.

<p>
  You need a published form. This guide uses the one built in <a href="/guides/ai-agents/build-a-form">Build a form with an AI agent</a>:
  company name, contact email, VAT number, a yes or no question about payment terms, and a file upload. Every method and option used here is
  described in <a href="/developers/rest-api">API methods</a>.
</p>

<h2 id="token">1. Create an API token</h2>

<p>
  In formbase, open <strong>OAuth and API Keys</strong> in the workspace sidebar and{' '}
  <a href="/developers/api-tokens#create">create a token</a>. The value is shown once. Keep it in an environment variable, not in your code:
</p>

```
export FORMBASE_TOKEN='fb_...'
```

<p>
  A token reaches the one workspace it was created in. Every call below is a <code>POST</code> to the same URL with the token in the{' '}
  <code>Authorization</code> header; the body names the method and its parameters.
</p>

<h2 id="form-id">2. Find the form ID</h2>

<p>
  Open the form in the editor. The form ID is the part of the address after <code>/forms/</code>:
</p>

```
https://app.formbase.so/<workspace ID>/forms/jx75hdx8vb5hy1x85gm17nqgkn8f674g/edit
```

<p>The commands below use this guide's form ID. Put your own in its place.</p>

<h2 id="fields">3. List the field keys</h2>

<p>
  A request fills in questions, and the answers come back, by <a href="/requests/field-keys">field key</a>. Ask the form for its keys
  instead of guessing them from the titles:
</p>

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

<p>The reply lists each question of the published form. Shortened:</p>

```
{
  "ok": true,
  "data": {
    "published": true,
    "items": [
      { "key": "company_name", "type": "text", "title": "Company name", "required": true, "prefillable": true },
      { "key": "contact_email", "type": "email", "title": "Contact email", "required": true, "prefillable": true },
      { "key": "vat_number", "type": "text", "title": "VAT number", "required": false, "prefillable": true },
      {
        "key": "do_you_accept_our_30_day_payment_terms", "type": "radio", "title": "Do you accept our 30-day payment terms?", "required": true, "prefillable": true,
        "options": [{ "key": "yes", "label": "Yes" }, { "key": "no", "label": "No" }]
      },
      { "key": "certificate_of_incorporation", "type": "file", "title": "Certificate of incorporation", "required": true, "prefillable": false }
    ],
    "hasMore": false
  }
}
```

<p>
  To prefill a choice question, send the option's <code>key</code>, here <code>"yes"</code>, not its label. A question with{' '}
  <code>prefillable: false</code>, like the file upload, only the recipient can answer. The other flags are explained under{' '}
  <a href="/developers/rest-api#fields-list">fields.list</a>.
</p>

<h2 id="create">4. Create a test request</h2>

<p>
  Send the request to Ada Lovelace, with the company name filled in and locked. <code>test: true</code> makes it a{' '}
  <a href="/requests/creating-requests#test-mode">test request</a>: it never emails anyone and counts nowhere, so you can repeat this step
  as often as you like, with a new <code>idempotencyKey</code> each time.
</p>

```
curl -X POST https://api.formbase.so/api/v1 \
  -H "Authorization: Bearer $FORMBASE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "method": "requests.create",
    "params": {
      "formId": "jx75hdx8vb5hy1x85gm17nqgkn8f674g",
      "recipient": { "email": "ada@acme.example", "name": "Ada Lovelace" },
      "prefill": { "company_name": "Analytical Engines Ltd" },
      "readonly": ["company_name"],
      "externalId": "supplier-2043",
      "idempotencyKey": "supplier-2043",
      "test": true
    }
  }'
```

<ul>
  <li>
    <strong>prefill</strong> fills in answers the recipient can still change. A key in <strong>readonly</strong> is{' '}
    <a href="/requests/creating-requests#locked-fields">locked</a> as well.
  </li>
  <li>
    <strong>externalId</strong> is your own id for this piece of work, like a supplier number. It comes back on every read and callback.
  </li>
  <li>
    <strong>idempotencyKey</strong> makes a retry safe. If your code sends the same call twice, formbase returns the first request with{' '}
    <code>deduplicated: true</code> instead of creating a second one.
  </li>
</ul>

<p>The reply carries the request ID and the link for the recipient:</p>

```
{
  "ok": true,
  "data": {
    "id": "m17ayhcnj9xvzkff3atek49bdd8f6872",
    "status": "pending",
    "url": "https://form.formbase.so/r/rq_...",
    "deliveryStatus": "not_requested",
    "externalId": "supplier-2043",
    "deduplicated": false,
    "createdAt": 1790539225370,
    "expiresAt": 1793131225370
  }
}
```

<p>
  <code>deliveryStatus: "not_requested"</code> means formbase sent no email; you deliver the link yourself. To have formbase send the
  invitation and reminders, add <code>"delivery": "email"</code>, which needs a Pro or Business plan. See{' '}
  <a href="/requests/invitations-and-reminders">Invitations, reminders & expiry</a>. If the form has <strong>Send reminders</strong> on, a
  real request with a recipient email still gets the scheduled reminders; add <code>"reminders": []</code> to send none.
</p>

> ℹ️ **Test requests stay out of sight**
> <p>
>     The <strong>Requests</strong> page lists test requests only when you turn on the <strong>Show test requests</strong>
>     filter. Open the <code>url</code> yourself to complete it. Drop <code>test</code> when you send the real thing.
>   </p>

<h2 id="read">5. Complete it and read the answers</h2>

<p>
  Open the <code>url</code> in a browser. The company name is filled in and locked; answer the rest and submit. Then read the request back
  with its ID:
</p>

```
curl -X POST https://api.formbase.so/api/v1 \
  -H "Authorization: Bearer $FORMBASE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"method": "requests.get", "params": {"requestId": "m17ayhcnj9xvzkff3atek49bdd8f6872"}}'
```

<p>
  Once <code>status</code> is <code>completed</code>, the reply holds two maps keyed by field key. <code>answers</code> is for code: a
  choice answer is the option key, a file answer is a list of files with download URLs. <code>display</code> is for people: labels and file
  names.
</p>

```
"answers": {
  "company_name": "Analytical Engines Ltd",
  "do_you_accept_our_30_day_payment_terms": "yes",
  "certificate_of_incorporation": [{ "name": "certificate-of-incorporation.pdf", "type": "application/pdf", "size": 635, "url": "https://api.formbase.so/api/storage/..." }]
},
"display": {
  "company_name": "Analytical Engines Ltd",
  "do_you_accept_our_30_day_payment_terms": "Yes",
  "certificate_of_incorporation": "certificate-of-incorporation.pdf"
}
```

<p>
  Asking again and again until the status changes works for a test, but not in production. Give the request a <code>callbackUrl</code> and
  formbase calls you when it ends; the next guide sets that up.
</p>

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

<div class="not-prose grid gap-3 sm:grid-cols-2 mb-8">
  - [Receive and verify the callback](/guides/rest-api/verify-the-callback) — Get the answers pushed to your endpoint, and check that formbase sent them.
  - [Creating requests](/requests/creating-requests) — Every option a request takes: context, metadata, documents, expiry.
</div>


# Receive and verify the callback

Run a small receiver in Node or Python, check each callback's signature with your workspace's signing secret, and read the answers formbase pushes when a request ends.

## Receive and verify the callback

A request with a callbackUrl ends with a signed POST to that URL: the answers when the recipient submits, or word that it expired or was canceled. This guide runs a receiver on your machine, checks the signature, and prints the answers.

<p>
  You need an API token and a published form, as in <a href="/guides/rest-api/send-a-request">Send a request with the REST API</a>, and Node
  or Python on your machine. What a callback carries, and how retries work, is in <a href="/requests/callbacks">Callbacks & signing</a>.
</p>

<h2 id="secret">1. Copy the signing secret</h2>

<p>
  formbase signs every callback with your workspace's request signing secret. Open <strong>OAuth and API Keys</strong> in the workspace
  sidebar and find the <strong>Request signing secret</strong> card:
</p>

<p>Click the copy button and keep the secret in an environment variable in the terminal where your receiver will run:</p>

```
export FORMBASE_SIGNING_SECRET='rqs_...'
```

> ⚠️ **Do not regenerate it**
> <p>
>     The third button makes a new secret, and the old one stops working at once, also for callbacks already on their way. You do not need it
>     to follow this guide.
>   </p>

<h2 id="receiver">2. Write the receiver</h2>

<p>
  Save the <code>verify</code> function from <a href="/requests/callbacks#verify">Callbacks & signing</a> next to your receiver: as{' '}
  <code>verify.mjs</code> for Node, since it uses <code>import</code>, or as <code>verify.py</code> for Python. The receiver reads the raw
  body, checks the signature, and only then parses the JSON:
</p>

  
    
```
import http from 'node:http'
const secret = process.env.FORMBASE_SIGNING_SECRET
http
  .createServer((req, res) => {
    const chunks = []
    req.on('data', (chunk) => chunks.push(chunk))
    req.on('end', () => {
      const rawBody = Buffer.concat(chunks).toString('utf8')
      const signature = req.headers['x-formbase-signature'] ?? ''
      if (!verifyFormbaseCallback(rawBody, signature, secret)) {
        console.log('rejected: bad signature')
        res.writeHead(401).end()
        return
      }
      const event = JSON.parse(rawBody)
      console.log(event.type, event.data.request.externalId, JSON.stringify(event.data.answers))
      res.writeHead(200).end()
    })
  })
  .listen(8787)
```

  
  
    
```
import json, os
from http.server import BaseHTTPRequestHandler, HTTPServer
from verify import verify_formbase_callback
SECRET = os.environ['FORMBASE_SIGNING_SECRET']
class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        raw_body = self.rfile.read(int(self.headers['Content-Length']))
        signature = self.headers.get('X-formbase-Signature', '')
        if not verify_formbase_callback(raw_body, signature, SECRET):
            print('rejected: bad signature', flush=True)
            self.send_response(401)
            self.end_headers()
            return
        event = json.loads(raw_body)
        print(event['type'], event['data']['request'].get('externalId'), event['data'].get('answers'), flush=True)
        self.send_response(200)
        self.end_headers()
HTTPServer(('', 8787), Handler).serve_forever()
```

  

<p>Hash the body exactly as it arrived. Parsing it and turning it back into JSON changes the bytes, and the signature no longer matches.</p>

<h2 id="tunnel">3. Run it and give it a public URL</h2>

<p>Start the receiver:</p>

```
node receiver.mjs      # or: python3 receiver.py
```

<p>
  formbase only calls public HTTPS addresses, so <code>localhost</code> will not do. While you test, a tunnel gives your machine one.{' '}
  <a href="https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/">
    Cloudflare's quick tunnel
  </a>{' '}
  needs no account. Run it in a second terminal:
</p>

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

<p>
  It prints an address like <code>https://horn-cod-classics-arab.trycloudflare.com</code>. ngrok and similar tools work the same way. In
  production, use your server's own HTTPS URL instead.
</p>

<h2 id="request">4. Send a request with a callback URL</h2>

<p>
  Create a test request as in the <a href="/guides/rest-api/send-a-request#create">previous guide</a>, and add <code>callbackUrl</code> with
  the tunnel address. Use your own form ID:
</p>

```
curl -X POST https://api.formbase.so/api/v1 \
  -H "Authorization: Bearer $FORMBASE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "method": "requests.create",
    "params": {
      "formId": "jx75hdx8vb5hy1x85gm17nqgkn8f674g",
      "recipient": { "email": "ada@acme.example", "name": "Ada Lovelace" },
      "prefill": { "company_name": "Analytical Engines Ltd" },
      "readonly": ["company_name"],
      "externalId": "supplier-2044",
      "idempotencyKey": "supplier-2044",
      "callbackUrl": "https://horn-cod-classics-arab.trycloudflare.com/formbase",
      "test": true
    }
  }'
```

<p>
  Use a new <code>idempotencyKey</code>: the same key with a different body is rejected. Open the <code>url</code> from the reply, answer
  the form, and submit.
</p>

<h2 id="see-it">5. See the callback arrive</h2>

<p>A few seconds after you submit, the receiver prints the event type, your external ID and the answers. The Node receiver prints:</p>

```
request.completed supplier-2044 {"certificate_of_incorporation":[{"name":"certificate-of-incorporation.pdf","size":635,"type":"application/pdf","url":"https://api.formbase.so/api/storage/...",...}],"company_name":"Analytical Engines Ltd","contact_email":"ada@acme.example","do_you_accept_our_30_day_payment_terms":"yes","vat_number":"GB123456789"}
```

<p>
  To see a rejection, send the receiver any POST yourself, for example <code>curl -X POST -d '{}' http://localhost:8787</code>. It has no
  valid signature, so the receiver prints <code>rejected: bad signature</code> and answers 401.
</p>

<h2 id="production">Before you go live</h2>

<ul>
  <li>
    <strong>Answer 2xx within 10 seconds.</strong> formbase marks the callback delivered on any 2xx. A 5xx, a 408, a 429 or a timeout is
    retried for about four hours; any other 4xx, like the 401 above, stops the retries. See{' '}
    <a href="/requests/callbacks#retries">Retries</a>.
  </li>
  <li>
    <strong>
      Branch on <code>type</code>.
    </strong>{' '}
    The same URL also hears <code>request.expired</code> and <code>request.canceled</code>, which carry no answers.
  </li>
  <li>
    <strong>Handle each event once.</strong> A retry carries the same <code>id</code>. Store the ids you have handled and skip a repeat.
  </li>
  <li>
    <strong>
      Check <code>test</code>.
    </strong>{' '}
    A callback from a test request has <code>"test": true</code>. Drop <code>test</code> from
    <code>requests.create</code> for real recipients.
  </li>
</ul>

<p>
  If your receiver was down for longer than the retries last, the answers are not lost. Read them with <code>requests.get</code>, or send
  the callback again with <a href="/developers/rest-api#requests-replay-callback">requests.replayCallback</a> or from the{' '}
  <a href="/requests/managing-requests">Requests page</a>.
</p>

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

<div class="not-prose grid gap-3 sm:grid-cols-2 mb-8">
  - [Callbacks & signing](/requests/callbacks) — The full payload, the three events, and the retry schedule.
  - [API methods](/developers/rest-api) — Every method, with its parameters and responses.
</div>

