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.

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.
| Piece | Value | Julia |
|---|---|---|
| Timestamp header | x-sume-webhook-timestamp | tryparse(Int, ts); nothing means reject |
| Signature header | sume-v1=<hex>, comma-separated during rotation | split(header, ','), compare every entry |
| Signed message | <timestamp>.<raw body> | vcat(Vector{UInt8}("$t."), body) |
| Replay window | Five minutes is the documented default | abs(round(Int, time()) - t) > 300 |
| Secret | Workspace signing secret | SHA.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
endHow 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.canceledcome from model endpoints.format.run.terminal,action.run.terminal,agent.run.terminalcome from run endpoints; a canceled or skipped run sends nothing.- Branch on
outcome(ok,degraded,error) for runs, not only onstatus.
Sources
Related posts
More in Developers
- Lambda Powertools idempotency and Sume's Idempotency-Key: use both
Powertools idempotency guards your Lambda handler; Sume's Idempotency-Key guards the paid submit. How they differ, and how to derive one key from an order id.
- LangGraph replay re-fires API calls: key Sume submits per fork
LangGraph time travel re-runs every node after the checkpoint, API calls too. Derive the Sume Idempotency-Key from the body so replays dedupe and forks run.
- List Sume assets: limit, cursor, media_type and status filters
GET /v1/assets returns up to 100 assets per page, newest first, with a null next_cursor on the last page. Filters, defaults, and what a bad cursor does.
- Litestar handler for Sume webhooks: await request.body() and 204
A Litestar post handler that awaits request.body(), verifies the Sume HMAC signature, sets status_code=204 explicitly, and raises 401 on a mismatch.
Written by Sume