Skip to main content
Anyone who knows your endpoint’s URL can send it a request. To make sure that a delivery came from sajn and wasn’t changed on the way, check its webhook-signature header before you act on it. sajn signs deliveries according to Standard Webhooks. We recommend that you verify them with an official Standard Webhooks library, which handles the encoding, the timestamp check, multiple signatures, and constant-time comparison for you. If you can’t add a dependency, verify the signature yourself.

How sajn signs a delivery

Before each attempt, sajn computes an HMAC-SHA256 over the event ID, the timestamp, and the raw request body, and sends three headers:
The headers have the following values:
  • webhook-id: the event id, the same value as id in the body.
  • webhook-timestamp: the time of this attempt, in Unix seconds. Each retry gets a new timestamp and signature.
  • webhook-signature: one or more space-separated signatures. Each one is v1, followed by the base64 HMAC-SHA256 of the string <webhook-id>.<webhook-timestamp>.<raw body>.
The HMAC key is the base64 decoding of the part of the secret after the whsec_ prefix. You get the secret when you create the endpoint or rotate its secret. For 24 hours after you rotate the secret, webhook-signature carries two signatures, one per secret, so a receiver that still has the old secret keeps working until you deploy the new one. Accept a request when any signature matches. The timestamp is part of the signed string, so someone who captures a delivery can’t resend it later under a fresh timestamp.

Verify with a Standard Webhooks library

Install the library for your language. Each one reads the secret with its whsec_ prefix, checks the timestamp with a five-minute tolerance, and accepts a request when any of its signatures matches:
Each of the following samples is a complete receiver that reads the raw body, verifies it, and reads the secret from the SAJN_WEBHOOK_SECRET environment variable:
Standard Webhooks also maintains libraries for PHP, Ruby, C#, Java, Kotlin, Rust, and Elixir. For the list, see the Standard Webhooks repository.

Verify without a library

To verify a delivery yourself, do the following:
1

Read the raw body

Read the request body as bytes, before any framework parses it as JSON. A parsed and re-serialized body has different bytes, and its signature doesn’t match.
2

Check the timestamp

Read webhook-timestamp as an integer. Reject the request if it’s more than five minutes away from your server’s clock, in either direction.
3

Compute the expected signature

Remove the whsec_ prefix from your secret and base64-decode the rest. Use the bytes as the key for an HMAC-SHA256 of webhook-id, a period, webhook-timestamp, a period, and the raw body. Base64-encode the result.
4

Compare with each signature

Split webhook-signature on spaces. For each entry, split it on its first comma, skip it if the version isn’t v1, and compare the rest with your result in constant time, such as with crypto.timingSafeEqual in Node.js or hmac.compare_digest in Python. Accept the request if any entry matches.
5

Respond

If a signature matches, return a 2xx status code and process the event. If none does, return 400 Bad Request and don’t process it.
Each of the following functions implements these steps. It takes the raw body, the three header values, and the secret, and returns true for a valid delivery:

Send a signed test request

To test your receiver without sajn, sign a request yourself. The following script signs a body with openssl and posts it to a receiver on port 3000:
Start the receiver with SAJN_WEBHOOK_SECRET set to the same secret. A receiver that works returns HTTP/1.1 200 OK. To have sajn send a real signed event instead, call POST /api/v1/webhooks/{id}/test. For more ways to test, see Test webhooks locally.

Common pitfalls

The body was parsed before verification

Most frameworks parse JSON request bodies automatically. The parsed body serializes back to different bytes, for example with other whitespace, other key order, or å instead of å, so the signature never matches. Read the raw bytes for the webhook route: Verify first, then parse the same bytes.

The clock is wrong

The timestamp check compares webhook-timestamp with your server’s clock. If your clock drifts by more than the tolerance, you reject every delivery. Keep the server’s clock synchronized with NTP. Don’t compare it with the event’s createdAt: createdAt is when the event happened, and on a retry it can be days older than the attempt.

The secret doesn’t match

If every delivery fails verification, check the secret:
  • The secret is the one for this endpoint. Each endpoint, and each environment, has its own.
  • The secret has no surrounding whitespace or quotes from your environment file.
  • The key is the base64 decoding of the secret after whsec_, not the secret’s text.
  • The secret is current. Twenty-four hours after a rotation, sajn stops signing with the old secret.

Only the first signature is checked

During a rotation, webhook-signature holds two signatures, and either can be the one that matches your secret. Split the header on spaces and check every entry. The libraries do.

Prevent replays

The signature proves that sajn sent the body. To make sure that you act on each event once, also do the following:
  • Keep the timestamp tolerance short, so an old captured request fails.
  • Deduplicate on webhook-id, the event id, which is the same on every retry and replay. For more information, see How delivery behaves.

Verify a 2026-09 endpoint

An endpoint on API version 2026-09 isn’t signed according to Standard Webhooks. Its deliveries carry X-Sajn-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>, an HMAC over <t>.<raw body> keyed with the secret’s UTF-8 text, with one signature only, even during a rotation. These endpoints also receive the secret in plain text in the X-Sajn-Secret header; don’t trust that header, because it proves nothing about the body. To switch to Standard Webhooks signatures, move the endpoint to 2026-10, as Upgrading to 2026-10 describes.