Verifying Webhook Signatures

When Allegrow delivers a webhook to your endpoint, you can verify that the request genuinely came from us — and wasn't sent by a third party or tampered with in transit — by checking its signature.

Each webhook is signed with HMAC-SHA256 using a secret that is unique to your company. You generate this secret yourself from your Allegrow account, and only you and Allegrow ever hold it.


This applies to every webhook Allegrow sends, including:


Signature verification is optional and backwards compatible. If you don't generate a signing secret, your webhooks are delivered exactly as before, just without a signature header. You can adopt verification whenever you're ready, with no change to your existing integration.




Step 1: Generate a Signing Secret

  1. Ensure that you're logged into an Admin account for Allegrow. Only users who can manage your company settings can see the Webhook Secrets tab.
  2. Select Settings in the bottom left-hand corner of the navigation pane.
  3. Select Webhook Secrets from the top row of options.
  4. Select Generate Secret in the top right-hand corner.
  5. Your new secret will be shown once, beginning with whsec_. Copy it now and store it securely (for example, in your environment variables or a secret manager) — it will not be shown again.

Allegrow starts signing your webhooks with the new secret straight away.

After generating, the screen lists your secret by its prefix and last four characters (e.g. whsec_aB3x…9QzK) so you can identify it, but the full value is never displayed again. If you lose it, generate a new one, update your endpoint, then revoke the old one.




The Signature Header

Every signed webhook includes an X-Allegrow-Signature header:

X-Allegrow-Signature: t=1716729600,v1=5257a8691...d9f2c

The header is a comma-separated list of components:

ComponentDescription
tThe Unix timestamp (in seconds) at which the webhook was signed.
v1An HMAC-SHA256 signature, hex-encoded. There may be more than one v1 value — see Rotating Your Secret.

The signature is computed over the string TIMESTAMP.RAW_BODY — the timestamp, a literal full stop, then the exact raw bytes of the request body. The key is your full signing secret, including the whsec_ prefix, as UTF-8 text.

If your company has no active signing secret, webhooks are sent without this header.




Step 2: Verify the Signature

To verify an incoming webhook:

  1. Read the t and v1 value(s) from the X-Allegrow-Signature header.
  2. Build the signed payload by concatenating the timestamp, a ., and the raw request body: TIMESTAMP.RAW_BODY.
  3. Compute an HMAC-SHA256 of that payload using your full signing secret (including whsec_) as the key, and hex-encode the result.
  4. Compare your computed value against each v1 in the header using a constant-time comparison. If any one matches, the signature is valid.
  5. Check that t is within an acceptable tolerance of the current time (we recommend 300 seconds) to protect against replayed requests.

Use the raw request body — not a re-serialized object. The signature is calculated over the exact bytes we sent. If you parse the JSON and then re-stringify it, key order and whitespace can change and the signature will not match. Capture the raw body before any JSON parsing.


Node.js (Express)

const crypto = require('node:crypto');
const express = require('express');

const app = express();
const SIGNING_SECRET = process.env.ALLEGROW_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 300;

function verifyAllegrowSignature(header, rawBody, secret) {
  if (!header) return false;

  const parts = header.split(',').map((p) => p.trim());
  const tPart = parts.find((p) => p.startsWith('t='));
  if (!tPart) return false;

  const t = tPart.slice(2);
  if (!/^\d+$/.test(t)) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > TOLERANCE_SECONDS) {
    return false; // outside tolerance — possible replay
  }

  const expected = crypto
    .createHmac('sha256', secret)
    .update(t + '.' + rawBody)
    .digest('hex');
  const expectedBuf = Buffer.from(expected, 'hex');

  // Compare against every v1 (there may be two during rotation)
  return parts
    .filter((p) => p.startsWith('v1='))
    .map((p) => p.slice(3))
    .some((v1) => {
      if (!/^[0-9a-f]+$/i.test(v1) || v1.length !== expected.length) return false;
      return crypto.timingSafeEqual(Buffer.from(v1, 'hex'), expectedBuf);
    });
}

