~ 8 min read
HTTP webhooks on Firebase Functions and Fastify: A Practical Case Study with 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.
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! ;-)