~ 8 min read

HTTP webhooks on Firebase Functions and Fastify: A Practical Case Study with Lemon Squeezy

share this story on
A break-down of how to set up Fastify to work on serverless Firebase Functions and access the request's rawBody to validate incoming HTTP webhooks requests from Lemon Squeezy.

I’m building a side-project on Firebase and as it usually is with overly abstracted platforms, the rudimentary things often get in the way. This step-by-step code through is how I managed to get a Fastify application to act as a Serverless Function on Firebase that is triggered upon receiving an HTTP webhook from Lemon Squeezy.

This Firebase Functions webhooks case study teaches on solving the following:

  • Using the Fastify web framework instead of vanilla JavaScript code for Firebase Functions
  • Fastify doesn’t provide access to the HTTP request’s raw body
  • Validating the HTTP webhook signature from Lemon Squeezy

The Lemon Squeezy use-case

Lemon Squeezy is an online merchant store provider and a payment processor and makes a good quick-to-get-running Gumroad and Stripe alternative for indiedevs.

In the project I’m building I needed to configure a webhook on Lemon Squeezy so that any time an order gets processed I can update the database entry for the user and have a record for the plans they bought.

HTTP Webhooks on Lemon Squeezy store settings

The Firebase tech stack: Functions and Firestore

The backend tech stack on Firebase is a simple use of Firebase Functions with a deployed HTTP function trigger that validates the signature of the incoming HTTP webhook from Lemon Squeezy and create Firestore database entries with the details of the order.

Instead of building on top of vanilla JavaScript for Firebase Functions, I choose to go with the Fastify framework for the following benefits:

  • Better control over HTTP aspects such as adding CORS.
  • All API endpoints reside in one codebase but provide a modular microservice-oriented architecture with Fastify’s plugin system.
  • I like Fastify and it allows me to easily shift-and-lift in the future if I need to deploy this in a different infrastructure.

Phase 1: Firebase Functions on Vanilla JavaScript

The following shows a working example of a Firebase Function called lmswebhook that deploys under a route of similar name and it exports just this one function:

// The Cloud Functions for Firebase SDK to create Cloud Functions and triggers.
const { logger } = require("firebase-functions");
const { onRequest } = require("firebase-functions/v2/https");

// The Firebase Admin SDK to access Firestore.
const { initializeApp } = require("firebase-admin/app");
const { getFirestore } = require("firebase-admin/firestore");

initializeApp();

exports.lmswebhook = onRequest(async (req, res) => {
  // capture the data coming from the HTTP request
  const requestData = req.body;

  // following is an example of some of the data that exists on
  // the Lemon Squeezy webhook:
  // const orderTransaction = {
  //   type: requestData.data.type,
  //   id: requestData.data.id,
  //   customer_id: requestData.data.customer_id,
  //   identifier: requestData.data.identifier,
  //   order_number: requestData.data.order_number,
  //   user_name: requestData.data.user_name,
  //   user_email: requestData.data.user_email,
  //   status: requestData.data.status,
  //   total: requestData.data.total,
  //   created_at: requestData.data.created_at,
  //   updated_at: requestData.data.updated_at,
  // };

  // @TODO you want to change this to whatever identifier you
  // pass in the webhook via the custom fields
  const uid = requestData.uid;
  // @TODO similarly if you want to use your own unique
  // plan or order identifier
  const planId = requestData.planId;

  // The following
  const orderId = requestData.data.id;
  const userEmail = requestData.data.user_email;

  const db = await getFirestore();

  // Construct the collection path
  const docRef = db
    .collection("users")
    .doc(uid)
    .collection("plans")
    .doc(planId);

  // Use a transaction to ensure atomic update
  await db.runTransaction(async (transaction) => {
    // Get the document snapshot
    const doc = await transaction.get(docRef);

    // Create the document if it doesn't exist
    if (!doc.exists) {
      transaction.set(docRef, { orderId, userEmail });
    } else {
      // Update the document if it already exists
      transaction.update(docRef, { orderId, userEmail });
    }
  });

  return res.status(200).send();
});

You can access the Lemon Squeezy order data from the request body, but notice that there is no webhook signature verification code involved here, which means that literally anyone on the Internet can send you HTTP requests that are fake and alter your data (⚠️).

You can deploy the function with Firebase tools CLI:

firebase deploy --only functions

Phase 2: Use Fastify for the Firebase Function

Let’s update the code snippet above to introduce Fastify as the library that handles the HTTP request and reply and provides the foundational web framework needs, such as:

  • Adding CORS
  • Configuring a request and response schema
  • Routing middleware

Here is the revised version to include Fastify:

// The Cloud Functions for Firebase SDK to create Cloud Functions and triggers.
const { logger } = require("firebase-functions");
const { onRequest } = require("firebase-functions/v2/https");

// The Firebase Admin SDK to access Firestore.
const { initializeApp } = require("firebase-admin/app");
const { getFirestore } = require("firebase-admin/firestore");

const fastify = require("fastify")({
  logger: true,
});

fastify.addContentTypeParser("application/json", {}, (req, body, done) => {
  done(null, body.body);
});

initializeApp();

