Webhooks

Verifying signatures

Check that a delivery comes from AdProMaxPay and was not changed or replayed, in ten languages.

Open as Markdown
On this page
  1. The signature header
  2. Verify a delivery
  3. Read the raw body
  4. When you roll the secret
  5. Test your handler

Anyone can send a request to your webhook address. Before you trust a delivery, check its signature: it proves that AdProMaxPay sent this exact body, recently, with your endpoint's secret.

The signature header

Every delivery carries an X-AdProMaxPay-Signature header:

  • t is the time of signing, in Unix seconds;
  • v1 is the HMAC-SHA256, in hexadecimal, of the text t + . + the raw body, keyed with your endpoint's signing secret (apx_whsec_…, used whole as the key).

While you roll a secret, the header carries two v1 values, one per secret. A delivery is genuine when any v1 matches.

X-AdProMaxPay-Signature: t=1790851200,v1=d2e39688034d…9a9879e

Verify a delivery

  1. Read the raw body, exactly as received, before any JSON parsing. A body parsed and serialised again no longer matches its signature.
  2. Split the header on ,, then each part on the first =: keep t and every v1.
  3. Refuse a delivery whose t is more than five minutes from your clock: an old delivery may be a replay.
  4. Compute the HMAC-SHA256 of t + . + the raw body with your secret, in hexadecimal.
  5. Compare it with each v1 in constant time. If one matches, the delivery is genuine.

Answer 400 to a delivery that fails, and do nothing with it. The functions here do the five steps; each is checked against the same signed example.

  • curl
  • Node.js
  • TypeScript
  • Python
  • PHP
  • Ruby
  • Go
  • Java
  • C#
  • Rust
verify-signature.shverify-signature.mjsverify-signature.tsverify_signature.pyverify_signature.phpverify_signature.rbsignature.goWebhookSignature.javaWebhookSignature.cssignature.rs
# Usage: verify_signature <file holding the raw body> "<header>" "<secret>"
# A check by hand; your server should verify in its own language.
# NOW replaces the clock, for tests.
verify_signature() {
  local body_file="$1" header="$2" secret="$3"
  local now="${NOW:-$(date +%s)}" timestamp expected
  timestamp=$(printf '%s\n' "$header" | tr ',' '\n' \
    | sed -n 's/^ *t=//p' | head -n 1)
  case "$timestamp" in '' | *[!0-9]*) return 1 ;; esac
  [ $((now - timestamp)) -le 300 ] || return 1
  [ $((timestamp - now)) -le 300 ] || return 1
  expected=$({ printf '%s.' "$timestamp"; cat "$body_file"; } \
    | openssl dgst -sha256 -hmac "$secret" | sed 's/^.*= //')
  printf '%s\n' "$header" | tr ',' '\n' | sed 's/^ *//' \
    | grep -qx "v1=$expected"
}
import { Buffer } from 'node:buffer'
import { createHmac, timingSafeEqual } from 'node:crypto'

const TOLERANCE_SECONDS = 300

// True when `header` (X-AdProMaxPay-Signature) signs `rawBody`
// (a Buffer or a string) with `secret` and is under five minutes old.
export function verifySignature(rawBody, header, secret, now) {
  let timestamp = null
  const signatures = []
  for (const part of String(header).split(',')) {
    const [key, value] = part.trim().split('=', 2)
    if (key === 't')
      timestamp = value
    else if (key === 'v1' && value !== undefined)
      signatures.push(value)
  }
  if (timestamp === null || !/^\d+$/.test(timestamp))
    return false
  const clock = now ?? Math.floor(Date.now() / 1000)
  if (Math.abs(clock - Number(timestamp)) > TOLERANCE_SECONDS)
    return false
  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest()
  return signatures.some((signature) => {
    const given = Buffer.from(signature, 'hex')
    return given.length === expected.length
      && timingSafeEqual(given, expected)
  })
}
import { Buffer } from 'node:buffer'
import { createHmac, timingSafeEqual } from 'node:crypto'

