verifyWebhook function

WebhookEvent verifyWebhook(
  1. String rawBody,
  2. Map<String, String> headers,
  3. String secret, {
  4. Duration tolerance = const Duration(minutes: 5),
  5. DateTime? now,
})

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');
  }
}