Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Some links on this page are affiliate links: if you buy through them we may earn a commission, at no extra cost to you.

To receive a webhook in Ruby, expose an HTTPS POST endpoint, read the exact raw request body and headers, verify the sender’s signature before parsing JSON, record the delivery ID, enqueue the work, and return a 2XX response quickly. The pattern works in Sinatra, Rails, and other Rack applications; only the provider-specific header names and signature algorithm change.

How a Ruby webhook endpoint works

A webhook provider sends an HTTP POST request to a URL you configure. The request normally contains JSON plus headers identifying the event, delivery, and authentication signature. Your endpoint should perform these operations in order:

  1. Accept only the HTTP method and route you configured.
  2. Read the request body as an unchanged byte/string sequence.
  3. Read the provider’s signature and event headers.
  4. Verify the signature with the shared secret, using constant-time comparison.
  5. Parse JSON only after verification succeeds.
  6. Validate the event type, action, and required fields.
  7. Persist the delivery ID and enqueue processing before acknowledging.
  8. Return the provider’s required 2XX status.

Do not log secrets or entire payloads containing personal data. Keep the endpoint on HTTPS and subscribe only to event types your application handles.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Sinatra implementation

This complete example verifies GitHub-style signatures, records a delivery identifier in a placeholder method, and returns 202 Accepted. Set WEBHOOK_SECRET in the process environment rather than in source control.

require "sinatra"
require "json"
require "openssl"

SECRET = ENV.fetch("WEBHOOK_SECRET")

helpers do
  def constant_time_equal?(a, b)
    Rack::Utils.secure_compare(a, b)
  rescue ArgumentError
    false
  end
end

post "/webhook" do
  request.body.rewind
  raw_body = request.body.read
  signature = request.env["HTTP_X_HUB_SIGNATURE_256"]
  expected = "sha256=" + OpenSSL::HMAC.hexdigest(
    OpenSSL::Digest.new("sha256"), SECRET, raw_body
  )

  halt 401 unless signature && constant_time_equal?(expected, signature)

  event_name = request.env["HTTP_X_GITHUB_EVENT"]
  delivery_id = request.env["HTTP_X_GITHUB_DELIVERY"]

  begin
    payload = JSON.parse(raw_body)
  rescue JSON::ParserError
    halt 400
  end

  halt 400 unless delivery_id && event_name

  # Replace these with a database insert and background-job enqueue.
  # persist_delivery_once(delivery_id, event_name, payload)
  # WebhookJobs.perform_async(delivery_id)

  status 202
end

GitHub’s documented signature is an HMAC-SHA256 hexadecimal digest over the raw body, prefixed with sha256=. The comparison must be constant-time; ordinary == is not an appropriate security decision. GitHub also advises storing the secret securely and never hardcoding or committing it.

Routing by event and action

After verification, dispatch narrowly. GitHub payloads commonly include an event header and an action field. Check both so an event you do not support cannot accidentally invoke a handler.

case event_name
when "issues"
  action = payload.fetch("action")
  case action
  when "opened"
    # enqueue issue-opened work
  when "closed"
    # enqueue issue-closed work
  end
when "push"
  # enqueue push processing
else
  # Ignore subscribed-but-unhandled events safely
end

Use fetch or explicit validation for fields that are required by your business logic. Treat missing or malformed fields as a bad request or a failed job, according to your provider’s retry policy.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Rails implementation

Create a dedicated POST route and controller action. Obtain the raw body before Rails or middleware parses or transforms it.

# config/routes.rb
post "/webhooks/github", to: "webhooks#github"

# app/controllers/webhooks_controller.rb
class WebhooksController < ActionController::API
  def github
    raw_body = request.raw_post
    signature = request.headers["X-Hub-Signature-256"]
    secret = ENV.fetch("WEBHOOK_SECRET")
    expected = "sha256=" + OpenSSL::HMAC.hexdigest(
      OpenSSL::Digest.new("sha256"), secret, raw_body
    )

    head :unauthorized unless signature &&
      Rack::Utils.secure_compare(expected, signature)

    begin
      payload = JSON.parse(raw_body)
    rescue JSON::ParserError
      return head :bad_request
    end

    event_name = request.headers["X-GitHub-Event"]
    delivery_id = request.headers["X-GitHub-Delivery"]
    return head :bad_request if event_name.blank? || delivery_id.blank?

    # Insert delivery_id with a unique database constraint, then enqueue.
    # GithubWebhookJob.perform_later(delivery_id, event_name, payload)
    head :accepted
  end
end

Use the raw-body facility before JSON parsing. Avoid middleware that replaces the body, changes whitespace, normalizes encoding, or parses and re-serializes JSON before verification. Return a 4XX response for unauthenticated or malformed requests, and a 2XX response only after accepted work has been durably recorded or queued.

Provider-specific signature verification

GitHub

Read X-Hub-Signature-256, calculate HMAC-SHA256 over the unchanged body with the webhook secret, and compare the resulting sha256=... value in constant time. Use X-GitHub-Event for the event name and X-GitHub-Delivery as the delivery identifier.

