Elixir 1.20: verify a Sume webhook with :crypto.mac
An Elixir module that checks the Sume sume-v1 header with :crypto.mac and a guard clause that refuses an empty secret. Written against Elixir 1.20.4.

Short answer
In Elixir, verify a Sume delivery with :crypto.mac(:hmac, :sha256, secret, [timestamp, ".", raw_body]), hex-encode it lowercase, prefix it with sume-v1=, and compare it to each comma-separated header entry with a constant-time comparison. A guard clause on the function head rejects an empty secret before any crypto runs. The scheme is on Sume's webhooks page.
The Elixir changelog lists v1.20.4 (2026-08-28) as the latest release and describes a type system that infers types for function definitions, including guards (read 2026-10-03). A guard-first verifier is the sort of clause that benefits from that.
What the changelog says
The notes do not mention HTTP or JSON modules, so this post relies on :crypto from OTP and on Plug for the web layer, not on anything new in 1.20.
| Item | Detail |
|---|---|
| Latest release | v1.20.4, 2026-08-28 |
| Type system | infers types of function definitions, guards and whole bodies |
| Compile time | module_definition: :interpreted evaluates module definitions instead of compiling them |
| Mix | parallel dependency lock checks, mix source MODULE |
The module
Plug.Crypto.secure_compare/2 is the constant-time comparison; it comes with the plug_crypto dependency that Plug already pulls in. The reducer visits every header entry so a secret rotation, where the header carries one entry per live secret, is accepted without leaking which entry matched.
defmodule SumeWebhook do
@tolerance 300
def verify(secret, ts, header, body)
when is_binary(secret) and secret != "" and is_binary(ts) and is_binary(header) do
with {t, ""} <- Integer.parse(ts),
true <- abs(System.os_time(:second) - t) <= @tolerance do
mac = :crypto.mac(:hmac, :sha256, secret, [ts, ".", body])
want = "sume-v1=" <> Base.encode16(mac, case: :lower)
header
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reduce(false, fn entry, ok -> Plug.Crypto.secure_compare(entry, want) or ok end)
else
_ -> false
end
end
def verify(_secret, _ts, _header, _body), do: false
endGetting the raw body in Plug
The body must be the exact bytes Sume signed. If Plug.Parsers runs first it consumes the body, so read and cache the raw bytes before parsing, then pass the cached binary to SumeWebhook.verify/4 together with the x-sume-webhook-timestamp and x-sume-webhook-signature headers.
Return 401 when verify/4 is false. After you have stored the event durably, return any 2xx: Sume retries network errors and non-2xx answers up to 10 attempts, 30 seconds apart by default, with a 10 second timeout per attempt.
What Sume does and does not do
Sume sends one terminal event per job (job.completed, job.failed, job.canceled) and one per run (*.run.terminal), and tells you the outcome in status and, for runs, outcome. Use job_id or the run's request_id to dedupe, since a retry repeats the same id.
Sume does not send partial progress, and a delivery failure never changes the job's real state. Keep status_url polling as the fallback for deliveries that never arrive.
Sources
Related posts
More in Developers
- Episode idempotency keys: season, episode and version for Sume runs
Build the Sume Idempotency-Key from series, season, episode and a version you bump on purpose. What replays, what conflicts, what needs a new key.
- Extend a video Veo didn't make: which API takes an uploaded clip
Veo 3.1 extension only accepts Veo-made videos. Here is what Gemini Omni and Sume accept instead when you need to continue a clip you uploaded.
- Extend an AI clip by hand: last frame to the next clip, in Python
No extend endpoint? Pull a clip's last frame with Sume's video-frames API and feed it as the first frame of the next job. Python code under 30 lines.
- Fabric audio under 10 MB: how many seconds of wav and mp3 fit
Fabric lip-sync takes Sume-hosted audio under 10 MB, 300 s max. Stereo wav hits the size cap near 56 s; 128 kbps mp3 would reach 625 s, so length binds first.
Written by Sume