Bonaventure OgetoBy Bonaventure Ogeto|

Handle Paystack Webhooks in Firebase Cloud Functions

Create a Firebase Cloud Function using onRequest (v2). Access req.rawBody for HMAC SHA512 verification with crypto.createHmac. Compare the hash against the x-paystack-signature header, send 200 immediately, then update Firestore using the Firebase Admin SDK.

Webhook Cloud Function

// functions/src/index.ts
import { onRequest } from "firebase-functions/v2/https";
import { defineSecret } from "firebase-functions/params";
import { getFirestore } from "firebase-admin/firestore";
import { initializeApp } from "firebase-admin/app";
import * as crypto from "crypto";

initializeApp();

const paystackSecretKey = defineSecret("PAYSTACK_SECRET_KEY");

export const paystackWebhook = onRequest(
  { secrets: [paystackSecretKey] },
  async (req, res) => {
    if (req.method !== "POST") {
      res.status(405).send("Method not allowed");
      return;
    }

    const signature = req.headers["x-paystack-signature"] as string;
    const secretKey = paystackSecretKey.value();

    // req.rawBody is a Buffer available in Firebase Cloud Functions
    const hash = crypto
      .createHmac("sha512", secretKey)
      .update(req.rawBody)
      .digest("hex");

    if (hash !== signature) {
      res.status(400).send("Invalid signature");
      return;
    }

    // Return 200 before processing
    res.sendStatus(200);

    const event = req.body;
    const db = getFirestore();

    // Idempotency check
    const eventId = event.data?.id?.toString() ?? event.data?.reference;
    if (eventId) {
      const processedRef = db.collection("webhook_events").doc(eventId);
      const existing = await processedRef.get();
      if (existing.exists) return;
      await processedRef.set({
        event_type: event.event,
        processed_at: new Date().toISOString(),
      });
    }

    switch (event.event) {
      case "charge.success": {
        const reference = event.data.reference;
        const amount = event.data.amount / 100;
        await db.collection("orders").doc(reference).set({
          status: "paid",
          paid_at: new Date().toISOString(),
          amount_paid: amount,
          currency: event.data.currency,
          customer_email: event.data.customer.email,
        }, { merge: true });
        break;
      }

      case "transfer.success": {
        const transferCode = event.data.transfer_code;
        await db.collection("transfers")
          .where("transfer_code", "==", transferCode)
          .get()
          .then(snapshot => {
            snapshot.forEach(doc => doc.ref.update({ status: "success" }));
          });
        break;
      }

      case "invoice.payment_failed": {
        const email = event.data.customer.email;
        await db.collection("subscriptions")
          .where("email", "==", email)
          .get()
          .then(snapshot => {
            snapshot.forEach(doc => doc.ref.update({ status: "past_due" }));
          });
        break;
      }
    }
  }
);

Setup and Deploy

# Set Paystack secret key as a Firebase secret
firebase functions:secrets:set PAYSTACK_SECRET_KEY

# Deploy only the webhook function
firebase deploy --only functions:paystackWebhook

After deploying, add the function URL to your Paystack dashboard webhook settings:

https://<region>-<project-id>.cloudfunctions.net/paystackWebhook

Create the Firestore security rules for webhook_events (server-only access):

match /webhook_events/{eventId} {
  allow read, write: if false; // Only Admin SDK can access
}

Learn More

Key Takeaways

  • Firebase Cloud Functions provide req.rawBody (Buffer) when content-type is application/json — use it for HMAC verification.
  • Use crypto.createHmac("sha512", secretKey).update(req.rawBody).digest("hex") for the signature check.
  • Send res.sendStatus(200) before processing the event — Paystack retries on slow or failed responses.
  • Mark events as processed in Firestore (webhook_events collection) to prevent duplicate handling on retries.
  • Handle charge.success, transfer.success, subscription.create, and invoice.payment_failed as a minimum.
  • Use Firebase Cloud Messaging in the same function to push payment notifications to mobile apps.

Frequently Asked Questions

Is req.rawBody available in Firebase Cloud Functions v2?
Yes. Firebase Cloud Functions (both v1 and v2) provide req.rawBody as a Buffer when the incoming request has Content-Type: application/json. This is a Firebase-specific addition to the standard Express request object. Use it for Paystack HMAC signature verification instead of re-reading the body.
Why not use req.body (the parsed JSON) for signature verification?
Paystack computes the HMAC over the exact raw bytes it sent. If you serialize req.body back to a string (JSON.stringify), the byte order or whitespace might differ. Always use req.rawBody (the original Buffer) for accurate HMAC computation.
How do I test Paystack webhooks locally with Firebase Functions?
Run firebase emulators:start --only functions. Expose your local emulator with ngrok: ngrok http 5001. The webhook URL will be http://<ngrok-id>.ngrok.io/<project-id>/<region>/paystackWebhook. Use the Paystack webhook simulator or curl to send test events.
Can I send a Firebase push notification from the same webhook function?
Yes. After updating Firestore, call getMessaging().send() from firebase-admin/messaging. Pass the user's FCM token stored in Firestore. This lets you push payment confirmed notifications to Android or iOS apps in the same Cloud Function that handles the Paystack webhook.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon