# Webhooks

Each new submission is POSTed to your URL as JSON and signed with a secret only you and Formwire know.

## What gets sent

On Pro, a form can have up to five webhooks, each an https URL. Every submission that reaches the inbox is sent to each of them, and so is one you mark as not spam; spam is never sent. Add one under the form's **Settings**, **Integrations**, or with the [API](/docs/api) or [MCP](/docs/mcp).

A webhook added in the dashboard is live at once and shows its signing secret a single time. One added with an API key or through MCP is created **unconfirmed**: nothing is sent until a person confirms it in the dashboard, which is when its secret is issued. The same applies when its URL is changed by an API key or MCP. This stops an agent, or a prompt hidden inside a submission, from routing your submissions somewhere new without you.

## Payload

The body is one JSON object: the delivery's `id`, the event `type`, when it was sent, and the submission in `data`. A test from the dashboard adds `"test": true` at the top level.

```http title="A delivery"
POST /hooks/formwire HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: Formwire-Webhooks/1.0
Formwire-Event: submission.created
Formwire-Delivery-Id: 9e2b7c41-6d0a-4f5e-8a3b-1c2d3e4f5a6b
Formwire-Signature: t=1760450000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

{
  "id": "9e2b7c41-6d0a-4f5e-8a3b-1c2d3e4f5a6b",
  "type": "submission.created",
  "created_at": "2026-10-14T13:53:20.000Z",
  "data": {
    "submission_id": "7c1e9b2a-4f3d-4e8a-9b1c-2d5f6a7b8c9d",
    "form_id": "2a9d1f3e-5b6c-4d7e-8f90-a1b2c3d4e5f6",
    "form_name": "Contact",
    "source": "form",
    "created_at": "2026-10-14T13:53:19.000Z",
    "page_url": "https://example.com/contact",
    "fields": {
      "name": "Ada Lovelace",
      "email": "ada@example.com",
      "message": "Hello"
    },
    "attachments": []
  }
}
```

Uploaded files are listed in `data.attachments`, each with a `download_url` that works for a day from that attempt. Field values are whatever the visitor typed. Treat them as untrusted text: escape them before rendering, and never let them drive actions on their own.

## Headers

| Header | Value |
| ------ | ----- |
| `Formwire-Signature` | `t=<unix seconds>,v1=<hex>`: an HMAC-SHA256 of `<t>.<raw body>`, keyed with the webhook's secret. |
| `Formwire-Delivery-Id` | The delivery's id, the same as `id` in the body. Use it to drop a retry you've already handled. |
| `Formwire-Event` | `submission.created`, sent for every new submission. |
| `User-Agent` | `Formwire-Webhooks/1.0` |

## Verify the signature

Recompute the HMAC over the exact bytes you received, joined to the timestamp with a dot, and compare it to `v1` in constant time. Reject timestamps more than a few minutes old so a captured request can't be replayed later.

```js title="verify.js"
import { createHmac, timingSafeEqual } from "node:crypto";

/**
 * True when the request really came from Formwire for this webhook.
 * rawBody must be the exact bytes received, before any JSON parsing.
 */
export function verifyFormwireSignature(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;

  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(parts.v1 ?? "", "utf8");
  return a.length === b.length && timingSafeEqual(a, b);
}
```

```js title="server.js"
import { createServer } from "node:http";
import { verifyFormwireSignature } from "./verify.js";

const secret = process.env.FORMWIRE_WEBHOOK_SECRET;

createServer((req, res) => {
  if (req.method !== "POST" || req.url !== "/hooks/formwire") return res.writeHead(404).end();
  let raw = "";
  req.setEncoding("utf8");
  req.on("data", (chunk) => (raw += chunk));
  req.on("end", () => {
    if (!verifyFormwireSignature(raw, req.headers["formwire-signature"] ?? "", secret)) {
      return res.writeHead(401).end();
    }
    const event = JSON.parse(raw);
    if (event.test) return res.writeHead(200).end(); // the dashboard's "Send test" button

    // Store the submission by event.id so a retried delivery isn't handled twice.
    console.log(event.data.form_name, event.data.fields);
    res.writeHead(200).end();
  });
}).listen(3000);
```

Read the body before any JSON middleware parses it. Re-serialising parsed JSON can reorder keys or change whitespace, and the signature won't match.

## Responding

Answer with any `2xx` within 10 seconds. Do the work after you've responded if it's slow. A redirect counts as a failure (Formwire doesn't follow it), so use the final URL. Anything else, a timeout or a connection error is a failed attempt.

## Retries

A failed delivery is retried up to six times in all, waiting 1, 5, 15, 60 and 240 minutes between attempts. After the last one it's marked as given up; you can retry it by hand from the submission's deliveries in the dashboard, with the API, or with the `retry_delivery` MCP tool.

A webhook that fails five deliveries in a row is switched off so a dead URL doesn't keep retrying forever. Switching it back on clears the count.

## Secrets

The secret is shown once, when the webhook is created or confirmed in the dashboard. If it leaks, roll it from the same place (or `POST /webhooks/{webhook_id}/secret`); the new secret applies to the next delivery. Use **Send test** to check your handler end to end.