> ## Documentation Index
> Fetch the complete documentation index at: https://formpaste.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Get a signed HTTP POST for every delivered submission - Pro.

<Info>**Pro** - configuring and receiving webhook deliveries requires Pro.</Info>

Formpaste can `POST` every non-spam submission to a URL you control, in real time, signed
so you can verify it actually came from Formpaste.

## Enabling a webhook

1. Open a form in the [dashboard](https://app.formpaste.com) → **Settings** → **Webhooks**.
2. Enter your endpoint's `https://` URL (`webhook_url`) and save.
3. The dashboard shows a **signing secret** (`whsec_…`) for this form - copy it; you'll
   need it to verify incoming requests.

## When it fires

A webhook fires for each **delivered, non-spam** submission - the same submissions that
generate a notification email. Quarantined (spam) submissions do not fire a webhook.
Delivery is best-effort and independent of email: a webhook failure never blocks or affects
your email notification.

## Request

`POST` to your configured URL, `Content-Type: application/json`.

| Header                  | Value                                                   |
| ----------------------- | ------------------------------------------------------- |
| `X-Formpaste-Event`     | `submission.received`                                   |
| `X-Formpaste-Timestamp` | Unix epoch **milliseconds** when the request was signed |
| `X-Formpaste-Signature` | `sha256=<hex>` - HMAC-SHA256 (see Verifying below)      |
| `User-Agent`            | `Formpaste-Webhook/1`                                   |

## Payload

The body is a versioned JSON envelope. Your submitted fields live under `data`:

```json theme={null}
{
  "type": "submission.received",
  "version": 1,
  "id": "sub_...",
  "form_id": "frm_...",
  "created_at": 1720700000000,
  "data": {
    "name": "John Smith",
    "email": "john@example.com",
    "message": "Hello!"
  }
}
```

* `type` / `version` - the envelope. Only `submission.received` (version `1`) exists today;
  new event types may be added later.
* `data` - your submitted fields, already cleaned: `access_key`, the `botcheck` honeypot,
  and any `redirect` control field are stripped and never included.

<Note>
  Fields are nested under `data`, not flattened at the top level - in Zapier, Make,
  Pipedream, or n8n, map `data.email`, `data.name`, etc.
</Note>

## Verifying the signature

The signature is `HMAC-SHA256` over the string `${timestamp}.${rawBody}`, keyed with your
form's signing secret (`whsec_…`, shown in the dashboard; regenerate it anytime). Compare it
against `X-Formpaste-Signature` (`sha256=<hex>`), and reject requests whose
`X-Formpaste-Timestamp` is too old for your tolerance.

```js theme={null}
import crypto from "node:crypto";

function verify(rawBody, headers, secret, toleranceMs = 5 * 60_000) {
  const ts = Number(headers["x-formpaste-timestamp"]);
  if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > toleranceMs) return false;

  const expected =
    "sha256=" +
    crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
  const got = headers["x-formpaste-signature"] ?? "";

  const a = Buffer.from(expected);
  const b = Buffer.from(got);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

Sign over the **raw request body bytes**, before any JSON re-serialization - re-encoding
can reorder keys and break the comparison.

## Retries, backoff & delivery log

If your endpoint doesn't return a `2xx`, Formpaste retries with exponential backoff —
roughly **10s → 60s → 5m → 30m**, up to **5 attempts total** over about 35 minutes, then the
delivery is marked terminal (dead-letter). A `4xx` response is treated as a permanent
failure and is not retried - fix your endpoint, then replay it manually. Network errors and
timeouts (10s) are retried the same as a `5xx`/`429`.

Your endpoint should respond `2xx` quickly and do slow work asynchronously.

The dashboard shows a per-form **delivery log** (event, status, HTTP code, timestamp) with a
**Replay** action to re-send a past delivery once your endpoint is fixed.

## Requirements & limits

* The URL must be **`https://`** - plain HTTP is rejected. Loopback, private-network,
  link-local, and cloud-metadata addresses are rejected as SSRF protection, both on save and
  again before every send.
* One webhook URL per form.

## Sending to Slack / Discord

A raw Formpaste webhook cannot post directly to a Slack or Discord webhook URL - those
expect their own body shape (Slack: `{"text": …}`, Discord: `{"content": …}`) and will
reject the envelope above. Point your Formpaste webhook at an automation platform (Zapier,
Make, Pipedream, n8n) that reshapes the payload into the chat service's format, then have
that automation post to Slack/Discord. There's no built-in, paste-and-works Slack/Discord
integration yet - see the [Slack](/docs/guides/slack-notifications) and
[Discord](/docs/guides/discord-notifications) guides for the webhook-relay recipe.

<Note>
  [Google Sheets](/docs/features/google-sheets) is a real, native integration - connect a sheet
  and rows arrive automatically, no webhook needed. Zapier "native" triggers, Notion, and
  Airtable direct integrations are not built yet; for those, the generic webhook above is
  the working path today.
</Note>

## Free vs. Pro

Free forms cannot configure a webhook. Attempting to save a `webhook_url` on Free returns:

| Code               | HTTP | Meaning                           |
| ------------------ | ---- | --------------------------------- |
| `upgrade_required` | 403  | This feature requires a Pro plan. |

## Zero-config

No webhook fires until you explicitly set a `webhook_url` on a Pro-plan form - a new form
sends nothing.
