Guides12 min read
Verify form webhook signatures
Check that a form webhook really came from Ikiform: the Standard Webhooks HMAC-SHA256 scheme, a test vector, raw bodies, timestamps and secret rotation.
Recompute the signature yourself and compare it with the one in the request. Ikiform follows Standard Webhooks: the signature is a base64 HMAC-SHA256 of webhook-id, webhook-timestamp and the raw body joined by dots, keyed with the base64-decoded part of your whsec_ secret, and sent as v1,<signature>. Accept the request only if one v1 signature matches in a constant-time compare and the timestamp is within 5 minutes.
A webhook endpoint is a public URL that accepts POST requests from anyone. Without a signature check, anybody who learns the URL can send fake submissions into your CRM, your database or your team's chat. The check is a few lines of code, and getting those lines exactly right is what this guide is about.
How signing works
Ikiform and your server share one secret, shown once when you add the endpoint. Before each delivery attempt, Ikiform hashes the request with that secret and puts the result in a header. Your server repeats the calculation on what it received. Only someone with the secret can produce a matching value, and any change to the body, id or timestamp breaks it.
- Ikiform serializes the event as JSON.
- It joins the delivery id, the attempt's Unix timestamp and the body with dots.
- It computes an HMAC-SHA256 of that string with each active secret of the endpoint.
- It sends each result, base64-encoded and prefixed with v1, in the webhook-signature header.
- Your handler reads the raw body and the three headers before parsing anything.
- It computes the same HMAC with its copy of the secret.
- It compares in constant time against every v1 signature and checks the timestamp is within 5 minutes.
- On a match it answers 2xx and handles the event. Otherwise it answers 400 and does nothing.
The scheme, exactly
Ikiform uses Standard Webhooks, an open spec several webhook providers share. Every delivery carries three headers:
| Header | Value | Notes |
|---|---|---|
webhook-id | dlv_... | The delivery id. The same on every retry, so it doubles as an idempotency key |
webhook-timestamp | Unix seconds, such as 1792300000 | When this attempt was signed. New on every retry |
webhook-signature | v1, plus base64 | One or more space-separated values. Two while a secret is being rolled |
webhook-id: dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
raw body: {"id":"dlv_01k7...","type":"endpoint.test",...}
signed content = webhook-id + "." + webhook-timestamp + "." + raw body
key = base64-decode(secret without "whsec_")
signature = base64(HMAC-SHA256(key, signed content))
header = "v1," + signatureTo verify a delivery, in this order:
- Read the raw body as bytes or an exact string. Don't parse it yet.
- Read the three headers. If any is missing, reject.
- Check the timestamp is an integer within 5 minutes of your clock, in either direction. If not, reject.
- Strip
whsec_from your secret and base64-decode the rest. Those 32 bytes are the HMAC key, not the string itself. - Compute HMAC-SHA256 of
{webhook-id}.{webhook-timestamp}.{raw body}and base64-encode it with the standard alphabet and padding. - Split
webhook-signatureon spaces. For each entry, split on the first comma; skip anything whose version isn'tv1. - Compare each
v1value with yours in constant time. If any matches, the delivery is genuine. - Only now parse the JSON and act on it.
A test vector
Before you deploy a handler, prove it computes the right value. These inputs are a real endpoint.test delivery body signed with a made-up secret. Your code must produce exactly the signature in the last row. The language guides each include a unit test that uses them.
| Input | Value |
|---|---|
| Secret | whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw= |
webhook-id | dlv_01k7v2d4n8r2p6s0w3y5b7c9e1 |
webhook-timestamp | 1792300000 |
| Raw body | The exact bytes in the script below, with no spaces or newline |
Expected webhook-signature | v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA= |
You can check it without writing code. This runs in a terminal on macOS or Linux and prints the base64 part of the signature:
SECRET='whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw='
ID='dlv_01k7v2d4n8r2p6s0w3y5b7c9e1'
TS='1792300000'
BODY='{"id":"dlv_01k7v2d4n8r2p6s0w3y5b7c9e1","type":"endpoint.test","timestamp":"2026-10-17T12:00:00.000Z","api_version":"2026-10-17","data":{"endpoint_id":"whk_01k7v2e1b3d5f7h9k1m3n5q7s9","message":"This is a test event from Ikiform."}}'
KEY=$(printf '%s' "${SECRET#whsec_}" | base64 -d | xxd -p -c 256)
printf '%s.%s.%s' "$ID" "$TS" "$BODY" \
| openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY" -binary \
| base64Two more cases are worth a test each, because they're the two most common real failures:
| Change | Signature | Should your handler accept it? |
|---|---|---|
| The same body pretty-printed with two-space indents | v1,se0TDhknrG0hYMvCId83HnpZfJcELG3ozxFJktRUHqY= | No. Same JSON, different bytes |
webhook-timestamp one second later | v1,oBfWyLDPfrwVSo40e6b7B7+LmKkZX7OqT88SsrpY6ks= | No. The timestamp is signed |
| Signed with a second secret during a roll | v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak= | Yes, if the header also has a value your secret matches |
During a secret roll the header carries both values. Your handler, holding either secret, must accept this:
webhook-id: dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
webhook-signature: v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA= v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=Ways to verify
| Approach | Good at | Trade-offs |
|---|---|---|
verifyWebhook from @ikiform/sdk/webhooks | One call that checks everything and returns a typed event. Runs on Node, Workers, Deno and Bun | JavaScript and TypeScript only |
| A Standard Webhooks library | Maintained code for Python, Go, PHP, Ruby, JavaScript, Rust, Java, C# and Elixir | One more dependency. Check how each wants headers passed; some are case-sensitive |
| Your own HMAC code | No dependency, about 20 lines, and you can test every branch | Easy to get subtly wrong: string comparison, the wrong key bytes or a parsed body |
| A secret path or query token instead | Simple | Leaks through logs, proxies and error trackers, proves nothing about the body and can't expire. Not a substitute |
Ikiform signs every delivery, and the signature is the supported way to authenticate one. Its docs don't publish a fixed list of sending IP addresses, so an IP allowlist isn't a check you can rely on.
Verify in your framework
For JavaScript stacks, the SDK does the work. What changes between frameworks is where the route lives and how to get the body before anything parses it. Pick yours:
An App Router route handler. request.text() is the raw body; calling request.json() first would lose it. The own server guide adds dedupe and after().
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
export async function POST(request: Request) {
const payload = await request.text();
try {
const event = await verifyWebhook({
payload,
headers: request.headers,
secret: process.env.IKIFORM_WEBHOOK_SECRET!,
});
console.info(event.type, request.headers.get("webhook-id"));
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
}An endpoint with a POST export. It needs on-demand rendering, so keep prerender = false and an adapter. getSecret from astro:env/server reads IKIFORM_WEBHOOK_SECRET at runtime and only on the server; import.meta.env would bake it into the build.
import type { APIRoute } from "astro";
import { getSecret } from "astro:env/server";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
const payload = await request.text();
try {
const event = await verifyWebhook({
payload,
headers: request.headers,
secret: getSecret("IKIFORM_WEBHOOK_SECRET") ?? "",
});
console.info(event.type, event.id);
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
};A server route: the same file-based route, with a server.handlers.POST function that receives the standard Request.
import { createFileRoute } from "@tanstack/react-router";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
export const Route = createFileRoute("/api/webhooks/ikiform")({
server: {
handlers: {
POST: async ({ request }) => {
const payload = await request.text();
try {
const event = await verifyWebhook({
payload,
headers: request.headers,
secret: process.env.IKIFORM_WEBHOOK_SECRET!,
});
console.info(event.type, event.id);
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
},
},
},
});A resource route: a module with an action and no default export, registered in routes.ts. It answers Ikiform directly instead of rendering a page.
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("webhooks/ikiform", "routes/webhooks.ikiform.ts"),
] satisfies RouteConfig;import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import type { Route } from "./+types/webhooks.ikiform";
export async function action({ request }: Route.ActionArgs) {
if (request.method !== "POST") return new Response(null, { status: 405 });
const payload = await request.text();
try {
const event = await verifyWebhook({
payload,
headers: request.headers,
secret: process.env.IKIFORM_WEBHOOK_SECRET!,
});
console.info(event.type, event.id);
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
}A +server.ts endpoint. SvelteKit's built-in CSRF check only blocks cross-site form content types, and Ikiform sends application/json, so no config change is needed. Declare IKIFORM_WEBHOOK_SECRET in src/env.ts with defineEnvVars so $app/env/private can read it.
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import { IKIFORM_WEBHOOK_SECRET } from "$app/env/private";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ request }) => {
const payload = await request.text();
try {
const event = await verifyWebhook({ payload, headers: request.headers, secret: IKIFORM_WEBHOOK_SECRET });
console.info(event.type, event.id);
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
};A Nitro server route. readRawBody returns the body as a string before anything parses it. Set NUXT_IKIFORM_WEBHOOK_SECRET and declare ikiformWebhookSecret under runtimeConfig in nuxt.config.ts.
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
export default defineEventHandler(async (event) => {
const payload = (await readRawBody(event, "utf8")) ?? "";
const { ikiformWebhookSecret } = useRuntimeConfig(event);
try {
const delivery = await verifyWebhook({
payload,
headers: getRequestHeaders(event),
secret: ikiformWebhookSecret,
});
console.info(delivery.type, delivery.id);
setResponseStatus(event, 204);
return null;
} catch (error) {
if (error instanceof WebhookVerificationError) {
throw createError({ status: 400, message: error.reason });
}
throw error;
}
});Every handler answers 204 on success and 400 with the failure reason otherwise. Do slow work after answering; Ikiform waits 15 seconds before it counts an attempt as failed.
Why the raw body matters
The signature covers bytes, not meaning. {"a":1} and { "a": 1 } are the same JSON and different bytes, so a body that has been parsed and serialized again almost never matches. The same goes for anything that touches the body on the way in: middleware that parses JSON, a proxy that re-encodes, or a framework that hands you an object instead of a string.
Read the body once, as bytes or a string, verify it, then parse that same string. Each language guide shows where the raw body lives in its frameworks, and the own server guide has a table for the common Node and Python ones.
Compare in constant time
A normal string comparison stops at the first different character. In theory, an attacker who can time thousands of requests learns how many leading characters of a guess are right and builds a valid signature one character at a time. A constant-time compare takes the same time however much matches. Every language has one:
| Language | Constant-time compare |
|---|---|
| Node.js | crypto.timingSafeEqual(a, b), which throws if lengths differ, so check length first |
| Python | hmac.compare_digest(a, b) |
| Go | hmac.Equal(a, b) |
| PHP | hash_equals($known, $user) |
| Ruby | OpenSSL.secure_compare(a, b), or ActiveSupport::SecurityUtils.secure_compare in Rails |
Timestamps and replays
A valid request captured once stays valid forever unless something expires it. The signed timestamp is that something: reject any delivery more than 5 minutes from your clock, in either direction. Because Ikiform signs each attempt again with a fresh timestamp, a retry three days later still passes; only a copied old request fails.
Within those 5 minutes, a replay is still possible, and so is a genuine duplicate: Ikiform retries when it doesn't get a 2xx in time, even if your handler finished. Store webhook-id with a unique constraint and skip ids you've already handled. That closes both.
timestamp_too_old or timestamp_too_new. Managed platforms usually keep clocks synced; on your own VM, run NTP.Rolling the secret
Roll the secret when someone who knew it leaves, when it may have been logged or committed, or on a schedule. Roll signing secret on the endpoint page creates a new one, and for 24 hours every delivery is signed with both:
- Before the roll, each delivery carries one signature, made with the old secret.
- You choose Roll signing secret and copy the new secret.
- For the next 24 hours, each delivery carries two signatures, so a server holding either secret accepts it. Deploy the new secret in this window.
- After 24 hours, deliveries carry only the new signature, and a server still holding the old secret rejects them.
If the old secret is still deployed after 24 hours, every delivery fails. Ikiform retries each one 8 times over about three days, so once you deploy the right secret, use Retry in the delivery log for anything that already gave up.
After the check passes
A valid signature proves the request came from Ikiform and wasn't changed. It doesn't prove the content is safe or new. Three habits finish the job:
- Skip repeats. Store
webhook-id, or the payload'sid, which is the same value, with a unique constraint. A retry of a delivery you already handled becomes a no-op. - Answer first, work later. Return
2xxas soon as the event is stored or queued. Ikiform counts anything slower than 15 seconds as a failure and retries, which brings back the duplicate problem. - Treat fields as untrusted.
data.fieldsholds what a visitor typed into a public form. Escape it before rendering it in HTML, a chat message or an email, and never pass it to a shell or a query unparameterized.
Testing before you go live
Ikiform only delivers to public https: URLs on port 443, so localhost can't receive a webhook. Run a tunnel such as cloudflared tunnel --url http://localhost:3000 and add the URL it prints as a second endpoint while you work. Send test on the endpoint page sends an endpoint.test event once, with no retries, signed exactly like a real delivery. Delete the test endpoint when you're done; it has its own secret, so nothing else changes.
If a delivery fails and you can't see why, copy its headers and body from your logs into the signature verifier. It runs the same check in the browser and tells you which step failed.
Five ways verification goes wrong
| Mistake | Symptom | Fix |
|---|---|---|
Using the whole whsec_... string as the key | no_matching_signature on every delivery | Strip whsec_, then base64-decode. The key is 32 bytes |
| Verifying a parsed body | no_matching_signature, sometimes only for some payloads | Verify the raw body; parse after |
| Checking only the first signature | Failures that start right after a roll | Loop over every space-separated v1 entry |
| Hex instead of base64 | no_matching_signature | Standard Webhooks uses standard base64 with padding, not hex or base64url |
| Looking up headers by exact case | missing_headers behind some proxies | Header names are case-insensitive. Ikiform sends them lowercase; look them up case-insensitively |
There are no one-click integrations. Every tool is reached through a signed webhook, directly or through an automation tool such as Zapier or n8n. A workspace can have 3 endpoints, each with its own secret. The full payload, retry schedule and delivery log are in Webhooks.
Pick your situation
Each language page has idiomatic handlers, a library option and a unit test that runs the test vector from this page.
- Node.jsVerify Ikiform webhook signatures in Node.js: raw bodies in Express and Fastify, the SDK, the standardwebhooks package and a tested node:crypto version.
- PythonVerify Ikiform webhook signatures in Flask, FastAPI and Django, with the standardwebhooks package or the standard library, plus a pytest test.
- Go net/httpVerify Ikiform webhook signatures in Go with net/http: read the body once, check it with crypto/hmac, test it, or use the Standard Webhooks library.
- PHPVerify Ikiform webhook signatures in plain PHP with hash_hmac and hash_equals, and in Laravel with the Standard Webhooks library and a CSRF exception.
- RubyVerify Ikiform webhook signatures in Sinatra with OpenSSL and a Minitest test, and in Rails with the standardwebhooks gem and request.raw_post.
Check it works
Do this on a staging endpoint first, then again on production. Ikiform has no free plan. Testing is free: a workspace without a plan accepts 50 test submissions a month from localhost. To go live, pick Pro from $6/mo for 500 submissions, or pay once: 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire. See pricing.
- Run the test vector through your code: it should produce the expected signature, accept the two-signature header and reject the pretty-printed body.
- In the dashboard, open Webhooks, add your endpoint's public
https:URL and copy thewhsec_secret into your server's environment. - Choose Send test on the endpoint page. The delivery log shows your
204and the event reaches your logs. - Change one character of the secret on your server and send another test. The log shows
400withno_matching_signature. - Submit the form for real and check the
submission.createddelivery succeeds.
Troubleshooting
| Problem | What to do |
|---|---|
400 no_matching_signature | Wrong secret, a parsed body, or a key that wasn't base64-decoded. Run the test vector through the same code path to see which. |
400 timestamp_too_old or timestamp_too_new | The server clock is off, or you're replaying a saved request by hand. Sync the clock, and use Retry in the delivery log, which signs again. |
400 missing_headers | A proxy or framework dropped the webhook-* headers, or the request didn't come from Ikiform. Log every header name your handler sees. |
| Works for test events, fails for real submissions | Real payloads contain non-ASCII characters and longer text. Your code decodes or re-encodes the body before verifying. Verify the exact bytes. |
| Every delivery fails right after rolling the secret | The handler only checks the first signature, or the 24 hours passed with the old secret deployed. Check every v1 entry and deploy the new secret. |
| The delivery log shows a timeout, but your handler ran | Ikiform waits 15 seconds. Answer first and do slow work afterwards, and skip repeated webhook-id values. |
Questions
Recompute the HMAC-SHA256 of the webhook id, timestamp and raw body joined by dots, using the base64-decoded part of your whsec_ secret as the key. Base64-encode the result and compare it in constant time with each v1 value in the webhook-signature header.
Almost always one of three things: the body was parsed and re-serialized before verifying, the whole whsec_ string was used as the key instead of its base64-decoded bytes, or the secret belongs to a different endpoint.
An open specification for signing webhooks with HMAC-SHA256 and the webhook-id, webhook-timestamp and webhook-signature headers. Ikiform follows it, so any Standard Webhooks library can verify Ikiform deliveries.
A captured request would otherwise stay valid forever. Rejecting anything more than 5 minutes from your clock stops replays of old requests, and Ikiform signs every retry with a fresh timestamp, so real retries still pass.
For 24 hours each delivery is signed with both the old and the new secret, so you can deploy the new one without losing events. After that only the new secret works.
Either is fine for Ikiform; any status other than 2xx counts as a failure and is retried. Don't return 410, which disables the endpoint immediately.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.