Ruby · Code recipe
Verify a webhook signature in Ruby
Prove a delivery came from URLpipe before you act on it, in Ruby 3.1+ with Net::HTTP from the standard library. Every program on this page runs as it stands — each one was run against a stub of the API before it was published.
By Roger Campos · Last updated: September 2026
TL;DR
To verify a URLpipe webhook in Ruby, compute HMAC-SHA256 over the X-URLpipe-Timestamp value, a dot and the raw request body, keyed with your whsec_ secret. Prefix it with v1= and compare it with OpenSSL.secure_compare against each comma-separated value of X-URLpipe-Signature; reject timestamps more than five minutes off.
Free plan, no credit card. 1,000 credits a month.
Your report_to URL accepts a POST from anyone who learns it. Turn on webhook signing for the project and every delivery carries X-URLpipe-Timestamp and X-URLpipe-Signature, so the receiver can prove the body came from URLpipe, unchanged, in the last five minutes.
The Ruby receiver below does the whole check: it reads the raw body (request.raw_post in Rails, request.body.read in Rack), recomputes the HMAC, compares it in constant time with OpenSSL.secure_compare, and rejects stale timestamps. To send it a signed test delivery, use the shell script on the cURL page.
Setup
Before you start
The check itself needs only openssl from the standard library. The receiver is a Rack app, so it runs under rackup here and drops into Rails or Sinatra as it is. Turn signing on under Settings → Webhook Signing and copy the secret (it starts with whsec_).
gem install rackup puma
export URLPIPE_WEBHOOK_SECRET="whsec_your_signing_secret"
Receiver
A receiver that verifies every delivery
verify is the part to copy into your app. In a Rails controller pass it request.raw_post — the bytes that were signed — never params re-encoded. OpenSSL.secure_compare is constant-time and safe on strings of different lengths.
require "json"
require "openssl"
class UrlpipeWebhook
TOLERANCE = 5 * 60 # seconds
def initialize(secret)
@secret = secret
end
def call(env)
request = Rack::Request.new(env)
return [ 404, {}, [] ] unless request.post? && request.path == "/webhooks/urlpipe"
# The raw bytes, exactly as sent.
body = request.body.read
timestamp = request.get_header("HTTP_X_URLPIPE_TIMESTAMP").to_s
signature = request.get_header("HTTP_X_URLPIPE_SIGNATURE").to_s
return [ 401, {}, [] ] unless verify(body, timestamp, signature)
delivery = JSON.parse(body)
warn "Verified delivery for #{delivery["token"]}"
[ 200, {}, [] ]
end
# True when the delivery was signed with the secret in the last five minutes.
def verify(body, timestamp, signature_header)
return false unless timestamp.match?(/\A\d+\z/) && (Time.now.to_i - timestamp.to_i).abs <= TOLERANCE
expected = "v1=" + OpenSSL::HMAC.hexdigest("SHA256", @secret, "#{timestamp}.#{body}")
# One signature normally, two during a secret rotation: accept any match.
signature_header.split(",").any? { |signature| OpenSSL.secure_compare(signature.strip, expected) }
end
end
run UrlpipeWebhook.new(ENV.fetch("URLPIPE_WEBHOOK_SECRET"))
Run it: rackup config.ru -p 8000
Details
What to know about signed deliveries
- Signing is off until you turn it on under Settings → Webhook Signing; the secret starts with
whsec_. Enabling it is safe at any time — the body does not change — so enable it first and deploy the check after. - Rotating the secret opens a 24-hour window in which every delivery carries two signatures, the new one first. That is why the header is a list and any match is accepted.
- Each attempt is signed with a fresh timestamp, so a retry passes the five-minute check like the first delivery did.
- A delivery your endpoint rejects is retried with backoff, up to six attempts over roughly twenty minutes, and can be resent by hand from the dashboard afterwards.
- Make the handler idempotent on
token: the same result can arrive more than once.
Other languages
Verify a webhook signature in another language
More Ruby: every Ruby recipe · how signing works, in the docs
FAQ
Frequently asked questions
Why verify against the raw body?
Why can the signature header hold more than one value?
What should a receiver answer when the check fails?
Why a five-minute tolerance?
Get a key and run it.
Free plan, no card. Paste your key into URLPIPE_API_KEY and every program on this page runs as it is.