const TOLERANCE_SECONDS = 300

// True when `header` (X-AdProMaxPay-Signature) signs `rawBody`
// with `secret` and is under five minutes old.
export function verifySignature(
  rawBody: Buffer | string,
  header: string,
  secret: string,
  now: number = Math.floor(Date.now() / 1000),
): boolean {
  let timestamp: string | null = null
  const signatures: string[] = []
  for (const part of header.split(',')) {
    const [key, value] = part.trim().split('=', 2)
    if (key === 't' && value !== undefined)
      timestamp = value
    else if (key === 'v1' && value !== undefined)
      signatures.push(value)
  }
  if (timestamp === null || !/^\d+$/.test(timestamp))
    return false
  if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS)
    return false
  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest()
  return signatures.some((signature) => {
    const given = Buffer.from(signature, 'hex')
    return given.length === expected.length
      && timingSafeEqual(given, expected)
  })
}
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify_signature(raw_body, header, secret, now=None):
    """True when header (X-AdProMaxPay-Signature) signs raw_body
    (bytes) with secret and is under five minutes old."""
    timestamp, signatures = None, []
    for part in header.split(","):
        key, _, value = part.strip().partition("=")
        if key == "t":
            timestamp = value
        elif key == "v1":
            signatures.append(value)
    if not (timestamp and timestamp.isascii() and timestamp.isdigit()):
        return False
    now = int(time.time()) if now is None else now
    if abs(now - int(timestamp)) > TOLERANCE_SECONDS:
        return False
    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256)
    digest = expected.hexdigest().encode()
    return any(hmac.compare_digest(digest, s.encode()) for s in signatures)
<?php
const TOLERANCE_SECONDS = 300;

// True when $header (X-AdProMaxPay-Signature) signs $rawBody
// with $secret and is under five minutes old.
function verify_signature(
    string $rawBody,
    string $header,
    string $secret,
    ?int $now = null
): bool {
    $timestamp = null;
    $signatures = [];
    foreach (explode(',', $header) as $part) {
        $pair = explode('=', trim($part), 2);
        if ($pair[0] === 't' && isset($pair[1])) {
            $timestamp = $pair[1];
        } elseif ($pair[0] === 'v1' && isset($pair[1])) {
            $signatures[] = $pair[1];
        }
    }
    if ($timestamp === null || !ctype_digit($timestamp)) {
        return false;
    }
    if (abs(($now ?? time()) - (int) $timestamp) > TOLERANCE_SECONDS) {
        return false;
    }
    $signed = $timestamp . '.' . $rawBody;
    $expected = hash_hmac('sha256', $signed, $secret);
    foreach ($signatures as $signature) {
        if (hash_equals($expected, $signature)) {
            return true;
        }
    }
    return false;
}
require 'openssl'

TOLERANCE_SECONDS = 300

# True when header (X-AdProMaxPay-Signature) signs raw_body
# with secret and is under five minutes old. Ruby 3.0 or later.
def verify_signature(raw_body, header, secret, now = Time.now.to_i)
  timestamp = nil
  signatures = []
  header.split(',').each do |part|
    key, value = part.strip.split('=', 2)
    timestamp = value if key == 't'
    signatures << value if key == 'v1' && value
  end
  return false unless timestamp&.match?(/\A\d+\z/)
  return false if (now - timestamp.to_i).abs > TOLERANCE_SECONDS

  signed = "#{timestamp}.".b + raw_body.b
  expected = OpenSSL::HMAC.hexdigest('SHA256', secret, signed)
  signatures.any? do |signature|
    OpenSSL.secure_compare(expected, signature)
  end
end
package main

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

const toleranceSeconds = 300