// Capture the raw body so it can be hashed byte-for-byte
app.post(
  '/webhooks/allegrow',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const rawBody = req.body.toString('utf8');
    const signature = req.get('X-Allegrow-Signature');

    if (!verifyAllegrowSignature(signature, rawBody, SIGNING_SECRET)) {
      return res.status(400).send('Invalid signature');
    }

    const payload = JSON.parse(rawBody);
    // ... handle the verified webhook ...

    res.sendStatus(200);
  },
);

Python (Flask)

import hashlib
import hmac
import time

from flask import Flask, request, abort

app = Flask(__name__)
SIGNING_SECRET = "whsec_..."  # load from your environment / secret manager
TOLERANCE_SECONDS = 300


def verify_allegrow_signature(header: str, raw_body: bytes, secret: str) -> bool:
    if not header:
        return False

    parts = [p.strip() for p in header.split(",")]
    t_part = next((p for p in parts if p.startswith("t=")), None)
    if not t_part:
        return False

    t = t_part[2:]
    if not (t.isascii() and t.isdigit()):
        return False
    if abs(int(time.time()) - int(t)) > TOLERANCE_SECONDS:
        return False  # outside tolerance — possible replay

    signed = (t + ".").encode("utf-8") + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()

    # Compare against every v1 (there may be two during rotation)
    received = [p[3:] for p in parts if p.startswith("v1=")]
    return any(v1.isascii() and hmac.compare_digest(v1, expected) for v1 in received)


@app.post("/webhooks/allegrow")
def allegrow_webhook():
    signature = request.headers.get("X-Allegrow-Signature", "")
    raw_body = request.get_data()  # raw bytes, before JSON parsing

    if not verify_allegrow_signature(signature, raw_body, SIGNING_SECRET):
        abort(400, "Invalid signature")

    payload = request.get_json()
    # ... handle the verified webhook ...

    return "", 200



Rotating Your Secret

You can rotate your signing secret without any downtime. Your company can have up to two active secrets, and Allegrow signs each webhook with every active secret — the X-Allegrow-Signature header carries one v1 value per secret, so deliveries keep verifying against your old secret while you roll out the new one.

X-Allegrow-Signature: t=1716729600,v1=OLD_HMAC,v1=NEW_HMAC

To rotate:

  1. On the Webhook Secrets screen, select Generate Secret to create a second secret. It is shown once — copy and store it as before.
  2. Both secrets are now Active, and from the next delivery both are used to sign your webhooks.
  3. Update your endpoint to verify with the new secret. (If your code already compares against every v1 value, as in the examples above, it will continue to work throughout the rotation with no change.)
  4. Once your endpoint is verifying with the new secret, return to the screen and Revoke the old one.

Your company can have at most two active signing secrets. To rotate again, first revoke one of the two so there is a single active secret.




Revoking a Secret

Revoking a secret stops Allegrow signing webhooks with it. The change takes effect from the next delivery.

  • If you have another active secret, deliveries simply continue to be signed with that one.
  • If you revoke your only secret, webhooks will be delivered without an X-Allegrow-Signature header. If your endpoint requires the header to be present, it will start rejecting deliveries — so generate and roll out a new secret before revoking your last one.



Best Practices

PracticeWhy
Store your secret securelyTreat it like a password — anyone with it can forge valid signatures. Keep it in environment variables or a secret manager, never in source control.
Use a constant-time comparisonComparing signatures with == can leak information through timing. Use crypto.timingSafeEqual (Node), hmac.compare_digest (Python), or your language's equivalent.
Verify before trusting the payloadReject the request with a 400 before acting on its contents if the signature doesn't match.
Enforce the timestamp toleranceRejecting old timestamps protects you from replayed deliveries.
Compare against every v1Always loop over all v1 values so rotation is seamless.



Troubleshooting

If verification fails, the most common causes are:

  • The raw body was re-serialized. Hash the exact bytes you received, not a parsed-then-restringified object. This is by far the most frequent cause of a mismatch.
  • The wrong secret is being used. Confirm the secret in your endpoint matches the one generated in the app (check the prefix and last four characters).
  • Clock skew. If your server's clock differs from ours by more than your tolerance, the timestamp check will fail. Ensure your server's time is synchronised (e.g. via NTP).

If you have any questions or run into issues setting up verification, please reach out to your Customer Success Manager or email [email protected] and a member of the team will be able to assist.