Notes to self

Verifying Paddle Billing webhooks with Rails

Paddle recently made a major overhaul of its offering and API. If you need to validate the new Paddle Billing webhooks in Rails, here’s how to do it.

Notification verification

Paddle introduced a new offering called Paddle Billing (while renaming the old one to Classic) and since I am supporting Paddle in Business Class I am now looking at required changes for this integration.

One such a change is signature verification for webhooks (generally called notifications within Paddle Billing).

Since Paddle itself doesn’t include a Ruby example, I thought it might be useful to go through the text instructions and see how we can now verify the incoming webhooks from Paddle.

What we’ll need

To verify an incoming webhook we’ll need a notification’s secret key and Paddle’s signature.

Paddle dropped verification based on the public key and is instead providing a secret key (like a password). You can get your secret key in Developer Tools -> Notifications. Add or edit a webhook, and find the key under Secret key.

Let’s assume this key will come from the environment and call it PADDLE_SECRET_KEY.

The second thing we’ll need is a Paddle verification that will be automatically included in HTTP headers under Paddle-Signature. The value of the header will look like this:

ts=1671552777;h1=eb4d0dc8853be92b7f063b9f3ba5233eb920a09459b6e6b2c26705b4364db151

The first part is a timestamp, the second part is the signature.

Extracting timestamp and signature from header

The documentation starts by advising us to parse the Paddle-Signature. In Rails we can access HTTP headers within a controller with request.headers within a controller. We should then get a timestamp and signature by separating these two:

# Pass Paddle signature from request.headers["Paddle-Signature"]
def valid_signarure?(paddle_signature)
  ts_part, h1_part = paddle_signature.split(";")
  var, ts = ts_part.split("=")
  var, h1 = h1_part.split("=")
...

Building signed payload

The following step is to build signed payload.

As Paddle puts it:

Paddle creates a signature by first concatenating the timestamp (ts) with the body of the request, joined with a colon (:).

In a Rails controller, we can get the raw body from request.raw_post.

So let’s do that:

signed_payload = "#{ts}:#{request.raw_post}"

Hashing signed payload

The next step is where the magic happens.

I’ll include the Paddle instructions again:

Paddle generates signatures using a keyed-hash message authentication code (HMAC) with SHA256 and a secret key.

Compute the HMAC of your signed payload using the SHA256 algorithm, using the secret key for this notification destination as the key.

This should give you the expected signature of the webhook event.

First we’ll prepare our key, signed payload and digest:

key = ENV["PADDLE_SECRET_KEY"]
data = signed_payload
digest = OpenSSL::Digest.new("sha256")

Then we’ll get the HEX digest (not a regular one):

hmac = OpenSSL::HMAC.hexdigest(digest, key, data)

Comparing signatures

Finally, we’ll just compare our hmac with the Paddle signature that we saved as h1:

hmac == h1

If they match, all is well and we can process the notification. If not, we should return an HTTP 400 Bad Request response and drop the request.

Final solution

A final verification code looks like the following:

# Pass Paddle signature from request.headers["Paddle-Signature"]
def valid_signarure?(paddle_signature)
  ts_part, h1_part = paddle_signature.split(";")
  var, ts = ts_part.split("=")
  var, h1 = h1_part.split("=")

  signed_payload = "#{ts}:#{request.raw_post}"

  key = ENV["PADDLE_SECRET_KEY"]
  data = signed_payload
  digest = OpenSSL::Digest.new("sha256")

  hmac = OpenSSL::HMAC.hexdigest(digest, key, data)
  hmac == h1
end

Work with me

I have some availability for contract work. I can be your fractional CTO, a Ruby on Rails engineer, or consultant. Write me at strzibny@strzibny.name.

RSS