Julia: verify a Sume webhook with SHA.jl and a hand-rolled compare

Verify a Sume webhook in Julia: SHA.hmac_sha256 over timestamp.raw_body, a hand-rolled constant-time compare, rotation entries, and a five-minute replay window.

5 min readSume
All posts

To verify a Sume webhook in Julia, build the message <timestamp>.<raw body> as bytes, sign it with SHA.hmac_sha256 from the standard library, prepend sume-v1= to the hex digest, and compare it with every sume-v1= entry in the x-sume-webhook-signature header. Julia's standard library has no constant-time comparison, so you write a four-line XOR fold yourself. Refuse an empty secret and a timestamp more than 300 seconds from your clock. Do this before you parse the JSON, and return a 2xx only after the event is stored.

The rules below come from Sume's Webhooks and Run webhooks pages (read 2026-10-11). One verifier covers both surfaces.

What does Sume sign, and what do I need from Julia?

Sume signs the string <timestamp>.<raw_body> with HMAC-SHA256, using the workspace signing secret. The secret is on the Webhooks tab of the dashboard, or from GET /v1/webhooks/signing-secret with an API key that has account:read. Store it as SUME_COM_WEBHOOK_SIGNING_SECRET. The docs say to compare against the raw body, before any JSON parse or re-serialize, which matters in Julia because JSON3.read followed by JSON3.write reorders nothing you can rely on.

Work in Vector{UInt8} end to end. If your HTTP server hands you a String, convert with Vector{UInt8}(s) once and keep that value for both the signature and the parse.

Signature inputs for a Sume webhook, from Sume's Webhooks page (read 2026-10-11).
PieceValueJulia
Timestamp headerx-sume-webhook-timestamptryparse(Int, ts); nothing means reject
Signature headersume-v1=<hex>, comma-separated during rotationsplit(header, ','), compare every entry
Signed message<timestamp>.<raw body>vcat(Vector{UInt8}("$t."), body)
Replay windowFive minutes is the documented defaultabs(round(Int, time()) - t) > 300
SecretWorkspace signing secretSHA.hmac_sha256(key, msg)

What is the verifier?

This is a complete function. It throws on an empty secret, returns false for a bad timestamp or signature, and visits every header entry so that timing does not show which entry matched.

using SHA

const TOLERANCE = 300

function ct_equal(a::Vector{UInt8}, b::Vector{UInt8})
    length(a) == length(b) || return false
    acc = UInt8(0)
    for i in eachindex(a)
        acc |= a[i] ⊻ b[i]
    end
    return acc == 0
end

function verify_sume(secret::String, body::Vector{UInt8}, ts::String, header::String)
    isempty(secret) && error("empty signing secret")
    t = tryparse(Int, ts)
    t === nothing && return false
    abs(round(Int, time()) - t) > TOLERANCE && return false
    mac = hmac_sha256(Vector{UInt8}(secret), vcat(Vector{UInt8}("$t."), body))
    want = Vector{UInt8}("sume-v1=" * bytes2hex(mac))
    ok = false
    for entry in split(header, ',')
        ok |= ct_equal(Vector{UInt8}(strip(entry)), want)
    end
    return ok
end

How do I check it before pointing Sume at it?

Sign a fake delivery with the same secret and feed it through the function. The result for a header with a stale sume-v1=00 entry first and the right entry second must be true, and false for the stale entry alone. Then use POST /v1/webhooks/test-deliveries (key with account:write) or Send test on the dashboard to see a real signed webhook.test body arrive. That event has no job_id, so route it away from your job table.

I ran the function above on Julia 1.12.7 with a signed fake body: the rotated header returned true, the stale-only header returned false, and an empty secret threw.

What happens after the check passes?

Write the event to durable storage, return 2xx, and process afterwards. A delivery attempt times out after 10 seconds, and Sume tries up to 10 times in total. Job webhooks space attempts at a fixed 30 seconds by default; run webhooks back off from 30 seconds up to one hour and obey Retry-After. Dedupe on job_id for jobs and on request_id (equal to run_id) for runs.

A failed delivery never changes the job. If all ten attempts fail, GET /v1/jobs/{id}/status still returns the real terminal state, so keep a poll as the backup.

  • job.completed, job.failed, job.canceled come from model endpoints.
  • format.run.terminal, action.run.terminal, agent.run.terminal come from run endpoints; a canceled or skipped run sends nothing.
  • Branch on outcome (ok, degraded, error) for runs, not only on status.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume