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.

3 min readSume
All posts

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.

Inputs and verdicts (read 2026-10-06, Sume docs and the sample run)
InputVerdict
Correct signature, 10 s old1 (accept)
Correct signature, 301 s old0 (stale)
Empty secret0 (refused)
Signature of a different lengthSkipped, 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

All Developers posts

Written by Sume