Chapter 07API, Webhooks & Portals
4 min read

Outbound Webhooks & HMAC-SHA256 Signature Verification

Receive real-time HTTP POST event notifications whenever a verified submission arrives and cryptographically verify payload authenticity using HMAC-SHA256.

Key Technical Takeaways (TL;DR)
  • FormFork sends a signed POST request with Content-Type: application/json for every non-spam submission.
  • Verify the X-FormFork-Signature-256 header (formatted as sha256=<hex_digest>) using your endpoint whsec_... signing secret.
  • Always compare digests using constant-time comparison (crypto.timingSafeEqual) against the raw request body.
  • Includes X-FormFork-Event: submission.created and X-FormFork-Delivery unique delivery ID headers.

1. Webhook Event Payload & HTTP Headers

Each webhook delivery includes identifying headers and a normalized JSON body containing form metadata, submitted field values, and request telemetry.

jsonSample submission.created Webhook JSON Payload
{
  "event": "submission.created",
  "deliveryId": "whd_1729624800_9f8a2b",
  "timestamp": "2026-10-22T18:30:00.000Z",
  "form": {
    "id": "form-contact-demo",
    "name": "Website Contact & Enterprise Scope Form"
  },
  "submission": {
    "id": "sub-1729624800-k4m9p",
    "status": "new",
    "data": {
      "name": "Sarah Jenkins",
      "email": "sarah.j@acmecorp.io",
      "inquiry_type": "Enterprise Partnership",
      "message": "Routing qualified inbound leads directly into our CRM."
    },
    "meta": {
      "ipAddress": "192.168.1.45",
      "userAgent": "Mozilla/5.0",
      "referer": "https://acmecorp.io/contact"
    },
    "createdAt": "2026-10-22T18:30:00.000Z"
  }
}

2. Verifying X-FormFork-Signature-256 in Node.js / Next.js

Compute the HMAC-SHA256 digest of the raw UTF-8 request body using your form whsec_... secret and compare it with the X-FormFork-Signature-256 header.

typescriptNode.js / Next.js App Router Webhook Verification Route
import crypto from 'crypto';
import { NextRequest, NextResponse } from 'next/server';

export function verifyFormForkWebhook(
  rawBody: string,
  signatureHeader: string | null,
  webhookSecret: string
): boolean {
  if (!signatureHeader || !signatureHeader.startsWith('sha256=')) {
    return false;
  }

  const receivedHex = signatureHeader.slice('sha256='.length);
  const expectedHex = crypto
    .createHmac('sha256', webhookSecret)
    .update(rawBody, 'utf8')
    .digest('hex');

  if (receivedHex.length !== expectedHex.length) return false;

  return crypto.timingSafeEqual(
    Buffer.from(receivedHex, 'utf8'),
    Buffer.from(expectedHex, 'utf8')
  );
}

export async function POST(req: NextRequest) {
  const rawBody = await req.text();
  const signature = req.headers.get('x-formfork-signature-256');
  const secret = process.env.FORMFORK_WEBHOOK_SECRET || '';

  if (!verifyFormForkWebhook(rawBody, signature, secret)) {
    return NextResponse.json({ error: 'Invalid HMAC signature' }, { status: 401 });
  }

  const event = JSON.parse(rawBody);
  // Process event.submission.data...
  return NextResponse.json({ received: true });
}

Frequently Asked Technical Questions

Why did my HMAC signature check fail when I used JSON.stringify(req.body)?

HMAC signatures are computed over the exact raw request body string sent over the wire. Always read the raw body via await req.text() before parsing JSON, because re-serializing a parsed object can alter key ordering or whitespace.

What HTTP status code should my webhook receiver return?

Return any 2xx status code (such as 200 OK or 202 Accepted) within 10 seconds to acknowledge receipt.

All FormFork Documentation Guides

View Full Documentation Index →