Verifying webhooks
Check every delivery's signature before you trust it, so a request that didn't come from DawaTrack is rejected.
How the signature is made
Each delivery carries the time it was signed, in Unix seconds, and an HMAC-SHA256 of that time, a full stop and the raw request body, using your subscription's secret as the key, as a hex string:
X-Dawatrack-Timestamp: 1794067200
X-Dawatrack-Signature: sha256=5c0e2b...
To verify: reject the request if the timestamp is more than 5 minutes from your clock, then compute the same HMAC over timestamp + "." + raw body, before parsing the body, and compare in constant time. Checking the time is what stops someone who captured a delivery from sending it again later.
Examples
import hashlib
import hmac
import time
TOLERANCE = 300 # seconds
def is_valid(raw_body: bytes, timestamp: str, signature_header: str, secret: str) -> bool:
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > TOLERANCE:
return False
signed = timestamp.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
received = signature_header.removeprefix("sha256=")
return hmac.compare_digest(expected, received)
const crypto = require("crypto");
const TOLERANCE = 300; // seconds
function isValid(rawBody, timestamp, signatureHeader, secret) {
const ts = Number(timestamp);
if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > TOLERANCE) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(Buffer.concat([Buffer.from(timestamp + "."), rawBody]))
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader.replace(/^sha256=/, ""));
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
<?php
const TOLERANCE = 300; // seconds
function is_valid(string $rawBody, string $timestamp, string $signatureHeader, string $secret): bool {
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > TOLERANCE) {
return false;
}
$expected = hash_hmac("sha256", $timestamp . "." . $rawBody, $secret);
$received = str_replace("sha256=", "", $signatureHeader);
return hash_equals($expected, $received);
}
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
static final long TOLERANCE = 300; // seconds
boolean isValid(byte[] rawBody, String timestamp, String signatureHeader, String secret) throws Exception {
long ts;
try { ts = Long.parseLong(timestamp); } catch (NumberFormatException e) { return false; }
if (Math.abs(System.currentTimeMillis() / 1000 - ts) > TOLERANCE) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
String expected = HexFormat.of().formatHex(mac.doFinal(rawBody));
String received = signatureHeader.replace("sha256=", "");
return MessageDigest.isEqual(expected.getBytes(), received.getBytes());
}
Checklist
- Use the raw body bytes. Parsing and re-encoding JSON changes them and breaks the signature.
- Compare in constant time, as the examples do.
- Reject a request whose signature doesn't match, with any
4xx. - Reject a timestamp more than 5 minutes from your clock, and keep your server's clock synchronised (NTP).
- Ignore an
X-Dawatrack-Event-Idyou have already processed: retries resend the same event. - Keep the secret on your server. If it leaks, delete the subscription and create a new one, which gets a new secret.
Partner API v1. The API reference is generated from the API's own code.