Dart Shelf: verify a Sume webhook with package:crypto HMAC-SHA256

A Dart Shelf server that reads the raw body, checks the sume-v1 HMAC with package:crypto in constant time and exits at boot when the signing secret is empty.

5 min readSume
All posts

In Dart, read the raw body with await request.readAsString() in a Shelf handler, compute Hmac(sha256, utf8.encode(secret)).convert(utf8.encode('$ts.$raw')) from package:crypto, and compare the result with each sume-v1= entry in the x-sume-webhook-signature header. Compare in constant time by hand: Dart has no built-in timingSafeEqual, so XOR every code unit and test the accumulated value at the end.

Sume's webhook docs give the scheme: HMAC SHA-256 over <timestamp>.<raw_body>, a replay window that you enforce (five minutes is the suggested default), and one sume-v1= entry for each live secret during a rotation. Keep the verifier on your backend. A Flutter app should never hold the signing secret.

The server

The server exits at start when SUME_COM_WEBHOOK_SIGNING_SECRET is empty, because an HMAC with an empty key still produces a plausible-looking digest. Add shelf and crypto to pubspec.yaml.

import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as io;

bool same(String a, String b) {
  var d = a.length ^ b.length;
  for (var i = 0; i < a.length && i < b.length; i++) { d |= a.codeUnitAt(i) ^ b.codeUnitAt(i); }
  return d == 0;
}

bool valid(String raw, String? ts, String? header, String secret) {
  final t = int.tryParse(ts ?? '');
  if (t == null) return false;
  if ((DateTime.now().millisecondsSinceEpoch ~/ 1000 - t).abs() > 300) return false;
  final mac = Hmac(sha256, utf8.encode(secret)).convert(utf8.encode('$t.$raw')).toString();
  return (header ?? '').split(',').fold(false, (ok, e) => same(e.trim(), 'sume-v1=$mac') || ok);
}

Future<void> main() async {
  final secret = Platform.environment['SUME_COM_WEBHOOK_SIGNING_SECRET'] ?? '';
  if (secret.isEmpty) { stderr.writeln('SUME_COM_WEBHOOK_SIGNING_SECRET is empty'); exit(1); }
  await io.serve((Request req) async {
    final raw = await req.readAsString();
    final h = req.headers;
    return valid(raw, h['x-sume-webhook-timestamp'], h['x-sume-webhook-signature'], secret)
        ? Response(204) : Response(401);
  }, '0.0.0.0', 8080);
}

What the check does

Each step has one job, and none of them depends on Dart specifics except the constant-time loop.

Verification steps for a Sume job webhook in Dart (Sume docs, read 2026-10-04)
StepReason
Exit when the secret is emptyAn empty key still yields a digest, so forged bodies would pass
int.tryParse on the timestampA missing header returns null instead of throwing
(now - t).abs() > 300Rejects replays outside the five-minute window
Digest over "$t.$raw"The signature covers the timestamp, a dot and the exact body
Loop over comma-separated entriesDuring a rotation the header holds one entry per live secret
XOR compare, no early returnAvoids leaking where the first mismatch is

From verified to stored

The sample returns 204 for a verified event without storing it. In production, parse the body with jsonDecode after the check, write job_id to a store with a unique key, and then answer. Sume retries up to 10 times, so a retry that finds the id already stored should still get a 204.

The dashboard Send test posts a signed webhook.test body to a URL that you type. It has no job_id, and it never replays a real job, so it is a free way to check the secret and the raw-body path.

Caveats

  • Read the body as a string once. If a middleware decodes and re-encodes JSON before your handler, the digest will not match.
  • Lowercase hex is what Digest.toString() returns, which matches the lowercase sume-v1=<hex> Sume sends.
  • Sume allows 10 seconds for each delivery attempt, so keep the handler to verify, store and answer.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume