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.

5 min readSume
All posts

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.

Limits at the Vapor route, read 2026-10-11 from Vapor routing and Sume Run webhooks documentation.
LimitValueEffect
Vapor default for collected streaming bodies16 KB413 before your handler if exceeded
Route override.collect(maxSize: "1mb")Matches Sume's 1 MiB inline run-receipt ceiling
Sume attempt timeout10 secondsA slow handler is retried
Sume retriesUp to 10 attemptsThen 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 Content before verifying.
  • Add the swift-crypto Crypto product to the target.
  • Return .noContent only 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

All Developers posts

Written by Sume