FormThunder isn't open yet. This site describes the service as it will run at launch. Find out more

FormThunder
Menu

Deliveries, updated

Webhooks

A FormThunder webhook is an HTTPS POST of JSON to your URL for each form submission, signed with HMAC-SHA256. Anything other than a 2xx reply retries with exponential backoff for up to 24 hours across 10 attempts, and each attempt's status and last error are visible. Webhooks are on the Pro and Agency plans.

Set one up

bash
curl -X PATCH https://formthunder.com/api/v1/forms/YOUR_FORM_ID \
  -H "Authorization: Bearer ft_live_..." \
  -H "Content-Type: application/json" \
  -d '{"integrations":{"webhook":{"enabled":true,"url":"https://example.com/hooks/forms"}}}'

FormThunder generates a signing secret starting with whsec_ the first time you set a webhook. Copy it from the form's settings. The integrations object is sent whole, so read the form first if it already has other integrations.

You can also send one submission to a URL of its own with a hidden field. It's signed only if the form also has a webhook set, since the secret belongs to that webhook.

html
<input type="hidden" name="webhook" value="https://example.com/hooks/this-form">

Payload

json
{
  "event": "submission.created",
  "submission_id": "8f14e45f-...",
  "form_id": "3f2a7c10-...",
  "form_name": "Contact form",
  "submitted_at": "2026-11-05T09:30:00.000Z",
  "data": { "name": "Priya Raman", "email": "priya@example.com", "message": "Hi" },
  "subject": "New message from the website",
  "from_name": "Acme website",
  "site_url": "https://example.com/contact",
  "attachments": [
    { "filename": "brief.pdf", "size": 48213, "content_type": "application/pdf", "url": "https://formthunder.com/files/..." }
  ]
}

Headers

HeaderValue
Content-Typeapplication/json
User-AgentFormThunder/1.0
X-FormThunder-Signaturet=<unix>,v1=<hex>
X-FormThunder-DeliveryThe delivery's ID. It stays the same across retries, so use it to ignore duplicates.

Verify the signature

The signature is HMAC-SHA256 of <t>.<raw body>, using your signing secret. Compute it over the raw bytes you received, before any JSON parsing. Reject old timestamps to block replays.

js
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyFormThunder(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(",").map((part) => part.split("=")));
  const expected = createHmac("sha256", secret).update(parts.t + "." + rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 ?? "");
  if (a.length !== b.length || !timingSafeEqual(a, b)) return false;
  return Math.abs(Date.now() / 1000 - Number(parts.t)) <= toleranceSeconds;
}
python
import hashlib, hmac, time

def verify_formthunder(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(part.split("=", 1) for part in header.split(","))
    signed = parts["t"].encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, parts.get("v1", "")):
        return False
    return abs(time.time() - int(parts["t"])) <= tolerance

In a Next.js route handler, read the body with await request.text() and parse it after verifying.

ts
export async function POST(request: Request) {
  const raw = await request.text();
  const header = request.headers.get("x-formthunder-signature") ?? "";
  if (!verifyFormThunder(raw, header, process.env.FORMTHUNDER_WEBHOOK_SECRET!)) {
    return new Response("Bad signature", { status: 401 });
  }
  const event = JSON.parse(raw);
  // Store event.submission_id, then do slower work after replying.
  return new Response("ok");
}

Retries

AttemptWaits before it
230 seconds
32 minutes
410 minutes
530 minutes
61 hour
72 hours
84 hours
98 hours
108 hours

A request times out after 30 seconds. A 410 Gone reply stops retries for that delivery straight away. So does a URL that isn't public HTTPS, because we refuse private addresses.

Replay

Failed deliveries can be sent again. Press resend on the submission, or use one of these.

bash
npx formthunder submissions resend YOUR_SUBMISSION_ID
bash
curl -X POST https://formthunder.com/api/v1/submissions/YOUR_SUBMISSION_ID/resend \
  -H "Authorization: Bearer ft_live_..."