// VerifySignature reports whether header (X-AdProMaxPay-Signature)
// signs rawBody with secret and is under five minutes old.
func VerifySignature(rawBody []byte, header, secret string, now time.Time) bool {
	var timestamp string
	var signatures []string
	for _, part := range strings.Split(header, ",") {
		key, value, found := strings.Cut(strings.TrimSpace(part), "=")
		switch {
		case found && key == "t":
			timestamp = value
		case found && key == "v1":
			signatures = append(signatures, value)
		}
	}
	if timestamp == "" || strings.Trim(timestamp, "0123456789") != "" {
		return false
	}
	signedAt, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil {
		return false
	}
	age := now.Unix() - signedAt
	if age > toleranceSeconds || age < -toleranceSeconds {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(timestamp + "."))
	mac.Write(rawBody)
	expected := mac.Sum(nil)
	for _, signature := range signatures {
		given, err := hex.DecodeString(signature)
		if err == nil && hmac.Equal(given, expected) {
			return true
		}
	}
	return false
}
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public final class WebhookSignature {
    private static final long TOLERANCE_SECONDS = 300;

    // True when header (X-AdProMaxPay-Signature) signs rawBody
    // with secret and is under five minutes old. Java 17 or later.
    public static boolean verify(byte[] rawBody, String header, String secret, long now)
            throws GeneralSecurityException {
        String timestamp = null;
        List<String> signatures = new ArrayList<>();
        for (String part : header.split(",")) {
            String[] pair = part.trim().split("=", 2);
            if (pair.length < 2) {
                continue;
            }
            if (pair[0].equals("t")) {
                timestamp = pair[1];
            } else if (pair[0].equals("v1")) {
                signatures.add(pair[1]);
            }
        }
        if (timestamp == null || !timestamp.matches("\\d{1,18}")) {
            return false;
        }
        if (Math.abs(now - Long.parseLong(timestamp)) > TOLERANCE_SECONDS) {
            return false;
        }
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
        byte[] expected = mac.doFinal(rawBody);
        for (String signature : signatures) {
            try {
                byte[] given = HexFormat.of().parseHex(signature);
                if (MessageDigest.isEqual(expected, given)) {
                    return true;
                }
            } catch (IllegalArgumentException notHexadecimal) {
                // Not one of our signatures.
            }
        }
        return false;
    }

    public static boolean verify(byte[] rawBody, String header, String secret)
            throws GeneralSecurityException {
        return verify(rawBody, header, secret, System.currentTimeMillis() / 1000);
    }
}
using System.Globalization;
using System.Security.Cryptography;
using System.Text;

public static class WebhookSignature
{
    private const long ToleranceSeconds = 300;

    // True when header (X-AdProMaxPay-Signature) signs rawBody
    // with secret and is under five minutes old.
    public static bool Verify(byte[] rawBody, string header, string secret, long? now = null)
    {
        string? timestamp = null;
        var signatures = new List<string>();
        foreach (var part in header.Split(','))
        {
            var pair = part.Trim().Split('=', 2);
            if (pair.Length < 2) continue;
            if (pair[0] == "t") timestamp = pair[1];
            else if (pair[0] == "v1") signatures.Add(pair[1]);
        }
        if (!long.TryParse(timestamp, NumberStyles.None, CultureInfo.InvariantCulture, out var signedAt))
            return false;
        var clock = now ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds();
        if (Math.Abs(clock - signedAt) > ToleranceSeconds)
            return false;

        var signed = Encoding.UTF8.GetBytes(timestamp + ".").Concat(rawBody).ToArray();
        var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), signed);
        foreach (var signature in signatures)
        {
            try
            {
                var given = Convert.FromHexString(signature);
                if (CryptographicOperations.FixedTimeEquals(expected, given))
                    return true;
            }
            catch (FormatException)
            {
                // Not one of our signatures.
            }
        }
        return false;
    }
}
// Cargo.toml: hmac = "0.12", sha2 = "0.10", hex = "0.4"
use hmac::{Hmac, Mac};
use sha2::Sha256;

const TOLERANCE_SECONDS: i64 = 300;

