Signatures & retries
Verify every payload, survive every retry.
Signature header
Southbill-Signature: t=1755423672,v1=6f1c…9ab2
Signed payload = "{t}.{raw body}", HMAC-SHA256 with your endpoint secret (whsec_…), hex encoded.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(raw: string, header: string, secret: string, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!Number.isFinite(age) || age > toleranceSec) return false;
const expected = createHmac("sha256", secret).update(`${parts.t}.${raw}`).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(String(parts.v1 ?? ""));
return a.length === b.length && timingSafeEqual(a, b);
}
import hashlib, hmac, time
def verify(raw: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
try:
age = abs(time.time() - int(parts["t"]))
except (KeyError, ValueError):
return False
if age > tolerance:
return False
expected = hmac.new(
secret.encode(), f"{parts['t']}.".encode() + raw, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
<?php
function southbill_verify(string $raw, string $header, string $secret, int $tolerance = 300): bool {
$parts = [];
foreach (explode(",", $header) as $p) {
[$k, $v] = array_pad(explode("=", $p, 2), 2, "");
$parts[trim($k)] = $v;
}
if (!isset($parts["t"], $parts["v1"])) return false;
if (abs(time() - (int) $parts["t"]) > $tolerance) return false;
$expected = hash_hmac("sha256", $parts["t"] . "." . $raw, $secret);
return hash_equals($expected, $parts["v1"]);
}
Verify against the raw body — parsing first and re-serializing breaks the signature.
Rules for your endpoint
- Answer
2xxwithin 10 seconds. Do the work asynchronously. - Be idempotent: store
event.idand drop duplicates. - Do not rely on ordering. Compare timestamps or re-fetch the object.
- Reject requests whose signature fails — never "process anyway and log".
Retries
Failed or slow deliveries retry with exponential backoff for 24 hours: roughly 1 min, 5 min, 30 min, 2 h, 6 h, 12 h. After that the delivery is marked failed and stays visible in the logs for manual replay.
An endpoint failing continuously for 72 hours is auto-disabled and you are notified.
Secret rotation
Rotating issues a second active secret; both validate during a 24-hour overlap so you can deploy without dropped events. Old secrets are then destroyed. Secrets are stored encrypted (AES-GCM) and shown once.
Replay
Any delivery can be replayed from the logs with the original payload — the retry counter and signature timestamp are refreshed.