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| Header | Description |
|---|---|
zb-timestamp | Unix milliseconds at send time |
zb-signature | Signature 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
- Read the raw body before any automatic framework parsing.
- Normalize: parse the JSON → serialize with keys sorted alphabetically.
- Concatenate
normalizedBody + zb-timestamp. HMAC-SHA256(webhookSecret, step3)→ binary digest.hex(digest)→ bytes → Base64 = expected value ofzb-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 bodypython
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 bodyBest 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
401or403, don't process the event, and log the attempt for auditing. - Validate the time window. Only accept if
zb-timestampis within ±5 minutes to prevent replay attacks.
Troubleshooting: signature does not verify
| Cause | Solution |
|---|---|
| Body modified before verification | Read the body as raw text. In Express: express.raw(), not express.json() |
Wrong webhookSecret | Confirm the secret with Zertiban |
| Wrong property order | Serialize with keys sorted alphabetically |
| Missing hex→Base64 step | The algorithm is HMAC → hex → Base64, not HMAC → Base64 directly |
zb-timestamp omitted | Always concatenate normalizedBody + timestamp before the HMAC |
Recommended architecture
Cleanly separate the four phases of webhook consumption:
- Reception of the HTTP request.
- Cryptographic validation of signature and timestamp.
- Persistence of the event (for idempotency and auditing).
- Functional processing, asynchronously.
This yields consumers that are idempotent, auditable, and resilient to retries, decoupled from business processing.