TheCarGoDevelopers

Webhooks

Verify signatures

Deliveries follow the Standard Webhooks spec. Verify the raw body before parsing it.

Library

js
import { Webhook } from "standardwebhooks";
 
const event = new Webhook(process.env.THECARGO_WEBHOOK_SECRET).verify(rawBody, headers);
python
from standardwebhooks.webhooks import Webhook
 
event = Webhook(os.environ["THECARGO_WEBHOOK_SECRET"]).verify(raw_body, headers)

verify throws on a bad signature or a timestamp older than 5 minutes.

Manual

webhook-signature is v1, + base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body}. The key is the secret after whsec_, base64-decoded; a custom secret without that prefix is used as is.

python
import base64, hashlib, hmac, time
 
def verify(secret: str, headers: dict, body: bytes) -> bool:
    msg_id, ts = headers["webhook-id"], headers["webhook-timestamp"]
    if abs(time.time() - int(ts)) > 300:
        return False
    key = base64.b64decode(secret[6:]) if secret.startswith("whsec_") else secret.encode()
    expected = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()).decode()
    return any(sig.startswith("v1,") and hmac.compare_digest(sig[3:], expected)
               for sig in headers["webhook-signature"].split(" "))
php
function verify(string $secret, array $headers, string $body): bool
{
    $id = $headers['webhook-id'];
    $ts = $headers['webhook-timestamp'];
    if (abs(time() - (int) $ts) > 300) return false;
    $key = str_starts_with($secret, 'whsec_') ? base64_decode(substr($secret, 6)) : $secret;
    $expected = base64_encode(hash_hmac('sha256', "$id.$ts.$body", $key, true));
    foreach (explode(' ', $headers['webhook-signature']) as $sig) {
        if (str_starts_with($sig, 'v1,') && hash_equals($expected, substr($sig, 3))) return true;
    }
    return false;
}