How to verify Paddle webhooks in a Cloudflare Worker

Tim Hanlon

We're using Paddle to handle our subscription billing for Yield Report, because they handle all the joys of international tax compliance for you.

Paddle uses webhooks for subscription events, like when a subscription is created or a successful payment has occurred. In our case, our API is built with Hasura, so we don't have a monolithic backend to handle these webhooks from. Enter Cloudflare Workers, the perfect tool to handle these webhooks and pass the data into our API.

Handling the webhooks was relatively trivial, but verifying the SHA-1 signature that Paddle provides so that you can verify that they actually sent it was...not at all trivial. Paddle provides a sample Node implementation and a third-party NPM package, but we can't use the Node crypto library in a Cloudflare Worker.

So armed with the Web Crypto API documentation on MDN and Cloudflare, I embarked on a process of trial and error that lasted hours.

Here's what I ended up with:

const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
Paste your public key from https://vendors.paddle.com/public-key
-----END PUBLIC KEY-----`

/*
Return form data as an object
Adapted from https://developers.cloudflare.com/workers/examples/read-post
*/
async function readRequestBody(request) {
  const formData = await request.formData()
  const body = {}
  for (const entry of formData.entries()) {
    body[entry[0]] = entry[1]
  }
  return body
}

/*
Convert a string into an ArrayBuffer for use with the Web Crypto API
From https://developers.google.com/web/updates/2012/06/How-to-convert-ArrayBuffer-to-and-from-String
*/
function str2ab(str) {
  const buf = new ArrayBuffer(str.length)
  const bufView = new Uint8Array(buf)
  for (let i = 0, strLen = str.length; i < strLen; i++) {
    bufView[i] = str.charCodeAt(i)
  }
  return buf
}

/*
Import a PEM public key for use with the Web Crypto API
Adapted from https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey#Examples
*/
async function importRsaKey(pem) {
  // fetch the part of the PEM string between header and footer
  const pemHeader = "-----BEGIN PUBLIC KEY-----"
  const pemFooter = "-----END PUBLIC KEY-----"
  const pemContents = pem.substring(pemHeader.length, pem.length - pemFooter.length)
  // base64 decode the string to get the binary data
  const binaryDerString = atob(pemContents)
  // convert from a binary string to an ArrayBuffer
  const binaryDer = str2ab(binaryDerString)

  return await crypto.subtle.importKey(
    "spki",
    binaryDer,
    {
      name: "RSASSA-PKCS1-v1_5",
      hash: "SHA-1"
    },
    true,
    ["verify"]
  )
}

/*
Verify a Paddle webhook signature using the Web Crypto API
Adapted from https://github.com/daveagill/verify-paddle-webhook/blob/master/index.js
*/
async function verifyPaddleWebhook(publicKey, webhookData) {
  try {
    const { serialize } = require('php-serialize')

    // extract the signature from the remainder of the payload
    // the signature actually signs the remainder
    const { p_signature:signature, ...otherProps } = webhookData || {}

    // sort by key (asciibetical)
    // also be sure to convert any numbers into strings
    const sorted = {}
    for (const k of Object.keys(otherProps).sort()) {
      const v = otherProps[k]
      sorted[k] = v == null ? null : v.toString()
    }
        
    // PHP-style serialization to utf8 format string
    const serialized = serialize(sorted)

    // https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/verify
    const result = await crypto.subtle.verify(
      'RSASSA-PKCS1-v1_5',
      await importRsaKey(publicKey),
      str2ab(atob(signature)),
      str2ab(serialized)
    )

    return result
  } catch (e) {
    console.log(e)
    return false
  }
}

async function webhookHandler(request) {
  const webhookData = await readRequestBody(request)

  const isValid = await verifyPaddleWebhook(PUBLIC_KEY, webhookData)
  if (!isValid) {
    return new Response(null, { status: 401 })
  }

  // Draw the rest of the fucking owl
  // https://knowyourmeme.com/memes/how-to-draw-an-owl
  
  // Paddle expects a HTTP 200 response within 10 seconds
  // https://developer.paddle.com/webhook-reference/intro
  return new Response(null, { status: 200 })
}

addEventListener('fetch', event => {
  const { request } = event

  if (request.method === "POST") {
    return event.respondWith(webhookHandler(request))
  }
})
© twofutures Pty Ltd 2020