---
title: "Verifying a delivery"
description: "`OpenEmail.verify_webhook_signature`: constant-time, with a replay window, and the parsed event back."
url: "https://openemail.uk/docs/ruby/webhooks/verify"
area: "Ruby"
category: "Webhooks"
---

# 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
  end
end
```

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.

**Receivers**

_Rack_

```ruby
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
```

_Sinatra_

```ruby
require "sinatra"
require "openemail"

post "/webhooks/openemail" do
  event = OpenEmail.verify_webhook_signature(
    payload: request.body.read,
    headers: request.env,
    secret: ENV.fetch("OPENEMAIL_WEBHOOK_SECRET")
  )

  logger.info("#{event[:type]} #{event[:id]}")
  status 204
rescue OpenEmail::WebhookSignatureError
  halt 400, "bad signature"
end
```

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
  end
end
```

`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.
