Receive a signed webhook.
Use action ingress for the exact signed bytes, a bounded body, and provider authentication before the contract binds.
| Contract | Value |
|---|---|
| Needs | A provider that signs each body with HMAC-SHA256 and sends the hex digest in one header, and a test-only WEBHOOK_SECRET in .env.test |
| You will have | A raw-body route limited to 256 KiB, an authenticator that runs before the contract binds, and a request spec for a valid, a forged, a missing and an oversized delivery |
| Status | Run end to end by scripts/check page-webhooks |
| Proved by | frappe check · frappe corretto · frappe lint |
Where the change goes
.env.testTest-only secretconfig/routes.crThe inbox routeapp/actions/hooks/inbox.crPolicy, verifier and handlespec/requests/hooks_spec.crValid, forged, missing, oversizedDeclare the route’s request policy
The application is ledger. Its action and its spec read the secret from ENV["WEBHOOK_SECRET"], and frappe new already writes a comment-only .env.test to hold it.
- Create the application and give its specs the secret
Terminal · after machine setup frappe new ledger cd ledgerExpect: Prints
Created ledger., thenledger is configured at https://ledger.caramel.Terminal · inside the application printf 'WEBHOOK_SECRET=test-webhook-secret\n' >> .env.testExpect:
frappe correttogives every spec worker the values of.env.test, so the value reaches both the action and the spec.frappe devreads.envinstead: a development secret goes there, and a real one is never committed. - Route the inbox
config/routes.cr · inside Caramel::Router.draw post "/hooks/inbox", App::Hooks::InboxThe action declares
ingress, so this route reads raw bytes without a browser CSRF token. The body limit, the 401 and 413 it answers, and whatcsrf: falsedemands of an authenticator are the ingress rules. Read the ingress rules →
Verify before interpreting the body
This is one documented scheme: the hex HMAC-SHA256 of the exact body, in the X-Signature header. A provider that puts a timestamp in the signed text changes only signed?.
- Write the action
app/actions/hooks/inbox.cr require "openssl/hmac" require "crypto/subtle" module App::Hooks # A signed webhook: the exact bytes, no browser CSRF, an HMAC over the body. struct Inbox < App::ApplicationAction ingress body: :raw, limit: 256.kilobytes, csrf: false, authenticate: :signed? contract do end def handle(contract : Contract) event = JSON.parse(String.new(raw_body)) Log.info { "Webhook #{event["id"]?} #{event["type"]?}" } Caramel::Response.new(202) end # Hex HMAC-SHA256 of the body, sent in one header. A provider that signs # a timestamp with the body changes only this method. private def signed? : Bool signature = request.headers["X-Signature"]? || return false expected = OpenSSL::HMAC.hexdigest(:sha256, ENV["WEBHOOK_SECRET"], raw_body) Crypto::Subtle.constant_time_compare(signature, expected) end end endingresskeeps the bytes as sent, caps them at 256 KiB, skips the browser CSRF check and namessigned?; the contract is empty because a raw route binds only the route and the query.signed?returns false when the header is absent, so the router answers 401 before the contract binds.- It computes the digest over
raw_body, the original bytes, with the same secret the provider used. Crypto::Subtle.constant_time_comparecompares the two digests without leaking where they differ.handleruns only after that: it parses the verified body, logs the event’sidandtype, and returns an empty 202 throughCaramel::Response.new(202).
- Read the route’s policy
Terminal · inside the application frappe routes hooksExpect: Prints
POST /hooks/inbox App::Hooks::Inbox [raw, 256 KiB, csrf off, authenticate signed?]: a raw route ends with its ingress policy in brackets.
Send exact bodies in Corretto
Use body: for the signed text or bytes, and headers: for its media type and signature. Unlike json:, this preserves the input’s original whitespace and byte spelling. Choose one body mode per request.
- Sign the examples the way the provider does
spec/requests/hooks_spec.cr · the signing helper require "../spec_helper" require "openssl/hmac" module HooksSpec CONTENT = {"Content-Type" => "application/json"} def self.signature(body : String) : String OpenSSL::HMAC.hexdigest(:sha256, ENV["WEBHOOK_SECRET"], body) end def self.headers(signature : String) : Hash(String, String) CONTENT.merge({"X-Signature" => signature}) end endThe helper signs with the
.env.testsecret, so a valid example and the action agree. A forged example signs a different body; a missing one sends only the media type; an oversized one is 262,145 bytes, one over the limit, with a valid signature, and still answers 413 because the limit applies before the authenticator runs.spec/requests/hooks_spec.cr · the four examples describe "The webhook inbox" do it "accepts a body with a valid signature" do Corretto.session do |client| body = %({"id":"evt_1","type":"invoice.paid"}) signed = HooksSpec.headers(HooksSpec.signature(body)) client.post("/hooks/inbox", body: body, headers: signed).should have_status(202) end end it "refuses a forged signature" do Corretto.session do |client| body = %({"id":"evt_1","type":"invoice.paid"}) forged = HooksSpec.headers(HooksSpec.signature("{}")) client.post("/hooks/inbox", body: body, headers: forged).should have_status(401) end end it "refuses a body without a signature" do Corretto.session do |client| body = %({"id":"evt_1","type":"invoice.paid"}) client.post("/hooks/inbox", body: body, headers: HooksSpec::CONTENT).should have_status(401) end end it "refuses a body over 256 KiB before checking its signature" do Corretto.session do |client| body = "x" * 262_145 signed = HooksSpec.headers(HooksSpec.signature(body)) client.post("/hooks/inbox", body: body, headers: signed).should have_status(413) end end endTerminal · inside the application frappe correttoExpect: Prints
5 examples, 0 failures, the four above and the generated home example, thenCorretto: 1 of 1 workers passed.
Prove it
Prove the signature check
frappe check
frappe corretto
frappe lintPassing: frappe check exits 0. frappe corretto answers 202 to the valid signature, 401 to the forged one and to the missing header, and 413 to the body over 256 KiB, then prints Corretto: 1 of 1 workers passed. frappe lint exits 0.
scripts/check page-webhooks creates ledger, appends the secret to .env.test, copies these files at the steps above, reads the route and runs the proof, and the website build holds every snippet on this page to those files. The recipe stores no event, does not detect a duplicate delivery, and checks no timestamp against replay.