fastify.post("/lms-webhook", async (request, reply) => {
  const requestData = request.body;

  // following is an example of some of the data that exists on
  // the Lemon Squeezy webhook:
  // const orderTransaction = {
  //   type: requestData.data.type,
  //   id: requestData.data.id,
  //   customer_id: requestData.data.customer_id,
  //   identifier: requestData.data.identifier,
  //   order_number: requestData.data.order_number,
  //   user_name: requestData.data.user_name,
  //   user_email: requestData.data.user_email,
  //   status: requestData.data.status,
  //   total: requestData.data.total,
  //   created_at: requestData.data.created_at,
  //   updated_at: requestData.data.updated_at,
  // };

  // @TODO you want to change this to whatever identifier you
  // pass in the webhook via the custom fields
  const uid = requestData.uid;
  // @TODO similarly if you want to use your own unique
  // plan or order identifier
  const planId = requestData.planId;

  // The following
  const orderId = requestData.data.id;
  const userEmail = requestData.data.user_email;

  const db = await getFirestore();

  // Construct the collection path
  const docRef = db
    .collection("users")
    .doc(uid)
    .collection("plans")
    .doc(planId);

  // Use a transaction to ensure atomic update
  await db.runTransaction(async (transaction) => {
    // Get the document snapshot
    const doc = await transaction.get(docRef);

    // Create the document if it doesn't exist
    if (!doc.exists) {
      transaction.set(docRef, { orderId, userEmail });
    } else {
      // Update the document if it already exists
      transaction.update(docRef, { orderId, userEmail });
    }
  });

  return reply.code(200).send();
});

const fastifyApp = async (request, reply) => {
  await fastify.ready();
  fastify.server.emit("request", request, reply);
};

exports.app = onRequest(fastifyApp);

Let’s break this code down for the Fastify parts that make it up.

We begin by requiring Fastify and initializing it with a logger default:

const fastify = require("fastify")({
  logger: true,
});

Then, we tell Fastify that it doesn’t need to parse the HTTP request’s raw data by calling the addContentTypeParser() function, and all we do is call the done callback function with payload.body because that body property is already in JSON format.

Why do we need to do that? the HTTP layer of Firebase Functions already parses the raw HTTP requests payload based on the content type and provides the already-parsed JSON data. So we explicitly tell Fastify here that it doesn’t need to do any special parsing of its own:

fastify.addContentTypeParser("application/json", {}, (req, payload, done) => {
  done(null, payload.body);
});

Next up is our POST endpoint API route definition which should be a very familiar and straightforward example for you:

fastify.post("/lms-webhook", async (request, reply) => {
    // ...
    return reply.code(200).send();
});

Then we continue to the stock code of working with Fastify. It is a good convention with Fastify to wait until all plugins have finished loading, so we call fastify.read() and wait for the promise to resolve.

Usually, we would return the Fastify app instance (which itself, is a plugin, how cool?), but in this case on Firebase there’s no way for Fastify to bind to an IP address on a network interface. So how do we do it?

We use fastify.server.emit() to emulate an HTTP request that originates from the lower levels of Firebase Functions and push that to Fastify’s own HTTP request handling layer.

All of this put together looks as follows:

const fastifyApp = async (request, reply) => {
  await fastify.ready();
  fastify.server.emit("request", request, reply);
};

Finally, we export a Firebase Function called app and we wrap the Fastify app with Firebase’s own onRequest() function which handles all the HTTP stuff for us:

exports.app = onRequest(fastifyApp);

Phase 3: Validating the HTTP webhook from Lemon Squeezy

You’d think that validating the incoming HTTP webhook from Lemon Squeezy servers would be a straight-forward thing to do, huh?

The crux of the problem is the following:

  • For performance reasons, Fastify does one of two things only: it either parses the JSON and serves that to the routes on the request object, or it leaves it in its raw form for you to handle that (just like we added our own JSON parser, remember?)
  • The Firebase layer only provides the JSON parsed data. However, luckily for us, they already solved that problem a while back and this isn’t a problem anymore.

We address the raw body with a one-liner change:

  fastify.addContentTypeParser("application/json", {}, (req, payload, done) => {
    req.rawBody = payload.rawBody;
    done(null, payload.body);
  });

The change is to update the previous function call for request parsing to use the payload that is passed from Firebase HTTP layer (payload) so that we save the rawBody on the request object.

This then allows us to access the raw body via request.rawBody in the route handler so we can calculate the digest.

Validating the HTTP request webhook signature looks as follows:

function validateWebhookRequest(request) {
  const secret = LEMON_SQUEEZY_WEBHOOK_SECRET;
  const hmac = crypto.createHmac("sha256", secret);
  const digest = Buffer.from(
    hmac.update(request.rawBody).digest("hex"),
    "utf8"
  );
  const signature = Buffer.from(request.headers["x-signature"] || "", "utf8");

  if (!crypto.timingSafeEqual(digest, signature)) {
    throw new Error("Invalid signature.");
  }
}

In summary, there’s a fully working code example in the following GitHub repository here: https://github.com/lirantal/lemon-squeezy-firebase-webhook-fastify

Have fun indie hacking! ;-)