/// True when `header` (X-AdProMaxPay-Signature) signs `raw_body` with
/// `secret` and is under five minutes old. `now` is in Unix seconds.
pub fn verify_signature(raw_body: &[u8], header: &str, secret: &str, now: i64) -> bool {
    let mut timestamp = None;
    let mut signatures = Vec::new();
    for part in header.split(',') {
        match part.trim().split_once('=') {
            Some(("t", value)) => timestamp = Some(value),
            Some(("v1", value)) => signatures.push(value),
            _ => {}
        }
    }
    let Some(timestamp) = timestamp else {
        return false;
    };
    if timestamp.is_empty() || !timestamp.bytes().all(|byte| byte.is_ascii_digit()) {
        return false;
    }
    let Ok(signed_at) = timestamp.parse::<i64>() else {
        return false;
    };
    if (now - signed_at).abs() > TOLERANCE_SECONDS {
        return false;
    }
    signatures.iter().any(|signature| {
        let Ok(given) = hex::decode(signature) else {
            return false;
        };
        let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
            .expect("HMAC takes a key of any length");
        mac.update(timestamp.as_bytes());
        mac.update(b".");
        mac.update(raw_body);
        // Compares in constant time.
        mac.verify_slice(&given).is_ok()
    })
}

Read the raw body

Most frameworks parse JSON before your code runs. Ask for the raw bytes instead, on the webhook route only.

  • Node: with Express, mount express.raw({ type: 'application/json' }) on the route; req.body is then a Buffer. With node:http, collect the request's chunks, as in the card quickstart.
  • PHP: read php://input. In Laravel, $request->getContent().
  • Python: in Flask, request.get_data(); in Django, request.body.
  • Ruby: in Rails, request.raw_post; in Sinatra, request.body.read.
  • Go: io.ReadAll(r.Body).
  • Java: in Spring, a @RequestBody byte[] parameter.
  • C#: copy Request.Body to a MemoryStream before any model binding.
  • Rust: in axum, the Bytes extractor.
  • curl
  • Node.js
  • TypeScript
  • Python
  • PHP
  • Ruby
  • Go
  • Java
  • C#
  • Rust
express.mjsexpress.tsviews.pyweb.phpwebhooks_controller.rbhandler.goWebhookController.javaProgram.cshandler.rs
# From a terminal, send the bytes exactly as they are: --data-binary.
# -d would strip the line breaks, and the signature would not match.
curl -i http://localhost:4242/webhooks/payments \
  -H "Content-Type: application/json" \
  -H "X-AdProMaxPay-Signature: t=$T,v1=$SIG" \
  --data-binary @event.json
app.post('/webhooks/payments',
  express.raw({ type: 'application/json' }), (req, res) => {
    const header = req.get('X-AdProMaxPay-Signature') ?? ''
    const secret = process.env.APX_WEBHOOK_SECRET
    if (!verifySignature(req.body, header, secret))
      return res.sendStatus(400)
    const event = JSON.parse(req.body.toString('utf8'))
    res.sendStatus(200)
    handle(event) // your own code, after answering
  })
app.post('/webhooks/payments',
  express.raw({ type: 'application/json' }),
  (req: Request, res: Response) => {
    const rawBody = req.body as Buffer
    const header = req.get('X-AdProMaxPay-Signature') ?? ''
    const secret = process.env.APX_WEBHOOK_SECRET ?? ''
    if (!verifySignature(rawBody, header, secret))
      return res.sendStatus(400)
    const event = JSON.parse(rawBody.toString('utf8')) as WebhookEvent
    res.sendStatus(200)
    handle(event) // your own code, after answering
  })
@csrf_exempt
@require_POST
def webhook(request):
    header = request.headers.get("X-AdProMaxPay-Signature", "")
    secret = os.environ["APX_WEBHOOK_SECRET"]
    if not verify_signature(request.body, header, secret):
        return HttpResponse(status=400)
    event = json.loads(request.body)
    # Your own code, quickly: answer within 10 seconds.
    return HttpResponse(status=200)
