# Webhooks

> Send every FormDoc submission to your own server as signed JSON, with automatic retries and a delivery log. Available on paid plans.

On paid plans, FormDoc can send each accepted submission to your own URL. Paid plans are opening soon; see [pricing](https://www.formdoc.dev/pricing/). Add an endpoint from your form’s **Integrations** tab.

## What we send

`POST <your URL>` with `Content-Type: application/json`:

JSON

```json
{
  "event": "submission.created",
  "submission": {
    "id": "01J…",
    "form_id": "01J…",
    "form_name": "Contact",
    "received_at": "2026-10-29T10:00:00.000Z",
    "fields": { "name": "Asha", "email": "asha@example.com", "message": "Hi!" },
    "origin": "https://example.com",
    "country": "IN"
  }
}
```

The **Send test** button sends the same shape with `"event": "test"`.

## Verifying the signature

Every request has an `X-Formdoc-Signature` header: `t=<unix seconds>,v1=<hex HMAC-SHA256>`. The signature is computed over `"<t>.<raw body>"` with your endpoint’s secret (`whsec_…`). The secret is shown once when you add the endpoint, and you can rotate it.

JavaScript

```js
import crypto from "node:crypto";

function verify(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= toleranceSeconds;
  return (
    fresh &&
    parts.v1?.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected))
  );
}
```

## Delivery and retries

-   Any `2xx` response within 10 seconds counts as delivered. Redirects aren’t followed.
-   Timeouts, `408`, `429` and `5xx` responses are retried: 6 attempts in total, after about 1 minute, 5 minutes, 15 minutes, 1 hour and 3 hours. Other responses fail at once.
-   Every attempt appears in the delivery log, which has a **Retry** button.
-   Endpoints must be public `https://` addresses on the default port. IP addresses and internal hostnames are refused.
