# Custom webhooks

POST submission data to any HTTPS endpoint — your backend, serverless function, or proxy.

## Custom webhooks

Send each completed submission as a signed POST request to any HTTPS endpoint — your backend, automation platform, or serverless function.

<h2 id="how-it-works">How it works</h2>

<p>
  On every submission, formbase POSTs a JSON body to your webhook URL. The request is signed with HMAC-SHA256, retried on failure, and
  logged in the integration's event log. This page covers setup. The exact payload, headers, and signature algorithm live in the{' '}
  <a href="/developers/webhooks-reference">webhook reference</a>.
</p>

<h2 id="add">Add a webhook</h2>

> ℹ️ **Multiple webhooks**
> <p>You can attach multiple webhooks to a single form. Each fires independently for every event.</p>

<h2 id="url-rules">Which URLs formbase accepts</h2>

<ul>
  <li>
    <code>https://</code> everywhere, or <code>http://</code> for <code>localhost</code> and <code>*.localhost</code> during development.
  </li>
  <li>No credentials in the URL, and at most 2,048 characters.</li>
  <li>
    No private or internal addresses. The hostname is resolved and re-checked immediately before <em>every</em> delivery, so a DNS record
    repointed at an internal address after setup is still refused.
  </li>
</ul>

<p>A refused URL is a configuration problem, not a transient one: the delivery fails permanently instead of retrying.</p>

<h2 id="payload">What you receive</h2>

<p>
  Every request is the same event envelope — <code>id</code>, <code>type</code>, <code>createdAt</code>, <code>apiVersion</code>,{' '}
  <code>test</code> and <code>data</code>. Inside <code>data</code> sit the form, the submission (id, respondent email, submitted time, PDF
  link, language), an <code>answers</code> object keyed by <a href="/requests/field-keys">field key</a>, and a <code>display</code> object
  with the same keys as readable text. Each answer appears once, in each map.
</p>

<p>
  See the reference for the full <a href="/developers/webhooks-reference#payload">payload shape</a>,{' '}
  <a href="/developers/webhooks-reference#fields-vs-answers">answers and display</a>, and how a{' '}
  <a href="/building-forms/repeating-groups">repeating group</a> is represented.
</p>

<p>
  To preview the exact body for your form, open the integration and expand <strong>Example payload</strong> under the signing secret. It
  renders your current mapping with sample answers.
</p>

<h2 id="signatures">Verifying signatures</h2>

<p>
  Each request includes an <code>X-formbase-Signature</code> header: <code>t=TIMESTAMP,sha256=HEX</code>, an HMAC-SHA256 of{' '}
  <code>TIMESTAMP.BODY</code> computed with your signing secret. The secret itself is never sent. The reference has a{' '}
  <a href="/developers/webhooks-reference#signing">copy-pasteable verification snippet</a>.
</p>

> ⚠️ **Always verify in production**
> <p>
>     Without verification, anyone who discovers your URL can post fake submissions. Reject requests where the signature is missing or
>     invalid.
>   </p>

<h2 id="abandoned-responses">Abandoned response events</h2>

<p>
  Custom webhooks fire only for completed submissions and edits — never for abandoned drafts. A custom webhook is the one receiver that gets
  both: a Zapier, Make or n8n subscription picks first submissions or edits, never both. For abandoned drafts, use a provider that has an{' '}
  <strong>Abandoned submissions</strong> step: Google Sheets, Airtable, Notion, Slack, Discord, Linear, or GitHub Issues. Each takes its own
  idle window and, where it applies, its own message template. That step requires Pro or Business.
</p>

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

<ul>
  <li>
    A delivery succeeds on any <code>2xx</code>.
  </li>
  <li>
    Up to 5 attempts: the first fires immediately, retries wait at least 1, 2, 4, and 8 minutes. formbase looks for due retries every 30
    minutes, so the last attempt comes about two hours after the first. A <code>Retry-After</code> header on a <code>429</code> or{' '}
    <code>5xx</code> is honored when it asks for a longer wait.
  </li>
  <li>
    <code>429</code>, <code>5xx</code>, timeouts, and connection failures are retried. Every other <code>4xx</code> fails immediately.
  </li>
  <li>
    After 5 consecutive failures the integration auto-pauses and the person who set it up gets an email. Fix the endpoint, then press{' '}
    <strong>Resume</strong>.
  </li>
  <li>
    <code>401</code>, <code>403</code>, and <code>404</code> stop the integration right away with an error status and the same email — there
    is no waiting for five failures.
  </li>
  <li>
    Deliveries that used up all 5 attempts collect in a banner on the integration. <strong>Retry all</strong> re-queues them and reactivates
    a paused integration.
  </li>
</ul>

<h2 id="testing">Testing</h2>

<p>
  <strong>Send a test event</strong> appears on the Finalize step and again on the saved integration. It POSTs a synthesized example
  submission — <code>"John Doe"</code> for text, <code>42</code> for numbers, <code>john@example.com</code> for email — signed and with your
  custom headers, exactly like a real delivery. From the saved integration it also writes a connection-test entry to the event log.
</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
```

<h2 id="faq">FAQ</h2>

  <p>Yes. Each has its own URL, signing secret, and custom headers. All active webhooks fire independently for every submission.</p>

  <p>
    Yes — up to 5, added during setup or later from the integration. <code>Content-Type</code> is set automatically and any attempt to
    override it is ignored.
  </p>

  <p>
    No. The signing secret is generated once when the webhook is created and cannot be changed. If you need a new secret, delete the webhook
    and create a new one.
  </p>

  <p>
    Custom webhooks (configured in Form settings) fire only for completed submissions and updates. For abandoned-draft events, use Slack,
    Discord, or a project-management integration — each has a dedicated abandoned-event tab. If you need abandoned events via HTTP, create a{' '}
    <code>submission_abandoned</code> subscription through the{' '}
    <a href="/developers/webhooks-reference#abandoned-submissions">REST API webhook subscriptions</a>, from Zapier, Make, or any other
    caller.
  </p>

  <p>
    Only for <code>localhost</code> and <code>*.localhost</code> during development. All other URLs must use HTTPS.
  </p>

  <p>
    Delete it from Form settings → Integrations. formbase stops sending requests immediately, and the integration's event history is deleted
    with it.
  </p>

<h2 id="next-steps">Next steps</h2>
<div class="not-prose grid gap-3 sm:grid-cols-2">
  - [Webhook reference](/developers/webhooks-reference) — Payload, headers, and signature verification
  - [Airtable](/integrations/airtable) — Push submissions into an Airtable base
  - [Linear](/integrations/linear) — Turn submissions into Linear issues
  - [Repeating groups](/building-forms/repeating-groups) — Let respondents add as many entries as they need
</div>
