Guides

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.

Preet SutharFounder, Ikiform

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.

How Ikiform signs a webhook delivery and how your server checks itIkiform, on every attemptyour handlerJSON bodysubmission.createdid.timestamp.bodythe signedcontentHMAC-SHA256with eachactive secretwebhook-signaturev1,<base64>raw body +headersbefore anyparsingsame HMACwith your copyof the secretconstant-timecompareany v1 match,timestampfresh2xxthen parse andhandle400nothinghandledno matchHow Ikiform signs a webhook delivery and how your server checks itIkiform, on every attemptyour handlerJSON bodysubmission.createdid.timestamp.bodythe signed contentHMAC-SHA256with each activesecretwebhook-signaturev1,<base64>raw body +headersbefore any parsingsame HMACwith your copy ofthe secretconstant-timecompareany v1 match,timestamp fresh2xxthen parse andhandle400nothinghandledno match
  1. Ikiform serializes the event as JSON.
  2. It joins the delivery id, the attempt's Unix timestamp and the body with dots.
  3. It computes an HMAC-SHA256 of that string with each active secret of the endpoint.
  4. It sends each result, base64-encoded and prefixed with v1, in the webhook-signature header.
  5. Your handler reads the raw body and the three headers before parsing anything.
  6. It computes the same HMAC with its copy of the secret.
  7. It compares in constant time against every v1 signature and checks the timestamp is within 5 minutes.
  8. On a match it answers 2xx and handles the event. Otherwise it answers 400 and does nothing.
Nothing secret travels with the request. The signature proves the sender knew the secret, and that the bytes weren't changed on the way.

The scheme, exactly

Ikiform uses Standard Webhooks, an open spec several webhook providers share. Every delivery carries three headers:

HeaderValueNotes
webhook-iddlv_...The delivery id. The same on every retry, so it doubles as an idempotency key
webhook-timestampUnix seconds, such as 1792300000When this attempt was signed. New on every retry
webhook-signaturev1, plus base64One or more space-separated values. Two while a secret is being rolled
What is signed
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," + signature

To verify a delivery, in this order:

  1. Read the raw body as bytes or an exact string. Don't parse it yet.
  2. Read the three headers. If any is missing, reject.
  3. Check the timestamp is an integer within 5 minutes of your clock, in either direction. If not, reject.
  4. Strip whsec_ from your secret and base64-decode the rest. Those 32 bytes are the HMAC key, not the string itself.
  5. Compute HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{raw body} and base64-encode it with the standard alphabet and padding.
  6. Split webhook-signature on spaces. For each entry, split on the first comma; skip anything whose version isn't v1.
  7. Compare each v1 value with yours in constant time. If any matches, the delivery is genuine.
  8. 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.

InputValue
Secretwhsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw=
webhook-iddlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp1792300000
Raw bodyThe exact bytes in the script below, with no spaces or newline
Expected webhook-signaturev1,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:

Terminal
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 \
  | base64

Two more cases are worth a test each, because they're the two most common real failures:

ChangeSignatureShould your handler accept it?
The same body pretty-printed with two-space indentsv1,se0TDhknrG0hYMvCId83HnpZfJcELG3ozxFJktRUHqY=No. Same JSON, different bytes
webhook-timestamp one second laterv1,oBfWyLDPfrwVSo40e6b7B7+LmKkZX7OqT88SsrpY6ks=No. The timestamp is signed
Signed with a second secret during a rollv1,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:

Two signatures
webhook-id: dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
webhook-signature: v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA= v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=
The timestamp in the vector is fixed, so a real verifier rejects it as too old. Tests must pass a fixed current time, which the language guides do. The signature verifier on the webhooks page checks any secret, headers and body in the browser.

Ways to verify

ApproachGood atTrade-offs
verifyWebhook from @ikiform/sdk/webhooksOne call that checks everything and returns a typed event. Runs on Node, Workers, Deno and BunJavaScript and TypeScript only
A Standard Webhooks libraryMaintained code for Python, Go, PHP, Ruby, JavaScript, Rust, Java, C# and ElixirOne more dependency. Check how each wants headers passed; some are case-sensitive
Your own HMAC codeNo dependency, about 20 lines, and you can test every branchEasy to get subtly wrong: string comparison, the wrong key bytes or a parsed body
A secret path or query token insteadSimpleLeaks 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().

app/api/webhooks/ikiform/route.ts
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;
  }
}

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:

LanguageConstant-time compare
Node.jscrypto.timingSafeEqual(a, b), which throws if lengths differ, so check length first
Pythonhmac.compare_digest(a, b)
Gohmac.Equal(a, b)
PHPhash_equals($known, $user)
RubyOpenSSL.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.

Keep the server clock synced. A clock that drifts by more than 5 minutes rejects every delivery with 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:

What webhook-signature contains before, during and after a secret rollbeforev1,<old>rollnew secretshown oncenext 24hoursv1,<old>v1,<new>afterv1,<new>onlydeploy newWhat webhook-signature contains before, during and after a secret rollbeforev1,<old>rollnew secret shown oncenext 24 hoursv1,<old> v1,<new>afterv1,<new> onlydeploy new
  1. Before the roll, each delivery carries one signature, made with the old secret.
  2. You choose Roll signing secret and copy the new secret.
  3. 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.
  4. After 24 hours, deliveries carry only the new signature, and a server still holding the old secret rejects them.
Your handler needs no special code for this, as long as it checks every v1 entry instead of only the first.

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's id, 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 2xx as 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.fields holds 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

MistakeSymptomFix
Using the whole whsec_... string as the keyno_matching_signature on every deliveryStrip whsec_, then base64-decode. The key is 32 bytes
Verifying a parsed bodyno_matching_signature, sometimes only for some payloadsVerify the raw body; parse after
Checking only the first signatureFailures that start right after a rollLoop over every space-separated v1 entry
Hex instead of base64no_matching_signatureStandard Webhooks uses standard base64 with padding, not hex or base64url
Looking up headers by exact casemissing_headers behind some proxiesHeader 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.

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.

  1. Run the test vector through your code: it should produce the expected signature, accept the two-signature header and reject the pretty-printed body.
  2. In the dashboard, open Webhooks, add your endpoint's public https: URL and copy the whsec_ secret into your server's environment.
  3. Choose Send test on the endpoint page. The delivery log shows your 204 and the event reaches your logs.
  4. Change one character of the secret on your server and send another test. The log shows 400 with no_matching_signature.
  5. Submit the form for real and check the submission.created delivery succeeds.

Troubleshooting

ProblemWhat to do
400 no_matching_signatureWrong 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_newThe 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_headersA 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 submissionsReal 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 secretThe 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 ranIkiform waits 15 seconds. Answer first and do slow work afterwards, and skip repeated webhook-id values.

Questions

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.