FieldCraftDocsServicesBlogWork With Me →

Webhook Adapter

Send form responses to any URL with HMAC signing and retries.

Install

npm install @squaredr/fieldcraft-webhook

Quick Start

import { createWebhookAdapter } from "@squaredr/fieldcraft-webhook";

const adapter = createWebhookAdapter({
url: "https://api.example.com/form-submissions",
secret: process.env.WEBHOOK_SECRET!,
});

const engine = createEngine(schema, {
adapters: adapter,
});

Configuration

OptionTypeDefaultDescription
urlstringWebhook endpoint URL (required)
secretstringHMAC signing secret (required)
retriesnumber3Max retry attempts
retryDelayMsnumber1000Base retry delay in ms
retryBackoff"linear" | "exponential""exponential"Backoff strategy
timeoutMsnumber30000Request timeout in ms
headersRecord<string, string>Additional request headers
transform(response) => unknownTransform payload before sending
onSuccess(response, statusCode) => voidCalled after delivery
onError(error, attempt) => voidCalled on each error
onRetry(attempt, delay) => voidCalled before each retry

HMAC-SHA256 Signing

Every request is signed with HMAC-SHA256 using your secret. The signature is sent in the X-FormEngine-Signature header.

// Header format:
// X-FormEngine-Signature: sha256=<hex_digest>

// The digest is computed over the raw JSON body:
// HMAC-SHA256(secret, JSON.stringify(body))

Verifying on Your Server

import crypto from "node:crypto";

function verifySignature(body: string, signature: string, secret: string): boolean {
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(body)
.digest("hex");

return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected),
);
}

// In your route handler:
app.post("/form-submissions", (req, res) => {
const sig = req.headers["x-formengine-signature"] as string;
const valid = verifySignature(JSON.stringify(req.body), sig, WEBHOOK_SECRET);

if (!valid) return res.status(401).json({ error: "Invalid signature" });
// Process the submission...
});

Request Headers

Every webhook request includes these headers:

HeaderValue
Content-Typeapplication/json
X-FormEngine-Signaturesha256={hmac}
X-FormEngine-Eventsubmit
X-FormEngine-TimestampISO 8601 timestamp

Additional headers can be added via the headers config option.

Retry Behavior

Failed requests are retried with configurable backoff. The default is exponential backoff with 10% jitter.

StrategyFormulaExample (1s base, 3 retries)
exponentialbase * 2^(attempt-1) + jitter~1s, ~2s, ~4s
linearbase * attempt1s, 2s, 3s

Total attempts = retries + 1 (one initial attempt plus retries). With the default of 3 retries, there are 4 total attempts.

Transform Payload

Use transform to reshape the payload before sending:

const adapter = createWebhookAdapter({
url: "https://api.example.com/submissions",
secret: process.env.WEBHOOK_SECRET!,
transform: (response) => ({
form_id: response.schemaId,
data: response.values,
timestamp: response.submittedAt,
// Only send what the API expects
}),
});

Error Handling

const adapter = createWebhookAdapter({
url: "https://api.example.com/submissions",
secret: process.env.WEBHOOK_SECRET!,
retries: 5,
retryDelayMs: 2000,
retryBackoff: "exponential",
timeoutMs: 15000,
onError: (error, attempt) => {
console.error(`Attempt ${attempt} failed:`, error.message);
},
onRetry: (attempt, delay) => {
console.log(`Retrying in ${delay}ms (attempt ${attempt})...`);
},
onSuccess: (response, statusCode) => {
console.log(`Delivered (HTTP ${statusCode})`);
},
});

Draft Support

The webhook adapter does not support draft persistence. Webhooks are fire-and-forget submission-only. For draft support, combine with a Supabase or Postgres adapter:

import { createWebhookAdapter } from "@squaredr/fieldcraft-webhook";
import { createSupabaseDraftAdapter } from "@squaredr/fieldcraft-supabase";

const engine = createEngine(schema, {
adapters: createWebhookAdapter({ url: "...", secret: "..." }),
draftAdapter: createSupabaseDraftAdapter({ client: supabase }),
sessionToken: "user-123",
});