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.

5 min readSume
All posts

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.

Elixir v1.20 changelog items (read 2026-10-03)
ItemDetail
Latest releasev1.20.4, 2026-08-28
Type systeminfers types of function definitions, guards and whole bodies
Compile timemodule_definition: :interpreted evaluates module definitions instead of compiling them
Mixparallel 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
end

Getting 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

All Developers posts

Written by Sume