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.

5 min readSume
All posts

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.

Checks, in order (as of 2026-10-03)
CheckResult when it fails
secret is emptyfalse, before any crypto
timestamp is not a number or is over 300 seconds oldfalse
no entry equals sume-v1=<hex> of the recomputed MACfalse
any entry equals ittrue (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

All Developers posts

Written by Sume