> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sajn.se/llms.txt
> Use this file to discover all available pages before exploring further.

# Verify webhook signatures

> Confirm that a webhook delivery came from sajn by checking its Standard Webhooks signature

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](https://www.standardwebhooks.com). 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](#verify-without-a-library).

## 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:

```text theme={null}
webhook-id: cm4k2x9p10014abcd1234efgh
webhook-timestamp: 1759311000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
```

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](/webhooks/manage-endpoints#create-an-endpoint) or [rotate its secret](/webhooks/manage-endpoints#rotate-the-signing-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:

<CodeGroup>
  ```bash Node.js theme={null}
  pnpm add standardwebhooks
  ```

  ```bash Python theme={null}
  pip install standardwebhooks
  ```

  ```bash Go theme={null}
  go get github.com/standard-webhooks/standard-webhooks/libraries
  ```
</CodeGroup>

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:

<CodeGroup>
  ```javascript Node.js (Express) theme={null}
  import express from "express";
  import { Webhook } from "standardwebhooks";

  const webhook = new Webhook(process.env.SAJN_WEBHOOK_SECRET);
  const app = express();

  // Keep this route's body as raw bytes. express.json() would parse it first.
  app.post(
    "/webhooks/sajn",
    express.raw({ type: "application/json" }),
    (req, res) => {
      let event;
      try {
        event = webhook.verify(req.body, {
          "webhook-id": req.get("webhook-id"),
          "webhook-timestamp": req.get("webhook-timestamp"),
          "webhook-signature": req.get("webhook-signature"),
        });
      } catch {
        return res.sendStatus(400);
      }

      // Deduplicate on event.id, then queue the work and return quickly.
      console.log(event.id, event.type);
      res.sendStatus(200);
    },
  );

  app.listen(3000);
  ```

  ```python Python (Flask) theme={null}
  import os

  from flask import Flask, abort, request
  from standardwebhooks import Webhook, WebhookVerificationError

  webhook = Webhook(os.environ["SAJN_WEBHOOK_SECRET"])
  app = Flask(__name__)


  @app.post("/webhooks/sajn")
  def sajn_webhook():
      # The bytes exactly as sent, before Flask parses anything.
      raw_body = request.get_data()
      try:
          event = webhook.verify(raw_body, dict(request.headers))
      except WebhookVerificationError:
          abort(400)

      # Deduplicate on event["id"], then queue the work and return quickly.
      print(event["id"], event["type"])
      return "", 200
  ```

  ```go Go theme={null}
  package main

  import (
  	"encoding/json"
  	"io"
  	"log"
  	"net/http"
  	"os"

  	standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"
  )

  func main() {
  	webhook, err := standardwebhooks.NewWebhook(os.Getenv("SAJN_WEBHOOK_SECRET"))
  	if err != nil {
  		log.Fatal(err)
  	}

  	http.HandleFunc("POST /webhooks/sajn", func(w http.ResponseWriter, r *http.Request) {
  		rawBody, err := io.ReadAll(r.Body)
  		if err != nil || webhook.Verify(rawBody, r.Header) != nil {
  			http.Error(w, "invalid signature", http.StatusBadRequest)
  			return
  		}

  		var event struct {
  			ID   string `json:"id"`
  			Type string `json:"type"`
  		}
  		if err := json.Unmarshal(rawBody, &event); err != nil {
  			http.Error(w, "invalid JSON", http.StatusBadRequest)
  			return
  		}
  		// Deduplicate on event.ID, then queue the work and return quickly.
  		log.Println(event.ID, event.Type)
  		w.WriteHeader(http.StatusOK)
  	})

  	log.Fatal(http.ListenAndServe(":3000", nil))
  }
  ```
</CodeGroup>

Standard Webhooks also maintains libraries for PHP, Ruby, C#, Java, Kotlin, Rust, and Elixir. For the list, see the [Standard Webhooks repository](https://github.com/standard-webhooks/standard-webhooks/tree/main/libraries).

## Verify without a library

To verify a delivery yourself, do the following:

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

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:

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from "node:crypto";

  const TOLERANCE_SECONDS = 5 * 60;

  export function verifySajnWebhook(rawBody, id, timestamp, signatures, secret) {
    if (!id || !timestamp || !signatures) return false;

    const seconds = Number(timestamp);
    const now = Math.floor(Date.now() / 1000);
    if (!Number.isInteger(seconds) || Math.abs(now - seconds) > TOLERANCE_SECONDS) {
      return false;
    }

    const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
    const expected = crypto
      .createHmac("sha256", key)
      .update(`${id}.${seconds}.`)
      .update(rawBody)
      .digest();

    return signatures.split(" ").some((entry) => {
      const [version, signature] = entry.split(",", 2);
      if (version !== "v1" || !signature) return false;
      const received = Buffer.from(signature, "base64");
      return (
        received.length === expected.length &&
        crypto.timingSafeEqual(received, expected)
      );
    });
  }
  ```

  ```python Python theme={null}
  import base64
  import hashlib
  import hmac
  import time

  TOLERANCE_SECONDS = 5 * 60


  def verify_sajn_webhook(
      raw_body: bytes, msg_id: str, timestamp: str, signatures: str, secret: str
  ) -> bool:
      if not (msg_id and timestamp and signatures):
          return False
      try:
          seconds = int(timestamp)
      except ValueError:
          return False
      if abs(time.time() - seconds) > TOLERANCE_SECONDS:
          return False

      key = base64.b64decode(secret.removeprefix("whsec_"))
      signed = f"{msg_id}.{seconds}.".encode() + raw_body
      expected = base64.b64encode(
          hmac.new(key, signed, hashlib.sha256).digest()
      ).decode()

      for entry in signatures.split(" "):
          version, _, signature = entry.partition(",")
          if version == "v1" and hmac.compare_digest(signature, expected):
              return True
      return False
  ```

  ```php PHP theme={null}
  <?php

  const TOLERANCE_SECONDS = 5 * 60;

  function verifySajnWebhook(
      string $rawBody,
      ?string $id,
      ?string $timestamp,
      ?string $signatures,
      string $secret
  ): bool {
      if (!$id || !$timestamp || !$signatures || !ctype_digit($timestamp)) {
          return false;
      }
      if (abs(time() - (int) $timestamp) > TOLERANCE_SECONDS) {
          return false;
      }

      $key = base64_decode(preg_replace('/^whsec_/', '', $secret));
      $expected = base64_encode(
          hash_hmac('sha256', "$id.$timestamp.$rawBody", $key, true)
      );

      foreach (explode(' ', $signatures) as $entry) {
          $parts = explode(',', $entry, 2);
          if (count($parts) === 2 && $parts[0] === 'v1'
              && hash_equals($expected, $parts[1])) {
              return true;
          }
      }
      return false;
  }

  // The bytes exactly as sent. In Laravel, use $request->getContent().
  $valid = verifySajnWebhook(
      file_get_contents('php://input'),
      $_SERVER['HTTP_WEBHOOK_ID'] ?? null,
      $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? null,
      $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? null,
      getenv('SAJN_WEBHOOK_SECRET')
  );
  ```

  ```go Go theme={null}
  package sajnwebhook

  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/base64"
  	"strconv"
  	"strings"
  	"time"
  )

  const tolerance = 5 * time.Minute

  func Verify(rawBody []byte, id, timestamp, signatures, secret string) bool {
  	seconds, err := strconv.ParseInt(timestamp, 10, 64)
  	if id == "" || signatures == "" || err != nil {
  		return false
  	}
  	age := time.Since(time.Unix(seconds, 0))
  	if age > tolerance || age < -tolerance {
  		return false
  	}

  	key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
  	if err != nil {
  		return false
  	}
  	mac := hmac.New(sha256.New, key)
  	mac.Write([]byte(id + "." + timestamp + "."))
  	mac.Write(rawBody)
  	expected := mac.Sum(nil)

  	for _, entry := range strings.Split(signatures, " ") {
  		version, signature, ok := strings.Cut(entry, ",")
  		if !ok || version != "v1" {
  			continue
  		}
  		received, err := base64.StdEncoding.DecodeString(signature)
  		if err == nil && hmac.Equal(received, expected) {
  			return true
  		}
  	}
  	return false
  }
  ```

  ```ruby Ruby theme={null}
  require "base64"
  require "openssl"

  TOLERANCE_SECONDS = 5 * 60

  def verify_sajn_webhook(raw_body, id, timestamp, signatures, secret)
    return false if [id, timestamp, signatures].any? { |value| value.to_s.empty? }

    seconds = Integer(timestamp, exception: false)
    return false if seconds.nil? || (Time.now.to_i - seconds).abs > TOLERANCE_SECONDS

    key = Base64.strict_decode64(secret.delete_prefix("whsec_"))
    signed = "#{id}.#{seconds}.".b + raw_body.b
    expected = Base64.strict_encode64(OpenSSL::HMAC.digest("SHA256", key, signed))

    signatures.split(" ").any? do |entry|
      version, signature = entry.split(",", 2)
      version == "v1" && !signature.nil? &&
        OpenSSL.secure_compare(signature, expected)
    end
  end
  ```

  ```csharp C# theme={null}
  using System.Security.Cryptography;
  using System.Text;

  static bool VerifySajnWebhook(
      byte[] rawBody, string? id, string? timestamp, string? signatures,
      string secret, int toleranceSeconds = 300)
  {
      if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(signatures)
          || !long.TryParse(timestamp, out var seconds))
      {
          return false;
      }
      var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
      if (Math.Abs(now - seconds) > toleranceSeconds) return false;

      var key = Convert.FromBase64String(
          secret.StartsWith("whsec_") ? secret[6..] : secret);
      var prefix = Encoding.UTF8.GetBytes($"{id}.{seconds}.");
      var signed = new byte[prefix.Length + rawBody.Length];
      prefix.CopyTo(signed, 0);
      rawBody.CopyTo(signed, prefix.Length);
      var expected = HMACSHA256.HashData(key, signed);

      foreach (var entry in signatures.Split(' '))
      {
          var parts = entry.Split(',', 2);
          if (parts.Length != 2 || parts[0] != "v1") continue;
          try
          {
              var received = Convert.FromBase64String(parts[1]);
              if (CryptographicOperations.FixedTimeEquals(received, expected))
              {
                  return true;
              }
          }
          catch (FormatException)
          {
              // Not base64, so not a match. Try the next signature.
          }
      }
      return false;
  }
  ```
</CodeGroup>

## 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:

```bash theme={null}
SECRET="whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
ID="cm4k2x9p10014abcd1234efgh"
BODY='{"id":"cm4k2x9p10014abcd1234efgh","type":"webhook.test","data":{}}'
TIMESTAMP=$(date +%s)
KEY_HEX=$(printf '%s' "${SECRET#whsec_}" | base64 -d | xxd -p -c 256)
SIGNATURE=$(printf '%s.%s.%s' "$ID" "$TIMESTAMP" "$BODY" \
  | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY_HEX" -binary \
  | base64)

curl -i -X POST http://localhost:3000/webhooks/sajn \
  -H "Content-Type: application/json" \
  -H "webhook-id: $ID" \
  -H "webhook-timestamp: $TIMESTAMP" \
  -H "webhook-signature: v1,$SIGNATURE" \
  -d "$BODY"
```

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`](/api-reference/send-a-test-event-to-a-webhook). For more ways to test, see [Test webhooks locally](/webhooks/testing).

## 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:

| Framework | Raw body |
| - | - |
| Express | `express.raw({ type: "application/json" })` on the route, before any `express.json()` middleware |
| Next.js route handler | `await request.text()` |
| Flask | `request.get_data()` |
| Django | `request.body` |
| Laravel | `$request->getContent()` |
| Rails | `request.raw_post` |
| ASP.NET Core | Copy `Request.Body` to a byte array, without model binding |

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](/webhooks/manage-endpoints#rotate-the-signing-secret), 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](/webhooks/overview#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](/upgrading/2026-10) describes.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.