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.

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.
| Step | Sume rule | Erlang call |
|---|---|---|
| Replay window | Reject outside five minutes | abs(os:system_time(second) - Ts) =< 300 |
| Message | <timestamp>.<raw_body> | [TsBin, $., Body] as an iolist |
| Digest | HMAC-SHA256, hex, sume-v1= prefix | binary:encode_hex(Mac, lowercase) |
| Rotation | One sume-v1= entry per live secret, comma-separated | binary:split(Header, <<",">>, [global]) |
| Compare | Constant time | crypto: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
- Falcon ASGI: verify a Sume webhook with req.bounded_stream.read()
Read the raw Sume webhook body in Falcon ASGI with req.bounded_stream.read(), verify HMAC SHA-256, and set HTTP_204 or raise HTTPUnauthorized.
- Fastify raw body for a Sume webhook: parseAs string and verifyWebhook
Fastify parses JSON before your handler, which breaks signatures. Use addContentTypeParser with parseAs string and verifyWebhook from @sume-com/sdk.
- Flask webhook receiver for Sume job events: verify the signature
Verify a Sume job webhook in Flask: read request.get_data() raw, check the sume-v1 HMAC and 300 s window, refuse an empty secret, answer 204.
- Port a gemini-nano-banana-2.1 image call to Sume POST /v1/images
Map Google's response_format aspect_ratio and image_size, base64 output and inline inputs to Sume's aspect_ratio, resolution, data[].url and input_references.
Written by Sume