Swift 6.4 CryptoKit: verify a Sume webhook signature
Verify x-sume-webhook-signature in Swift with CryptoKit HMAC<SHA256>: timestamp window, comma-separated entries, constant-time compare, empty secret refused.

Short answer
Build the signed string as <timestamp>.<raw body bytes>, compute HMAC<SHA256> with CryptoKit, hex-encode it, and compare sume-v1=<hex> against every comma-separated entry of x-sume-webhook-signature. Reject an empty secret and a timestamp more than 300 seconds away. The scheme is on Sume's webhooks page.
Swift 6.4 was announced on 15 September 2026 on the Swift blog (read 2026-10-03). The verifier uses long-standing CryptoKit and Foundation APIs, so it does not depend on 6.4 features. It runs on Apple platforms where CryptoKit is available; on Linux, swap in swift-crypto.
What the verifier checks
Sume signs the raw JSON body. In a Vapor, Hummingbird or URLSession-based server, keep the request body as Data and pass it unchanged. Decoding to a model and re-encoding first changes the bytes.
| Check | Result when it fails |
|---|---|
| secret is empty | false, before any crypto |
| timestamp is not a number or is over 300 seconds old | false |
| no entry equals sume-v1=<hex> of the recomputed MAC | false |
| any entry equals it | true (a secret rotation sends one entry per live secret) |
The function and a self-test
Run it with swift verify.swift. The constant-time helper compares UTF-8 bytes of equal length with an XOR fold; it maps over every entry so timing does not reveal which one matched.
import CryptoKit
import Foundation
func same(_ a: String, _ b: String) -> Bool {
let x = Array(a.utf8), y = Array(b.utf8)
return x.count == y.count && zip(x, y).reduce(0) { $0 | ($1.0 ^ $1.1) } == 0
}
func sign(_ secret: String, _ ts: String, _ body: Data) -> String {
let msg = Data((ts + ".").utf8) + body
let mac = HMAC<SHA256>.authenticationCode(for: msg, using: SymmetricKey(data: Data(secret.utf8)))
return "sume-v1=" + mac.map { String(format: "%02x", $0) }.joined()
}
func verify(secret: String, ts: String, header: String, body: Data) -> Bool {
guard !secret.isEmpty, let t = TimeInterval(ts),
abs(Date().timeIntervalSince1970 - t) <= 300 else { return false }
let want = sign(secret, ts, body)
return header.split(separator: ",")
.map { same($0.trimmingCharacters(in: .whitespaces), want) }.contains(true)
}
let body = Data(#"{"event":"job.completed"}"#.utf8)
let ts = String(Int(Date().timeIntervalSince1970))
let header = "sume-v1=old," + sign("s3cret", ts, body)
print(verify(secret: "s3cret", ts: ts, header: header, body: body),
verify(secret: "", ts: ts, header: header, body: body),
verify(secret: "s3cret", ts: "1", header: header, body: body))Where the inputs come from
Read x-sume-webhook-timestamp and x-sume-webhook-signature from the request headers. Read the secret from your server configuration, under the name SUME_COM_WEBHOOK_SIGNING_SECRET that the delivery worker uses. It is on the dashboard's Webhooks tab, or from GET /v1/webhooks/signing-secret with a key that carries account:read.
Return a 2xx after storing the event. Sume retries up to 10 attempts with a 10 second timeout each, and a failed delivery never changes the job's real state.
What Sume does and does not do
Sume attaches x-sume-webhook-secret-fingerprint to every delivery so you can tell which secret signed it without sending the secret. It signs job events and run events with the same scheme and secret.
Sume does not push to devices. A mobile app should receive webhooks on its own backend and learn the result from there, or poll GET /v1/jobs/:id/status.
Sources
Related posts
More in Developers
- Swift 6.4 URLSession: poll a Sume job with async/await
Swift 6.4 shipped on 15 September 2026. An async URLSession loop for GET /v1/jobs/:id/status that honors next_poll_after_seconds and a 20-minute deadline.
- SWR refreshInterval as a function: poll a Sume job and stop
SWR accepts a function for refreshInterval that receives the latest data. Return Sume's next_poll_after_seconds while running and 0 once terminal is true.
- SWR refreshWhenHidden is false: a hidden tab stops polling Sume jobs
SWR stops polling in a hidden tab by default, but a Sume job keeps running and billing. Store the job id and resume the poll on focus instead of resubmitting.
- Synthesia docs llms.txt vs the Sume Avatar 1.0 guide for AI assistants
Synthesia's docs offer llms.txt and .md pages for agents. Here is how to hand an AI assistant the Sume Avatar 1.0 guide, schema and tool list instead.
Written by Sume