Verify a Sume webhook signature in Perl with Digest::SHA
A tested Perl verifier for the Sume x-sume-webhook-signature header using Digest::SHA, an XOR compare, the 300 s window and an empty-secret refusal.

Perl's core Digest::SHA module has hmac_sha256_hex, which is all you need to verify a Sume webhook: sign the timestamp, a dot and the raw body, prefix sume-v1=, and compare. The script below also checks the 300 second window and refuses an empty secret.
Perl has no built-in constant-time compare, so the sample XORs two equal-length strings and checks that the result is all zero bytes. That is not a hardened primitive, and it is stated here plainly so you can choose a library if your threat model needs one.
The verifier
It was run with a good signature, a timestamp 301 seconds old and an empty secret, and printed 1, 0, 0.
Two details are worth copying exactly. First, the signed string is built as the timestamp, a literal dot and the raw body, in that order, and the timestamp is the header text as Sume sent it. Second, the hex digest is lowercase and the full value to compare is the sume-v1= prefix plus that digest. If you read the body through a CGI or PSGI layer, make sure it is not decoded to characters before hashing, because the signature covers bytes. Run the three sample calls after any change to confirm you still get 1, 0 and 0.
use strict; use warnings;
use Digest::SHA qw(hmac_sha256_hex);
sub verify {
my ($body, $ts, $header, $secret, $now) = @_;
return 0 unless length $secret && $ts =~ /^\d+$/;
return 0 if abs($now - $ts) > 300;
my $expected = 'sume-v1=' . hmac_sha256_hex("$ts.$body", $secret);
my $ok = 0;
for my $entry (split /,/, $header) {
$entry =~ s/^\s+|\s+$//g;
next unless length($entry) == length($expected);
$ok = 1 if (($entry ^ $expected) !~ /[^\0]/); # same length: XOR is all zero
}
return $ok;
}
my ($body, $ts, $secret) = ('{"event":"job.completed"}', 1780000000, 's3cret');
my $sig = 'sume-v1=' . hmac_sha256_hex("$ts.$body", $secret);
print verify($body, $ts, $sig, $secret, $ts + 10), "\n"; # 1
print verify($body, $ts, $sig, $secret, $ts + 301), "\n"; # 0 (stale)
print verify($body, $ts, $sig, '', $ts), "\n"; # 0 (empty secret)What it does with the header
Sume can send several comma-separated entries while a signing secret is being rotated, newest first. The loop splits on commas, trims spaces, skips entries of the wrong length and accepts any exact match.
| Input | Verdict |
|---|---|
| Correct signature, 10 s old | 1 (accept) |
| Correct signature, 301 s old | 0 (stale) |
| Empty secret | 0 (refused) |
| Signature of a different length | Skipped, then 0 |
Tradeoffs
Read the body as raw bytes before any decoding, or the digest will differ. The length check before the XOR leaks only the length, which is public anyway (the format is fixed). If you already run a framework with a vetted HMAC helper, prefer it; this is for scripts and CGI-style receivers where you want no dependencies beyond core Perl.
Fetch the signing secret once from GET /v1/webhooks/signing-secret, which needs a key with the account:read scope, and keep it in the environment, never in the script. The secret is per workspace, so a staging key and a production key may give you different secrets; check which one your receiver is loading.
Sources
Related posts
More in Developers
- Pin the image model id on the queue row so a swap can't break old jobs
Store the Sume model id on each queued render row at enqueue time, then re-point only rows still carrying a retired id. Python sqlite3 sample for gpt-image-1.
- Polling a Sume job for 20 minutes: 600 fixed reads or 44 backed off
Counting reads for a 20-minute Sume job poll: fixed 2 s versus a 1 s schedule doubling to 30 s. Arithmetic you can run, and why the server hint still wins.
- Poll Sume status in a loop, read the full job once at the end
Poll the lightweight status for progress and read GET /v1/jobs/{id} once at a terminal state to get the error. What next_action tells you and the 409 on result.
- Poll many transcription jobs without a 429: next_poll_after_seconds
Poll Sume job status with the next_poll_after_seconds the API sends, back off on 429 with retry-after, and stop on a terminal status.
Written by Sume