Skip to main content

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:

Headers
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

Python
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)
Node.js
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
<?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);
}
Java
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-Id you 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.