Skip to content
Developer Docs

Signature Verification

Each webhook is signed with HMAC-SHA256 using a webhookSecret unique to your organization. Your endpoint must recompute the signature to confirm the event is authentic.

The algorithm

text
HMAC-SHA256( secret, normalizedBody + timestamp ) → hex → Base64
HeaderDescription
zb-timestampUnix milliseconds at send time
zb-signatureSignature generated by Zertiban

Body normalization

The body is normalized before signing: the JSON is parsed and re-serialized with properties sorted alphabetically (no whitespace or line breaks). So two equivalent payloads, {"b":2,"a":1} and {"a":1,"b":2}, produce the same signature.

Detailed steps

  1. Read the raw body before any automatic framework parsing.
  2. Normalize: parse the JSON → serialize with keys sorted alphabetically.
  3. Concatenate normalizedBody + zb-timestamp.
  4. HMAC-SHA256(webhookSecret, step3) → binary digest.
  5. hex(digest) → bytes → Base64 = expected value of zb-signature.

Implementations

java
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import org.apache.commons.codec.binary.Hex;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;

private static final ObjectMapper MAPPER = JsonMapper.builder()
    .enable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY).build();

public boolean isValidWebhook(String rawBody, String timestamp,
                               String signature, String secret) throws Exception {
    // 1. Normalize: parse and re-serialize with properties sorted
    String normalized = MAPPER.writeValueAsString(MAPPER.readValue(rawBody, Object.class));

    // 2. HMAC-SHA256 of message = normalized body + timestamp
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
    byte[] digest = mac.doFinal(
        (normalized + timestamp).getBytes(StandardCharsets.UTF_8));

    // 3. hex → Base64
    String expected = Base64.getEncoder()
        .encodeToString(Hex.encodeHexString(digest)
            .getBytes(StandardCharsets.UTF_8));

    return MessageDigest.isEqual(
        expected.getBytes(StandardCharsets.UTF_8),
        signature.getBytes(StandardCharsets.UTF_8));
}
javascript
const crypto = require('crypto');

function isValidWebhook(rawBody, timestamp, signature, secret) {
  const normalized = JSON.stringify(sortKeysDeep(JSON.parse(rawBody)));
  const digest = crypto
    .createHmac('sha256', secret)
    .update(normalized + timestamp, 'utf8')
    .digest();
  const expected = Buffer.from(digest.toString('hex')).toString('base64');
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

function sortKeysDeep(obj) {
  if (typeof obj !== 'object' || obj === null) return obj;
  if (Array.isArray(obj)) return obj.map(sortKeysDeep);
  return Object.keys(obj)
    .sort()
    .reduce((acc, k) => ({ ...acc, [k]: sortKeysDeep(obj[k]) }), {});
}
// Express: use express.raw() BEFORE express.json() to preserve the raw body
python
import hmac, hashlib, base64, json

def is_valid_webhook(raw_body, timestamp, signature, secret):
    normalized = json.dumps(
        json.loads(raw_body), sort_keys=True, separators=(',', ':'))
    digest = hmac.new(
        secret.encode(), (normalized + timestamp).encode(), hashlib.sha256
    ).digest()
    expected = base64.b64encode(digest.hex().encode()).decode()
    return hmac.compare_digest(expected, signature)

# Flask: use request.get_data(as_text=True) to read the raw body

Best practices

  • Validate before processing. Never process a webhook without first verifying its signature.
  • Constant-time comparison. Use constant-time comparison (hmac.compare_digest, MessageDigest.isEqual, crypto.timingSafeEqual) to avoid timing attacks.
  • Reject invalid signatures with 401 or 403, don't process the event, and log the attempt for auditing.
  • Validate the time window. Only accept if zb-timestamp is within ±5 minutes to prevent replay attacks.

Troubleshooting: signature does not verify

CauseSolution
Body modified before verificationRead the body as raw text. In Express: express.raw(), not express.json()
Wrong webhookSecretConfirm the secret with Zertiban
Wrong property orderSerialize with keys sorted alphabetically
Missing hex→Base64 stepThe algorithm is HMAC → hex → Base64, not HMAC → Base64 directly
zb-timestamp omittedAlways concatenate normalizedBody + timestamp before the HMAC

Cleanly separate the four phases of webhook consumption:

  1. Reception of the HTTP request.
  2. Cryptographic validation of signature and timestamp.
  3. Persistence of the event (for idempotency and auditing).
  4. Functional processing, asynchronously.

This yields consumers that are idempotent, auditable, and resilient to retries, decoupled from business processing.