Erlang: verify a Sume webhook with crypto:mac and crypto:hash_equals

Verify a Sume webhook in Erlang/OTP 26+: crypto:mac over timestamp.raw_body, binary:encode_hex, crypto:hash_equals, rotation entries, and an empty-secret guard.

5 min readSume
All posts

To verify a Sume webhook in Erlang, compute crypto:mac(hmac, sha256, Secret, [Timestamp, $., RawBody]), hex-encode it in lower case with binary:encode_hex/2, prefix sume-v1=, and compare it with each entry of the x-sume-webhook-signature header using crypto:hash_equals/2. That function needs OTP 25 or newer, and binary:encode_hex(Bin, lowercase) needs OTP 26. Match an empty secret to an error clause, reject timestamps more than 300 seconds old, and check before you decode the JSON.

Sume documents the scheme on its Webhooks and Run webhooks pages (read 2026-10-11). The same function serves job and run deliveries.

Why use crypto:mac and an iolist?

crypto:mac/4 replaced the old crypto:hmac/3 family, which was removed in OTP 24. It accepts an iolist as data, so you can pass [TsBin, $., Body] and never build a joined binary. Use the timestamp header text exactly as sent when you sign; parse it separately only for the replay check.

crypto:hash_equals/2 raises badarg when the two binaries have different sizes, so guard on byte_size first. A signature header that carries two entries during a secret rotation is common enough to deserve the fold in the code below.

Erlang pieces for the Sume signature, checked against Sume's Webhooks page (read 2026-10-11) and run on OTP 29.
StepSume ruleErlang call
Replay windowReject outside five minutesabs(os:system_time(second) - Ts) =< 300
Message<timestamp>.<raw_body>[TsBin, $., Body] as an iolist
DigestHMAC-SHA256, hex, sume-v1= prefixbinary:encode_hex(Mac, lowercase)
RotationOne sume-v1= entry per live secret, comma-separatedbinary:split(Header, <<",">>, [global])
CompareConstant timecrypto:hash_equals/2 after a size check

What does the module look like?

The first clause refuses an empty secret. The case returns false on a non-numeric timestamp, a stale one, or a mismatch.

-module(verify).
-export([verify/4, main/1]).

verify(<<>>, _, _, _) -> error(empty_secret);
verify(Secret, Body, TsBin, Header) ->
    Now = os:system_time(second),
    case string:to_integer(TsBin) of
        {Ts, <<>>} when abs(Now - Ts) =< 300 ->
            Mac = crypto:mac(hmac, sha256, Secret, [TsBin, $., Body]),
            Want = iolist_to_binary(["sume-v1=", binary:encode_hex(Mac, lowercase)]),
            Entries = [string:trim(E) || E <- binary:split(Header, <<",">>, [global])],
            lists:foldl(fun(E, Acc) -> eq(E, Want) orelse Acc end, false, Entries);
        _ -> false
    end.

eq(A, B) when byte_size(A) =:= byte_size(B) -> crypto:hash_equals(A, B);
eq(_, _) -> false.

What should the cowboy or escript wrapper do?

Whatever HTTP server you use, read the body as a binary once (in Cowboy, cowboy_req:read_body/1 loops until {ok, Data, Req}) and pass that same binary to verify/4 and then to your JSON decoder. Do not decode and re-encode first. Return 401 for a failed check, and 200 after the event is written to disk or a queue.

Sume gives your endpoint 10 seconds per attempt and up to 10 attempts. Dedupe on job_id for job.* events and on request_id for *.run.terminal events. A webhook.test event from the dashboard has neither, so route it to a log line.

How do I test it?

Compile with erlc verify.erl and call verify:verify(<<"whsec_test">>, Body, Ts, Header) from erl with a header you computed yourself. A header of sume-v1=00, sume-v1=<good> returns true, sume-v1=00 returns false, and an empty secret raises empty_secret. I ran exactly this on OTP 29 before writing this page.

After that, trigger Send test on /dashboard/webhooks against a tunnel to your dev machine. Send test is not a replay of a real job; use POST /v1/jobs/{job_id}/webhook/redeliver for that.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume