Back to Integrations Guides

Introduction

Anyone can POST to a public URL. The Signature header is how you verify a message genuinely came from AAArdvark – it’s a fingerprint of the exact message body, created using a secret only you and AAArdvark know. Recreate that fingerprint on your side, check it matches, and you have proof the message is real. This is the one part of webhooks really worth getting right.


How the signature is built

The recipe is the same in every language:

  • Method: HMAC-SHA256.
  • Key: your webhook secret (the hex string from the configuration screen).
  • Message: the raw request body, exactly as it arrived – byte for byte.
  • Result: a lowercase hexadecimal string, which should match the Signature header.

The one thing that trips everyone up. Build the fingerprint from the raw bytes of the body, not from JSON you’ve parsed and turned back into text. Re-stringifying can quietly change spacing, key order, or how characters are escaped – and any of those will make the fingerprints disagree even though nothing’s wrong. Nearly every “verification keeps failing” report comes down to this. The samples below all read the raw body for exactly this reason.


Node.js / Express

import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.WEBHOOK_SECRET;

// Important: use express.raw so we get the body as bytes, not as parsed JSON.
app.post('/webhooks/aaardvark',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.header('Signature');
    if (!signature) return res.status(401).send('missing signature');

    const expected = crypto
      .createHmac('sha256', SECRET)
      .update(req.body) // req.body is a Buffer of the raw bytes
      .digest('hex');

    // Constant-time comparison to avoid timing attacks
    const ok = signature.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));

    if (!ok) return res.status(401).send('invalid signature');

    // Verified — safe to parse and handle
    const event = JSON.parse(req.body.toString('utf8'));
    // ... handle event
    res.status(200).send('ok');
  }
);

PHP / Laravel

<?php

use Illuminate\Http\Request;

Route::post('/webhooks/aaardvark', function (Request $request) {
    $secret = env('WEBHOOK_SECRET');
    $signature = $request->header('Signature');

    if (! $signature) {
        return response('missing signature', 401);
    }

    // Important: use the raw body, not Laravel's parsed json input
    $rawBody = $request->getContent();
    $expected = hash_hmac('sha256', $rawBody, $secret);

    if (! hash_equals($expected, $signature)) {
        return response('invalid signature', 401);
    }

    $event = json_decode($rawBody, true);
    // ... handle event

    return response('ok', 200);
});

Python / Flask

import hmac, hashlib, os, json
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ['WEBHOOK_SECRET'].encode('utf-8')

@app.post('/webhooks/aaardvark')
def aaardvark_webhook():
    signature = request.headers.get('Signature')
    if not signature:
        abort(401, 'missing signature')

    # request.get_data() returns the raw body as bytes
    raw = request.get_data()
    expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(signature, expected):
        abort(401, 'invalid signature')

    event = json.loads(raw.decode('utf-8'))
    # ... handle event

    return ('ok', 200)


Still stuck?

File a support ticket with our five-star support team to get more help.

File a ticket

  • This field is for validation purposes and should be left unchanged.
  • Please provide any information that will be helpful in helping you get your issue fixed. What have you tried already? What results did you expect? What did you get instead?

Related Guides