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.

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.
| Step | Reason |
|---|---|
| Exit when the secret is empty | An empty key still yields a digest, so forged bodies would pass |
| int.tryParse on the timestamp | A missing header returns null instead of throwing |
| (now - t).abs() > 300 | Rejects 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 entries | During a rotation the header holds one entry per live secret |
| XOR compare, no early return | Avoids 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 lowercasesume-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
- Datadog DogStatsD metrics for Sume jobs: tags that stay small
Count Sume submits, terminal statuses and durations with DogStatsD, tagged by route, status and code, and keep job ids and request ids out of the tag set.
- Demand Gen copy limits: 40-character headlines, one at 30 or fewer
Demand Gen allows 40-character headlines (one must be 30 or fewer), 90-character descriptions and 10-60 second videos. A checker script plus the Sume lengths.
- Deno KV: dedupe Sume webhooks with atomic check on a null versionstamp
Deno.serve and Deno KV: verify the Sume HMAC with crypto.subtle.verify, then claim job_id with kv.atomic().check({ versionstamp: null }) so retries commit once.
- Design a render tool for a stateless MCP server: job ids as arguments
MCP 2026-07-28 removes protocol sessions. A render tool stays correct if its state lives in a job id the client passes back, as Sume jobs do.
Written by Sume