Route::post('/webhooks/payments', function (Request $request) {
    $valid = verify_signature(
        $request->getContent(),
        $request->header('X-AdProMaxPay-Signature', ''),
        getenv('APX_WEBHOOK_SECRET'),
    );
    abort_unless($valid, 400);
    $event = json_decode($request->getContent(), true);
    // Your own code, quickly: answer within 10 seconds.
    return response()->noContent();
});
class WebhooksController < ApplicationController
  skip_forgery_protection

  def create
    valid = verify_signature(
      request.raw_post,
      request.headers['X-AdProMaxPay-Signature'].to_s,
      ENV.fetch('APX_WEBHOOK_SECRET')
    )
    return head :bad_request unless valid

    event = JSON.parse(request.raw_post)
    # Your own code, quickly: answer within 10 seconds.
    head :ok
  end
end
func webhook(w http.ResponseWriter, r *http.Request) {
	rawBody, err := io.ReadAll(r.Body)
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	header := r.Header.Get("X-AdProMaxPay-Signature")
	secret := os.Getenv("APX_WEBHOOK_SECRET")
	if !VerifySignature(rawBody, header, secret, time.Now()) {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	// Your own code, quickly: answer within 10 seconds.
	w.WriteHeader(http.StatusOK)
}
@RestController
public class WebhookController {
    @PostMapping("/webhooks/payments")
    public ResponseEntity<Void> receive(
            @RequestBody byte[] rawBody,
            @RequestHeader("X-AdProMaxPay-Signature") String header)
            throws GeneralSecurityException {
        String secret = System.getenv("APX_WEBHOOK_SECRET");
        if (!WebhookSignature.verify(rawBody, header, secret)) {
            return ResponseEntity.badRequest().build();
        }
        // Your own code, quickly: answer within 10 seconds.
        return ResponseEntity.ok().build();
    }
}
app.MapPost("/webhooks/payments", async (HttpRequest request) =>
{
    using var buffer = new MemoryStream();
    await request.Body.CopyToAsync(buffer);
    var valid = WebhookSignature.Verify(
        buffer.ToArray(),
        request.Headers["X-AdProMaxPay-Signature"].ToString(),
        Environment.GetEnvironmentVariable("APX_WEBHOOK_SECRET")!);
    if (!valid)
        return Results.BadRequest();
    // Your own code, quickly: answer within 10 seconds.
    return Results.Ok();
});
// axum: `Bytes` is the body exactly as it was received.
async fn webhook(headers: HeaderMap, body: Bytes) -> StatusCode {
    let header = headers
        .get("x-adpromaxpay-signature")
        .and_then(|value| value.to_str().ok())
        .unwrap_or("");
    let secret = std::env::var("APX_WEBHOOK_SECRET").unwrap_or_default();
    if !verify_signature(&body, header, &secret, unix_now()) {
        return StatusCode::BAD_REQUEST;
    }
    // Your own code, quickly: answer within 10 seconds.
    StatusCode::OK
}

When you roll the secret

Roll an endpoint's secret on the dashboard when it may have leaked, or as a habit. You choose how long the old secret keeps signing: not at all, one hour or a day. During that time each delivery carries two v1 values, so both your old and your new configuration accept it. Install the new secret before the overlap ends.

Test your handler

Sign a body yourself and send it to your handler: a 2xx means your verification accepts a good delivery. Change one character of the body and send it again: your handler must answer 400.

Terminal
SECRET="$APX_WEBHOOK_SECRET"
BODY='{"id":"evt_handler_test","object":"event",
"type":"payment_intent.succeeded","data":{"object":{}}}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" \
  | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //')

curl -i http://localhost:4242/webhooks/payments \
  -H "Content-Type: application/json" \
  -H "X-AdProMaxPay-Signature: t=$T,v1=$SIG" \
  --data-binary "$BODY"