Webhooks

On paid plans, FormDoc can send each accepted submission to your own URL. Add an endpoint from your form’s Integrations tab.

What we send

POST <your URL> with Content-Type: application/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.

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.