Skip to main content

Verifying Webhook Signatures

How to verify Anton webhook signatures using HMAC-SHA256 with code examples in Node.js, Python, and Go.

Written by Ryan O
Updated today

Overview

Every webhook delivery from Anton includes a signature header that you must verify before processing the event. This confirms the request genuinely came from Anton and has not been tampered with in transit.

The Signature Header

Each webhook request includes an Anton-Signature header with a timestamp and HMAC-SHA256 signature:

Anton-Signature: t=1708000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

The header contains two components:

  • t -- Unix timestamp (seconds) of when the webhook was sent

  • v1 -- HMAC-SHA256 signature of the signed payload

Verification Steps

  1. Extract the timestamp (t) and signature (v1) from the Anton-Signature header

  2. Construct the signed payload by concatenating the timestamp, a period, and the raw request body: {timestamp}.{raw_body}

  3. Compute the HMAC-SHA256 of the signed payload using your webhook signing secret

  4. Compare the computed signature with the received v1 value using constant-time comparison

  5. Reject the request if the timestamp is more than 5 minutes old (replay protection)

Obtaining Your Signing Secret

The signing secret is returned when you create a webhook subscription. It is only shown once. If you lose it, you can retrieve it via the API:

curl https://api.antonpayments.dev/v1/webhooks/wh_abc123/secret \
  -H "Authorization: Bearer ak_test_..."

Store the secret securely in your environment variables or secrets manager.

Code Examples

Node.js (Express)

const crypto = require("crypto");function verifySignature(payload, header, secret) {
  const parts = Object.fromEntries(
    header.split(",").map(p => p.split("="))
  );
  const timestamp = parts["t"];
  const signature = parts["v1"];  // Reject if older than 5 minutes
  const age = Math.floor(Date.now() / 1000) - parseInt(timestamp);
  if (age > 300) throw new Error("Timestamp too old");  const signedPayload = timestamp + "." + payload;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");  if (!crypto.timingSafeEqual(
    Buffer.from(signature), Buffer.from(expected)
  )) {
    throw new Error("Invalid signature");
  }
}

Python

import hmac
import hashlib
import timedef verify_signature(payload: bytes, header: str, secret: str):
    parts = dict(p.split("=", 1) for p in header.split(","))
    timestamp = parts["t"]
    signature = parts["v1"]    # Reject if older than 5 minutes
    if int(time.time()) - int(timestamp) > 300:
        raise ValueError("Timestamp too old")    signed_payload = f"{timestamp}.{payload.decode()}"
    expected = hmac.new(
        secret.encode(), signed_payload.encode(), hashlib.sha256
    ).hexdigest()    if not hmac.compare_digest(signature, expected):
        raise ValueError("Invalid signature")

Go

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "strconv"
    "strings"
    "time"
)func verifySignature(payload []byte, header, secret string) error {
    parts := make(map[string]string)
    for _, p := range strings.Split(header, ",") {
        kv := strings.SplitN(p, "=", 2)
        if len(kv) == 2 { parts[kv[0]] = kv[1] }
    }    ts, _ := strconv.ParseInt(parts["t"], 10, 64)
    if time.Now().Unix()-ts > 300 {
        return fmt.Errorf("timestamp too old")
    }    signedPayload := fmt.Sprintf("%s.%s", parts["t"], string(payload))
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(signedPayload))
    expected := hex.EncodeToString(mac.Sum(nil))    if !hmac.Equal([]byte(parts["v1"]), []byte(expected)) {
        return fmt.Errorf("invalid signature")
    }
    return nil
}

Important Security Notes

  • Always use constant-time comparison -- Functions like crypto.timingSafeEqual (Node.js), hmac.compare_digest (Python), and hmac.Equal (Go) prevent timing attacks. Never use == or ===.

  • Use the raw request body -- Verify against the raw bytes, not a parsed-and-re-serialized version. JSON re-serialization can change formatting and break the signature.

  • Enforce timestamp checks -- Rejecting events older than 5 minutes prevents replay attacks.

Did this answer your question?