Skip to the documentation
Ruby

Verifying a delivery

`OpenEmail.verify_webhook_signature`: constant-time, with a replay window, and the parsed event back.

In a request handler

A webhook URL is public. Anything on the internet can POST the right-shaped JSON at it, so a handler that reads the event’s type without checking the signature is an open write API.

app/controllers/open_email_webhooks_controller.rb
class OpenEmailWebhooksController < ActionController::API  def create    event = OpenEmail.verify_webhook_signature(      payload: request.raw_post,      headers: request.headers,      secret: ENV.fetch("OPENEMAIL_WEBHOOK_SECRET"),      tolerance_seconds: 300    )     Rails.logger.info("#{event[:type]} #{event[:data]}")    head :no_content  rescue OpenEmail::WebhookSignatureError    head :bad_request  endend

Pass the RAW body, as a String. Parsing and serialising again changes key order and whitespace, and the signature will not match, which is why a Hash, such as Rails’ params, raises ArgumentError instead of being checked. headers: takes a Hash with the header name in any case, a Rack env, where the header arrives as HTTP_X_OPENEMAIL_SIGNATURE, or Rails’ request.headers. A value that is an Array is read from its first element.

Two things this handles that a hand-rolled check usually does not: it compares the MAC in constant time, so the correct prefix cannot be recovered by timing it, and it rejects a delivery more than tolerance_seconds: old in either direction, five minutes unless you say otherwise, so a captured request is not replayable for ever. tolerance_seconds: 0 turns the replay check off. Both bugs are silent. A handler with either one passes every test you would think to write.

It raises OpenEmail::WebhookSignatureError on every failure: a missing X-OpenEmail-Signature header, one not in the form t=<seconds>,v1=<hex>, a timestamp outside the window, or a signature that does not match. On success it returns the body parsed into a Hash with Symbol keys, id, type, createdAt and data, so there is no second JSON.parse to get wrong.

A missing or empty secret: raises ArgumentError instead, because that is a mistake in your configuration rather than a bad delivery. Rescue only OpenEmail::WebhookSignatureError and answer 400, so a misconfigured server answers 500 and the delivery is tried again once you fix it.

It uses OpenSSL from the standard library, which the gem already loads, so it needs nothing else.

In a Rack or Sinatra app

Outside Rails, pass the Rack env itself as headers: and read the raw body from the request.

require "openemail"require "rack" webhook = lambda do |env|  event = OpenEmail.verify_webhook_signature(    payload: Rack::Request.new(env).body.read,    headers: env,    secret: ENV.fetch("OPENEMAIL_WEBHOOK_SECRET")  )   warn "#{event[:type]} #{event[:id]}"  [204, {}, []]rescue OpenEmail::WebhookSignatureError  [400, {"content-type" => "text/plain"}, ["bad signature"]]end run webhook

The Rack version is a whole config.ru. Answer quickly in either: a delivery that gets no answer within 5 seconds counts as failed and is sent again later, so hand the work to a job and reply with a 2xx.

OpenEmail::WEBHOOK_SIGNATURE_HEADERS names the three headers a delivery carries: X-OpenEmail-Signature, X-OpenEmail-Event with the event’s type, and X-OpenEmail-Delivery with its id, the same id as in the body. That id stays the same on every retry and replay of the event, so it is the one to store when you skip events you have already handled.

While the secret rotates

webhooks.rotate_secret has no overlap window: deliveries are signed with the new secret from the moment it returns. Deploy a receiver that accepts either secret first, rotate, store the new secret where the receiver reads it, then drop the old one.

two_secrets.rb
def verify_delivery(payload, headers)  secrets = [ENV.fetch("OPENEMAIL_WEBHOOK_SECRET"), ENV["OPENEMAIL_WEBHOOK_SECRET_NEXT"]].compact   secrets.each_with_index do |secret, index|    return OpenEmail.verify_webhook_signature(payload:, headers:, secret:)  rescue OpenEmail::WebhookSignatureError    raise if index == secrets.size - 1  endend

webhooks.test sends a signed synthetic event, so call it after the rotation to prove the new secret verifies before you remove the old one.