Webhooks

Webhooks deliver every non-spam submission to your own server as a signed HTTP POST, so you can sync data, trigger workflows, or fan out to other systems in real time.

Availability

Webhooks are a Pro and above feature. On the Free plan the Webhooks tab is read-only and no deliveries are sent. See Plans & top-ups for what each tier includes and how to upgrade.

How it works

Webhooks are configured per form. In the dashboard, open the form, go to the Webhooks tab, and add an HTTPS URL. When you create the webhook a signing secret (prefixed whsec_) is shown once — copy it and store it securely, because it is never displayed again. You use that secret to verify that incoming requests really came from Formcrest.

After that, every non-spam submission to the form triggers a JSON POST to your URL. Delivery happens off the response hot path: the visitor who submitted the form is never blocked waiting for your endpoint, and a slow or failing endpoint never slows down submission. Submissions that are filtered as spam do not produce a webhook — see Spam protection for how filtering works.

URL requirements

The endpoint URL must be HTTPS and resolve to a public host. URLs that point at private, loopback, or link-local addresses (for example localhost, 127.0.0.1, 10.0.0.0/8, 192.168.0.0/16, or 169.254.0.0/16) are rejected to prevent server-side request forgery (SSRF). For local development, use a public tunnel that terminates HTTPS.

Request headers

Every delivery includes these headers:

HeaderValue
Content-Typeapplication/json
User-AgentFormcrest-Webhook/1.0
X-Formcrest-Webhook-IdThe ID of the webhook that produced this delivery.
X-Formcrest-Delivery-IdA unique ID for this delivery attempt. Use it to deduplicate retries.
X-Formcrest-TimestampUnix time in seconds at the moment the request was signed and sent.
X-Formcrest-Signaturesha256=<hex> — the HMAC-SHA256 signature described below.

Request body

The body is a JSON object. The shape is stable:

{
  "event": "submission.created",
  "submission": {
    "id": "sub_1a2b3c4d5e",
    "form_id": "abc123",
    "created_at": "2026-06-03T10:21:44Z",
    "ip": "203.0.113.7",
    "country": "US",
    "user_agent": "Mozilla/5.0 (...)",
    "referer": "https://example.com/contact",
    "fields": {
      "email": "jane@example.com",
      "message": "Hello from the docs"
    }
  }
}

The fields object contains exactly the form fields the visitor submitted (control fields such as the honeypot are stripped), so its keys depend on your form. The surrounding metadata fields are always present, though country, referer, and similar values may be null when they cannot be determined.

Verifying the signature

Always verify the signature before trusting a payload. Sign and compare over the raw request body bytes — do not re-serialize the parsed JSON first, because any reformatting changes the bytes and breaks the signature. The steps are:

  1. Read the X-Formcrest-Timestamp and X-Formcrest-Signature headers.
  2. Reject the request if |now - timestamp| > 5 minutes. This is replay protection.
  3. Build the signed string timestamp + "." + rawBody and compute HMAC-SHA256 over it with your signing secret, then hex-encode the result.
  4. Strip the sha256= prefix from the header value and compare it to your computed hex digest using a constant-time comparison. Reject the request if they differ.

Constant-time comparison matters: comparing with == can leak information through timing. Each language's standard library provides a safe primitive, shown below.

Node.js

Capture the raw body before any JSON parser consumes it. In Express, the raw buffer is available with express.raw().

const crypto = require("crypto");

function verify(rawBody, headers, secret) {
  const timestamp = headers["x-formcrest-timestamp"];
  const signature = headers["x-formcrest-signature"]; // "sha256=<hex>"
  if (!timestamp || !signature) return false;

  // Replay protection: reject if older/newer than 5 minutes.
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(timestamp)) > 300) return false;

  const expected =
    "sha256=" +
    crypto
      .createHmac("sha256", secret)
      .update(timestamp + "." + rawBody)
      .digest("hex");

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

Python

In Flask, read the raw body with request.get_data() before touching request.json.

import hashlib
import hmac
import time

def verify(raw_body: bytes, headers, secret: str) -> bool:
    timestamp = headers.get("X-Formcrest-Timestamp")
    signature = headers.get("X-Formcrest-Signature")  # "sha256=<hex>"
    if not timestamp or not signature:
        return False

    # Replay protection: reject if older/newer than 5 minutes.
    if abs(int(time.time()) - int(timestamp)) > 300:
        return False

    signed = timestamp.encode() + b"." + raw_body
    digest = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    expected = "sha256=" + digest

    return hmac.compare_digest(expected, signature)

PHP

Read the raw body with file_get_contents("php://input") and the headers from $_SERVER.

<?php
function verify(string $rawBody, array $headers, string $secret): bool {
    $timestamp = $headers["X-Formcrest-Timestamp"] ?? null;
    $signature = $headers["X-Formcrest-Signature"] ?? null; // "sha256=<hex>"
    if (!$timestamp || !$signature) {
        return false;
    }

    // Replay protection: reject if older/newer than 5 minutes.
    if (abs(time() - (int) $timestamp) > 300) {
        return false;
    }

    $digest = hash_hmac("sha256", $timestamp . "." . $rawBody, $secret);
    $expected = "sha256=" . $digest;

    return hash_equals($expected, $signature);
}

Reliability and retries

Respond with any 2xx status to acknowledge a delivery. Any non-2xx status, or a network-level error such as a timeout or refused connection, is treated as a failure and retried with exponential backoff.

The retry schedule, measured from the original attempt, is:

AttemptWhen
1 (initial)immediately
2+1 minute
3+5 minutes
4+30 minutes
5+2 hours
6+12 hours

That is up to 6 attempts in total. If the delivery still has not succeeded after the final attempt, it is dead-lettered and no longer retried. Because retries can deliver the same submission more than once, make your handler idempotent by keying on X-Formcrest-Delivery-Id or the submission id.

Auto-disable

To protect your endpoint and ours, a webhook that accumulates 10 consecutive failed submissions is automatically disabled. While disabled it sends no deliveries. Fix the endpoint, then re-enable the webhook from the dashboard. A single successful delivery resets the failure counter.

Delivery history

The Webhooks tab shows recent delivery history for each webhook, including the response status and whether an attempt succeeded, failed, or was dead-lettered. Use it to debug a misbehaving endpoint before re-enabling a webhook that auto-disabled.

Related