Vapor 4: collect the body, verify a Sume webhook with swift-crypto
Use body: .collect(maxSize:) in Vapor 4, read req.body.data, and verify the Sume HMAC SHA-256 signature with swift-crypto before returning .noContent.

In Vapor 4, register the Sume webhook route with body: .collect(maxSize: "1mb"), read req.body.data as bytes, and recompute HMAC SHA-256 over <timestamp>.<raw_body> with swift-crypto. Compare every sume-v1= entry in the signature header in constant time, throw or return 401 on a mismatch, and return .noContent once the event is stored.
The maxSize argument is not decoration. Vapor's default limit for collected streaming bodies is small, and a larger Sume body would be rejected before your handler runs.
Why set maxSize on the route?
Vapor's routing documentation says request bodies are collected into memory before your handler is called, that the default limit for streaming body collection is 16 KB, and that exceeding the configured limit throws 413 Payload Too Large. A maxSize passed to collect overrides the application default for that one route.
Run receipts from Sume can reach 1 MiB inline, so set the route to "1mb". Anything bigger arrives as an envelope with payload: null and a result_url, so you never need more. The same page says a streamed body leaves req.body.data as nil, which is why this route uses collect and not stream.
import Vapor
import Crypto // add the swift-crypto "Crypto" product to your target
func verify(secret: String, ts: String?, header: String?, body: Data) -> Bool {
guard !secret.isEmpty, let ts, let header, let t = Int(ts),
abs(Int(Date().timeIntervalSince1970) - t) <= 300 else { return false }
var msg = Data("\(t).".utf8)
msg.append(body)
let mac = HMAC<SHA256>.authenticationCode(for: msg, using: SymmetricKey(data: Data(secret.utf8)))
let want = Array(("sume-v1=" + mac.map { String(format: "%02x", $0) }.joined()).utf8)
var ok = false
for entry in header.split(separator: ",") {
let got = Array(entry.trimmingCharacters(in: .whitespaces).utf8)
let same = got.count == want.count && zip(got, want).reduce(0) { $0 | ($1.0 ^ $1.1) } == 0
if same { ok = true }
}
return ok
}What does the route look like?
Header lookup uses req.headers.first(name:), which returns nil for a missing header, and the verifier treats nil as a failure. Put the registration in routes(_:).
func routes(_ app: Application) throws {
app.on(.POST, "hooks", "sume", body: .collect(maxSize: "1mb")) { req async throws -> HTTPStatus in
guard let buf = req.body.data else { throw Abort(.badRequest) }
let ok = verify(
secret: Environment.get("SUME_COM_WEBHOOK_SIGNING_SECRET") ?? "",
ts: req.headers.first(name: "x-sume-webhook-timestamp"),
header: req.headers.first(name: "x-sume-webhook-signature"),
body: Data(buf.readableBytesView))
guard ok else { throw Abort(.unauthorized) }
// insert the event keyed by job_id or request_id, then acknowledge
return .noContent
}
}Which limits and codes matter together?
Two sets of limits meet at this route: Vapor's body collection and Sume's delivery behavior.
| Limit | Value | Effect |
|---|---|---|
| Vapor default for collected streaming bodies | 16 KB | 413 before your handler if exceeded |
| Route override | .collect(maxSize: "1mb") | Matches Sume's 1 MiB inline run-receipt ceiling |
| Sume attempt timeout | 10 seconds | A slow handler is retried |
| Sume retries | Up to 10 attempts | Then the delivery is exhausted, not the run |
What do I check if verification fails in Vapor?
Start with the body. If req.body.data is nil, the route is streaming and you forgot .collect. If the handler never runs and the client sees 413, the body is above maxSize. If the handler runs and the signature still fails, compare the x-sume-webhook-secret-fingerprint header on the delivery with the fingerprint beside the secret in the dashboard.
Check the clock too. The verifier accepts a timestamp within 300 seconds of the server, so a container with a drifting clock will reject good deliveries.
- Use
.collect(maxSize:)on this route only. - Never decode to
Contentbefore verifying. - Add the swift-crypto
Cryptoproduct to the target. - Return
.noContentonly after the insert.
What should happen after the check?
Insert the event into a table keyed on job_id or request_id and return .noContent right away. Retries and manual redelivers carry the same id, so the unique key makes a duplicate harmless. If deliveries ever stop, the status_url poll still gets you the terminal state.
Sources
Related posts
More in Developers
- Vert.x Web: verify a Sume webhook after BodyHandler reads the body
Install BodyHandler before your Vert.x route, read the body as bytes, recompute HMAC SHA-256 over timestamp.body, and return 204 to a Sume delivery.
- What one script_run call cost: the usage script_runs array
Sume's usage summary lists each script_run call with its rows, debited, held and refunded micros, so a fan-out of TTS or image jobs has one price.
- WireMock scenarios: fake a Sume job going queued to completed
Use one WireMock scenario per job so GET /v1/jobs/{id}/status answers queued, processing, then completed, and test the poll loop without spending.
- Word timestamps for an Eleven v4 voice: the router says 400, use STT
The Sume TTS Router rejects timestamps on eleven-* ids. Get word timings by running the finished narration through Sume STT for one cent a minute.
Written by Sume