Stripe

Use the Stripe Ruby SDK’s webhook construction and signature-verification API rather than adapting GitHub code. Stripe’s header format, timestamp tolerance, and exception classes are provider-specific. Preserve the unmodified body until the SDK verifies it, and handle the SDK’s signature-verification errors explicitly.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Other providers

Do not assume every sender uses HMAC-SHA256, the same header names, or the same replay rules. Follow that provider’s current documentation for canonicalization, timestamp windows, key rotation, and required response status.

Fast acknowledgements, retries, and idempotency

GitHub’s handling guidance requires a 2XX response within 10 seconds of receiving a delivery. Slow API calls, image processing, or database-heavy work should run in a background queue, not inside the request. Resque is one Ruby queue option named in GitHub’s guidance; any durable job system is acceptable.

Persist before returning

Insert the delivery ID, event name, raw payload (or a validated representation), and received timestamp in a transaction. Enqueue a job using an outbox or equivalent mechanism so a process crash cannot acknowledge an event that was never saved.

Make retries harmless

Providers retry timeouts and non-success responses. Put a unique constraint on the delivery ID and make each handler idempotent: a repeated delivery should produce the same final state, not duplicate an email, charge, or database record. The GitHub delivery ID is useful for detecting replays and investigating failures.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Return status deliberately

  • 2XX: authentication passed and the delivery was durably accepted.
  • 4XX: bad signature, malformed body, unsupported authentication, or invalid required fields.
  • 5XX: a transient server failure when you want the provider to retry.

Configuration and security checklist

  • Use an HTTPS webhook URL and verify the route is reachable from the provider.
  • Keep secrets in environment variables or a secret manager; rotate them using the provider’s supported procedure.
  • Read the raw body exactly once and verify before parsing.
  • Use constant-time comparison for MACs and signatures.
  • Limit request size and reject unexpected content types where practical.
  • Subscribe only to required event types.
  • Validate event, action, identifiers, and required fields after authentication.
  • Log delivery ID, event type, verification result, processing status, and latency, but never secrets.
  • Protect replay-sensitive handlers with delivery-ID uniqueness and, where the provider supports it, timestamp validation.

Testing a Ruby webhook endpoint

Test the security boundary and the operational path separately:

  • Send a valid body and correctly computed signature; expect a 2XX response and one durable delivery record.
  • Change one byte of the body without changing the signature; expect 401.
  • Use a malformed signature or omit it; expect 401.
  • Send invalid JSON with a valid MAC; expect 400.
  • Send the same delivery ID twice; verify the second request creates no duplicate side effect.
  • Exercise unknown event names and unsupported actions; verify they are ignored or rejected according to your contract.
  • Simulate a queue or database outage and confirm the endpoint returns a retryable failure rather than falsely acknowledging.

Use the provider’s delivery history and redelivery controls when diagnosing production failures. Record correlation IDs so an incoming request can be followed through the queue and handler.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Troubleshooting common failures

Every request returns 401

Check that the application reads the provider’s actual header name, the secret belongs to this endpoint, and the body has not been parsed or modified first. Log the header’s presence and calculated length, never the secret itself. Confirm that the expected value includes the provider-required prefix such as sha256=.

Signature works locally but fails in production

Verify the production secret, proxy configuration, request encoding, and middleware order. A reverse proxy or body parser that changes bytes will invalidate an otherwise correct HMAC.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

The provider reports timeouts

Measure time spent before the response. Move third-party calls and expensive work to a queue, persist the delivery first, and return a 2XX within the provider’s deadline. For GitHub, that deadline is 10 seconds.

Events are processed twice

Retries are normal. Add a unique database constraint for the delivery ID, check it before enqueueing, and make the job’s external side effects idempotent.

JSON parsing raises an exception

Parse only after successful authentication, catch the parser exception, enforce a request-size limit, and return 400 for invalid JSON. Do not attempt to “repair” a signed body before verification.

Or skip the browser setup

If you also need clean screenshots of webhook dashboards, documentation, or delivery history, ScreenshotNeo provides a single HTTP call rather than a browser automation stack. It accepts cookie and consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing result. Its MCP server exposes take_screenshot, get_page_info, and capture_pdf to Claude, Cursor, and other MCP clients.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

For a screenshot, use the documented API parameters and save the binary response:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

See the ScreenshotNeo API documentation for options such as full-page capture, CSS selectors, custom headers and cookies, JavaScript, waits, blocking, PDF output, signed links, asynchronous jobs, and bulk capture. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.

Frequently Asked Questions

Should a webhook endpoint return 200 or 202?

Use the status your provider accepts after the delivery is durably recorded or queued. A 202 clearly indicates asynchronous acceptance; follow the sender’s documented response requirements.

Can I verify a webhook after calling JSON.parse?

No. Verify the unchanged raw body first. Parsing and re-serializing can alter whitespace, encoding, or key representation and invalidate the signature.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

How do I handle webhook secret rotation?

Use the provider’s documented rotation process. During an overlap window, support the currently valid keys in a controlled order, then remove the old key after deliveries using it have ceased.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.