verifyWebhook function
Checks that a webhook really came from AfriHex and returns the event.
Throws AfriHexException with code INVALID_SIGNATURE otherwise; reply 400.
Pass the raw request body exactly as received (not re-encoded JSON), the request headers, and the secret you gave when you set the webhook up. It checks X-Webhook-Signature-V2, which covers the timestamp too, so an old delivery can't be replayed later.
// Dart Frog / shelf
final event = verifyWebhook(await request.body(), request.headers, secret);
if (event.event == 'trip.completed') markDelivered(event.trip!.id);
Implementation
WebhookEvent verifyWebhook(String rawBody, Map<String, String> headers, String secret, {Duration tolerance = const Duration(minutes: 5), DateTime? now}) {
AfriHexException bad(String m) => AfriHexException('INVALID_SIGNATURE', m, status: 400);
if (secret.isEmpty) throw bad('no webhook secret given');
String? header(String name) {
final want = name.toLowerCase();
for (final e in headers.entries) {
if (e.key.toLowerCase() == want) return e.value;
}
return null;
}
final ts = header('X-Webhook-Timestamp');
final sig = header('X-Webhook-Signature-V2');
if (ts == null || sig == null) throw bad('missing X-Webhook-Timestamp or X-Webhook-Signature-V2 header');
final secs = num.tryParse(ts.trim());
final nowS = (now ?? DateTime.now()).millisecondsSinceEpoch / 1000;
final age = secs == null ? double.infinity : (nowS - secs).abs();
if (age > tolerance.inSeconds) {
throw bad('webhook timestamp is ${age.isFinite ? age.round() : '?'} s from now (allowed ${tolerance.inSeconds} s)');
}
final want = Hmac(sha256, utf8.encode(secret)).convert(utf8.encode('$ts.$rawBody')).toString();
if (!_sameHex(want, sig.trim().toLowerCase())) throw bad('webhook signature does not match');
try {
return WebhookEvent.fromJson((jsonDecode(rawBody) as Map).cast<String, dynamic>());
} catch (_) {
throw bad('webhook body is not a JSON object');
}
}