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
| Option | Type | Default | Description |
|---|
url | string | — | Webhook endpoint URL (required) |
secret | string | — | HMAC signing secret (required) |
retries | number | 3 | Max retry attempts |
retryDelayMs | number | 1000 | Base retry delay in ms |
retryBackoff | "linear" | "exponential" | "exponential" | Backoff strategy |
timeoutMs | number | 30000 | Request timeout in ms |
headers | Record<string, string> | — | Additional request headers |
transform | (response) => unknown | — | Transform payload before sending |
onSuccess | (response, statusCode) => void | — | Called after delivery |
onError | (error, attempt) => void | — | Called on each error |
onRetry | (attempt, delay) => void | — | Called 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:
| Header | Value |
|---|
Content-Type | application/json |
X-FormEngine-Signature | sha256={hmac} |
X-FormEngine-Event | submit |
X-FormEngine-Timestamp | ISO 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.
| Strategy | Formula | Example (1s base, 3 retries) |
|---|
exponential | base * 2^(attempt-1) + jitter | ~1s, ~2s, ~4s |
linear | base * attempt | 1s, 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",
});