DEV Community

Cover image for Stop Trusting Every Webhook - Verify Paystack Signatures in Express for Beginners
FredAbod
FredAbod

Posted on

Stop Trusting Every Webhook - Verify Paystack Signatures in Express for Beginners

So you added Paystack to your app.

The checkout pops up. The test card goes through. Your webhook route logs charge.success.

You mark the order as paid, you push to production, and you go to bed feeling like a payments engineer. 😎

Now, quick question.

What stops anybody on the internet from sending this to your server?

curl -X POST https://your-api.com/webhooks/paystack \
  -H "Content-Type: application/json" \
  -d '{"event":"charge.success","data":{"reference":"order_001","amount":500000}}'
Enter fullscreen mode Exit fullscreen mode

If your answer is "nothing"... then congratulations, you just built a free ₦5,000 voucher machine. 😭😭

Shocked

Your webhook URL is just a URL. Anyone who finds it can POST to it. So in this article we are going to fix that, step by step:

  • Check that the webhook really came from Paystack (the signature)
  • Do it on the raw body, because that's where most people get stuck
  • Ignore the same event if it shows up twice
  • Ask Paystack directly before we give anybody value

The Estate Gate Analogy 🏘️

Picture an estate in Lagos with a gate and a security man. Let's call him Baba Gate.

A delivery man walks up and says: "Paystack sent me. The customer in Flat 3 has paid. Release their goods."

A lazy Baba Gate just opens the gate. Anyone can say "Paystack sent me".

A good Baba Gate does four things:

  1. Checks the stamp on the letter. Paystack signs every letter with a stamp only Paystack and you can make (your secret key).
  2. Checks the stamp against the original letter. Not a photocopy. Not a version someone retyped neatly. The original.
  3. Checks his logbook. "Did I already let this same delivery in this morning?"
  4. Calls the office to confirm. "Did Flat 3 really pay ₦5,000?"

Hold on to Baba Gate. He is the whole article. 😁

Prerequisites

  • Node.js 18 or newer (we use the built-in fetch)
  • Basic Express knowledge (routes, middleware)
  • A little TypeScript (if you've read my Express + TypeScript post, you're good)
  • A Paystack account is optional. I'll explain below.

Honest note: in this tutorial we fake the Paystack side so you can run everything on your laptop. We sign our own test webhooks with a practice secret, and the "call the office" step uses a small fake ledger instead of the real Verify Transaction API. When you are ready, one environment variable switches it to the real API.

Let's Dive Right In 😁😁

mkdir paystack-webhook-guard && cd paystack-webhook-guard
npm init -y
npm i express@5
npm i -D typescript @types/express @types/node
Enter fullscreen mode Exit fullscreen mode

Here is where we're going:

paystack-webhook-guard/
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ config.ts
β”‚   β”œβ”€β”€ app.ts
β”‚   β”œβ”€β”€ server.ts
β”‚   β”œβ”€β”€ routes/
β”‚   β”‚   └── webhook.ts
β”‚   β”œβ”€β”€ services/
β”‚   β”‚   β”œβ”€β”€ paystack.ts
β”‚   β”‚   └── paymentHandler.ts
β”‚   β”œβ”€β”€ store/
β”‚   β”‚   └── orderStore.ts
β”‚   └── utils/
β”‚       └── verifySignature.ts
β”œβ”€β”€ scripts/
β”‚   β”œβ”€β”€ send-webhook.ts
β”‚   └── why-raw.ts
β”œβ”€β”€ package.json
└── tsconfig.json
Enter fullscreen mode Exit fullscreen mode

And the flow, Baba Gate style:

Paystack ──POST──▢ /webhooks/paystack
                     β”‚
                     β–Ό
           express.raw()  β†’ keep the ORIGINAL letter (Buffer)
                     β”‚
                     β–Ό
           check x-paystack-signature  β”€β”€βœ—β”€β”€β–Ά 401, gate stays shut
                     β”‚ βœ“
                     β–Ό
           JSON.parse + reply 200 quickly
                     β”‚
                     β–Ό
           logbook: seen this event?  ──yes──▢ skip
                     β”‚ no
                     β–Ό
           call Paystack: verify reference, amount, currency
                     β”‚ βœ“
                     β–Ό
           mark order as paid πŸŽ‰
Enter fullscreen mode Exit fullscreen mode

Step 0: The boring setup β€” tsconfig.json and package.json

Nobody claps for config files, but they are holding the house up.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "rootDir": ".",
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src", "scripts"]
}
Enter fullscreen mode Exit fullscreen mode

Add these scripts to your package.json:

"scripts": {
  "build": "tsc --strict",
  "start": "node dist/src/server.js",
  "webhook": "node dist/scripts/send-webhook.js"
}
Enter fullscreen mode Exit fullscreen mode

That's it. strict is on, because we are adults now. πŸ˜…

Step 1: Baba Gate's secret β€” src/config.ts

This file does one thing. It reads your secret key and refuses to start without it.

export const config = {
  port: Number(process.env.PORT ?? 4000),
  paystackSecret: process.env.PAYSTACK_SECRET_KEY ?? "",
  // true = pretend to be Paystack's Verify Transaction API (for local practice)
  mockPaystack: process.env.PAYSTACK_MOCK !== "false",
};

if (!config.paystackSecret) {
  throw new Error("PAYSTACK_SECRET_KEY is missing. Put it in your environment.");
}
Enter fullscreen mode Exit fullscreen mode
  • Your Paystack secret key is what Paystack uses to stamp the letters. Never put it in your frontend. Never commit it.
  • PAYSTACK_MOCK is on by default so you can practise without hitting the real API.

Step 2: Checking the stamp β€” src/utils/verifySignature.ts

Paystack's webhook docs say every webhook comes with an x-paystack-signature header, and that header is an HMAC SHA512 signature of the payload, signed with your secret key.

In plain English: Paystack takes the exact body it's sending you, mixes it with your secret key, and produces a long hex string. We do the same mixing on our side. If our string matches theirs, the letter is real.

import crypto from "node:crypto";

export function isValidPaystackSignature(
  rawBody: Buffer,
  signature: string | undefined,
  secret: string
): boolean {
  if (!signature) return false;

  const expected = crypto
    .createHmac("sha512", secret)
    .update(rawBody)
    .digest("hex");

  const expectedBuf = Buffer.from(expected, "utf8");
  const receivedBuf = Buffer.from(signature, "utf8");

  // timingSafeEqual throws if the lengths differ, so check first
  if (expectedBuf.length !== receivedBuf.length) return false;

  return crypto.timingSafeEqual(expectedBuf, receivedBuf);
}
Enter fullscreen mode Exit fullscreen mode

Three important lines, don't skip them:

  • .update(rawBody) β€” we hash the raw bytes, not a JavaScript object. (Step 4 explains why this matters so much.)
  • crypto.timingSafeEqual β€” a normal === can stop early on the first different character, and that timing difference is information an attacker can measure. timingSafeEqual compares in constant time.
  • The length check β€” timingSafeEqual throws a RangeError if the two buffers have different lengths. I tested it: Input buffers must have the same byte length. So if someone sends x-paystack-signature: lol, we return false instead of crashing.

Step 3: The estate records β€” src/store/orderStore.ts

We need somewhere to keep orders and a logbook of events we've already handled. For this tutorial, it's a Map and a Set in memory.

export type OrderStatus = "pending" | "paid";

export interface Order {
  reference: string;
  amount: number; // in kobo (100 kobo = ₦1)
  currency: "NGN";
  status: OrderStatus;
}

// In-memory for this tutorial. In real life: your database.
const orders = new Map<string, Order>([
  ["order_001", { reference: "order_001", amount: 500000, currency: "NGN", status: "pending" }],
  ["order_002", { reference: "order_002", amount: 250000, currency: "NGN", status: "pending" }],
]);

// Our logbook of events we have already handled
const processedEvents = new Set<string>();

export const orderStore = {
  find(reference: string): Order | undefined {
    return orders.get(reference);
  },
  markPaid(reference: string): void {
    const order = orders.get(reference);
    if (order) order.status = "paid";
  },
  // Returns false if we've seen this key before
  claimEvent(key: string): boolean {
    if (processedEvents.has(key)) return false;
    processedEvents.add(key);
    return true;
  },
  releaseEvent(key: string): void {
    processedEvents.delete(key);
  },
};
Enter fullscreen mode Exit fullscreen mode
  • Amounts are in kobo. Paystack works with the subunit of the currency, so ₦5,000 is 500000. Mix this up and someone pays ₦50 for a ₦5,000 order. πŸ™ƒ
  • claimEvent is Baba Gate's logbook. First time we see a key, we write it down and return true. Second time, false.

In production this is your database. More on that at the end.

Step 4: The original letter, not a photocopy β€” src/routes/webhook.ts

This is the heart of the whole thing. πŸ‘€

Most Express apps have this line at the top of app.js:

app.use(express.json());
Enter fullscreen mode Exit fullscreen mode

It reads the request body, parses it, and gives you a nice object in req.body. Lovely for normal routes. Terrible for webhooks.

Why? Because once Express has parsed the body, the original bytes are gone. To check the signature you'd have to do JSON.stringify(req.body) and hope it comes out byte for byte the same as what Paystack sent.

To be fair, Paystack's own docs example does exactly that, and it works when the re-built string happens to match the original exactly. But "happens to match" is not a payments strategy. πŸ˜… Stripe's docs say it plainly: they need the raw body for signature verification, and any change to it makes verification fail.

Don't believe me? Here's a tiny script that signs the same data in three slightly different "shapes":

// Same data, different bytes = different signature
import crypto from "node:crypto";

const secret = "sk_test_practice_only";
const sign = (s: string) => crypto.createHmac("sha512", secret).update(s).digest("hex").slice(0, 16);

const raw = '{"event":"charge.success","data":{"reference":"order_001","amount":500000}}';
const rawWithSpaces = '{ "event": "charge.success", "data": { "reference": "order_001", "amount": 500000 } }';
const rawEscaped = '{"event":"charge.success","data":{"reference":"order\\/001","amount":500000}}';

for (const original of [raw, rawWithSpaces, rawEscaped]) {
  const rebuilt = JSON.stringify(JSON.parse(original));
  console.log(sign(original) === sign(rebuilt) ? "βœ… match" : "❌ MISMATCH", "|", original);
}
Enter fullscreen mode Exit fullscreen mode
npx tsc --strict && node dist/scripts/why-raw.js
Enter fullscreen mode Exit fullscreen mode

Output:

βœ… match | {"event":"charge.success","data":{"reference":"order_001","amount":500000}}
❌ MISMATCH | { "event": "charge.success", "data": { "reference": "order_001", "amount": 500000 } }
❌ MISMATCH | {"event":"charge.success","data":{"reference":"order\/001","amount":500000}}
Enter fullscreen mode Exit fullscreen mode

Same data. One extra space, or one escaped /, and the stamp no longer matches. That's the photocopy problem: Baba Gate is comparing the stamp against a retyped letter.

So we tell Express: for this one route, don't parse anything, just give me the raw bytes.

import express, { Router, Request, Response } from "express";
import { config } from "../config";
import { isValidPaystackSignature } from "../utils/verifySignature";
import { handlePaystackEvent, PaystackEvent } from "../services/paymentHandler";

const router = Router();

router.post(
  "/paystack",
  express.raw({ type: "application/json" }),
  (req: Request, res: Response) => {
    // 1. We must have the raw bytes, not a parsed object
    if (!Buffer.isBuffer(req.body)) {
      console.error("Webhook body is not raw. Is express.json() running before this route?");
      res.sendStatus(400);
      return;
    }

    // 2. Check the stamp
    const signature = req.get("x-paystack-signature");
    if (!isValidPaystackSignature(req.body, signature, config.paystackSecret)) {
      res.sendStatus(401);
      return;
    }

    // 3. Only now do we read the letter
    let event: PaystackEvent;
    try {
      event = JSON.parse(req.body.toString("utf8")) as PaystackEvent;
    } catch {
      res.sendStatus(400);
      return;
    }

    // 4. Say "received" fast, then do the work
    res.sendStatus(200);
    handlePaystackEvent(event).catch((err) =>
      console.error("Failed to process webhook:", err)
    );
  }
);

export default router;
Enter fullscreen mode Exit fullscreen mode

Let's walk through it:

  • express.raw({ type: "application/json" }) gives us req.body as a Buffer. We must set type, because by default express.raw() only handles application/octet-stream, and Paystack sends JSON.
  • The Buffer.isBuffer check catches the classic mistake (see Step 6). If some other parser got there first, we fail loudly instead of silently rejecting every real webhook.
  • We verify before we JSON.parse. Never read a letter you haven't confirmed is real.
  • We reply 200 before the slow work. Paystack's docs say to acknowledge the event before running long tasks, because a slow handler can time out and count as a failure. And failures get retried: in live mode, every 3 minutes for the first 4 tries, then hourly for the next 72 hours. Which leads us nicely to...

Thinking

Step 5: The logbook and the phone call β€” src/services/paystack.ts and src/services/paymentHandler.ts

Retries mean the same event can arrive more than once. Stripe's docs say the same about their webhooks, and recommend logging the event IDs you've processed and skipping ones you've already seen. Paystack's "Verify Payments" page also tells you to confirm you haven't already delivered value for a transaction, especially if you use webhooks.

Also: a valid stamp proves the letter came from Paystack. It doesn't hurt to call the office and confirm the details match your order before you release goods. Paystack's Verify Transaction endpoint is GET https://api.paystack.co/transaction/verify/:reference with your secret key as a Bearer token.

First, the "phone call":

import { config } from "../config";

export interface VerifyResponse {
  status: boolean;
  message: string;
  data: {
    status: string; // "success", "failed", "abandoned", ...
    reference: string;
    amount: number; // subunit, e.g. kobo
    currency: string;
  };
}

// Pretend Paystack's records, used only when PAYSTACK_MOCK is on
const mockLedger: Record<string, VerifyResponse["data"]> = {
  order_001: { status: "success", reference: "order_001", amount: 500000, currency: "NGN" },
  order_002: { status: "success", reference: "order_002", amount: 100, currency: "NGN" },
};

export async function verifyTransaction(reference: string): Promise<VerifyResponse> {
  if (config.mockPaystack) {
    const data = mockLedger[reference];
    if (!data) return { status: false, message: "Transaction reference not found", data: { status: "failed", reference, amount: 0, currency: "NGN" } };
    return { status: true, message: "Verification successful", data };
  }

  const res = await fetch(
    `https://api.paystack.co/transaction/verify/${encodeURIComponent(reference)}`,
    { headers: { Authorization: `Bearer ${config.paystackSecret}` } }
  );
  return (await res.json()) as VerifyResponse;
}
Enter fullscreen mode Exit fullscreen mode
  • With PAYSTACK_MOCK on, we answer from mockLedger. Notice order_002 in the fake ledger says 100 kobo. That's on purpose. We'll use it to see what happens when the numbers don't agree. πŸ˜‹
  • With PAYSTACK_MOCK=false, it calls the real endpoint with your key.

Now the handler, where Baba Gate makes his final decision:

import { orderStore } from "../store/orderStore";
import { verifyTransaction } from "./paystack";

export interface PaystackEvent {
  event: string;
  data: { reference: string; amount: number; currency: string; status: string };
}

export async function handlePaystackEvent(event: PaystackEvent): Promise<void> {
  if (event.event !== "charge.success") {
    console.log(`Ignoring event: ${event.event}`);
    return;
  }

  const { reference } = event.data;
  const key = `${event.event}:${reference}`;

  // Have we let this visitor in before?
  if (!orderStore.claimEvent(key)) {
    console.log(`Duplicate event ${key}, skipping`);
    return;
  }

  try {
    const order = orderStore.find(reference);
    if (!order) {
      console.warn(`No order for reference ${reference}`);
      return;
    }
    if (order.status === "paid") {
      console.log(`Order ${reference} already paid`);
      return;
    }

    // Don't trust the letter alone. Call Paystack and ask.
    const check = await verifyTransaction(reference);
    const ok =
      check.status === true &&
      check.data.status === "success" &&
      check.data.amount === order.amount &&
      check.data.currency === order.currency;

    if (!ok) {
      console.warn(`Verification failed for ${reference}`, check.data);
      return;
    }

    orderStore.markPaid(reference);
    console.log(`βœ… Order ${reference} marked as paid`);
  } catch (err) {
    orderStore.releaseEvent(key); // let a retry try again
    throw err;
  }
}
Enter fullscreen mode Exit fullscreen mode
  • We only care about charge.success here. Everything else is politely ignored.
  • event + reference is our logbook key. Same event, same reference, second time? Skip.
  • We claim the key before the await. If two copies arrive at the same moment, only one gets past the logbook.
  • We check status, amount and currency against our own order. Not the webhook's numbers. Ours.
  • If something crashes, releaseEvent removes the key so a future retry can try again.

Step 6: Order matters β€” src/app.ts and src/server.ts

Remember that app.use(express.json()) line? Here is where most bugs are born.

import express from "express";
import webhookRoutes from "./routes/webhook";
import { orderStore } from "./store/orderStore";

const app = express();

// Webhooks FIRST, before any JSON parser touches the body
app.use("/webhooks", webhookRoutes);

// Everybody else can have parsed JSON
app.use(express.json());

app.get("/orders/:reference", (req, res) => {
  const order = orderStore.find(req.params.reference);
  if (!order) {
    res.status(404).json({ message: "Order not found" });
    return;
  }
  res.json(order);
});

export default app;
Enter fullscreen mode Exit fullscreen mode

The webhook router is mounted before express.json().

If you flip them, express.json() reads the body first. Then express.raw() sees the body has already been handled and does nothing, so req.body is an object, not a Buffer. I tested this exact mistake on purpose and our guard caught it:

Webhook body is not raw. Is express.json() running before this route?
status 400
Enter fullscreen mode Exit fullscreen mode

Without that check you'd just get mysterious 401s on every real webhook and spend your Saturday blaming Paystack. It wasn't Paystack. It was you. It was always you 😭

And the server file is short:

import app from "./app";
import { config } from "./config";

app.listen(config.port, () => {
  console.log(`Server running on http://localhost:${config.port} (mock Paystack: ${config.mockPaystack})`);
});
Enter fullscreen mode Exit fullscreen mode

Step 7: Pretend to be Paystack β€” scripts/send-webhook.ts

We need someone to knock on the gate. This script builds a charge.success payload, stamps it with the same secret, and POSTs it. Exactly what Paystack does, minus the money. 😁

// Pretend to be Paystack: sign a payload with your secret and POST it.
import crypto from "node:crypto";

const secret = process.env.PAYSTACK_SECRET_KEY ?? "";
const url = process.env.WEBHOOK_URL ?? "http://localhost:4000/webhooks/paystack";
const reference = process.argv[2] ?? "order_001";
const amount = Number(process.argv[3] ?? 500000);

const body = JSON.stringify({
  event: "charge.success",
  data: { reference, amount, currency: "NGN", status: "success" },
});

const signature = crypto.createHmac("sha512", secret).update(body).digest("hex");

fetch(url, {
  method: "POST",
  headers: { "Content-Type": "application/json", "x-paystack-signature": signature },
  body,
}).then((res) => console.log(`Webhook for ${reference} -> ${res.status}`));
Enter fullscreen mode Exit fullscreen mode

Let's Test It πŸ§ͺ

Build and start the server:

npm run build
export PAYSTACK_SECRET_KEY=sk_test_practice_only
npm start
Enter fullscreen mode Exit fullscreen mode

In a second terminal (export the same PAYSTACK_SECRET_KEY there too):

1. A real, signed webhook

npm run -s webhook -- order_001 500000
curl -s localhost:4000/orders/order_001
Enter fullscreen mode Exit fullscreen mode
Webhook for order_001 -> 200
{"reference":"order_001","amount":500000,"currency":"NGN","status":"paid"}
Enter fullscreen mode Exit fullscreen mode

Server log: βœ… Order order_001 marked as paid πŸŽ‰

2. The same webhook again (a retry)

npm run -s webhook -- order_001 500000
Enter fullscreen mode Exit fullscreen mode
Webhook for order_001 -> 200
Enter fullscreen mode Exit fullscreen mode

Server log: Duplicate event charge.success:order_001, skipping. We still reply 200, so Paystack stops retrying, but we don't deliver twice. Baba Gate checked his logbook. πŸ₯°

3. A forged signature

BODY='{"event":"charge.success","data":{"reference":"order_002","amount":250000,"currency":"NGN","status":"success"}}'

curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:4000/webhooks/paystack \
  -H "Content-Type: application/json" \
  -H "x-paystack-signature: iamdefinitelypaystack" \
  -d "$BODY"
Enter fullscreen mode Exit fullscreen mode
401
Enter fullscreen mode Exit fullscreen mode

Leave out the header completely and you also get 401.

4. A real signature, but someone changed the amount

SIG=$(printf '%s' "$BODY" | openssl dgst -sha512 -hmac "$PAYSTACK_SECRET_KEY" | awk '{print $NF}')
TAMPERED=$(echo "$BODY" | sed 's/250000/2500/')

curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:4000/webhooks/paystack \
  -H "Content-Type: application/json" \
  -H "x-paystack-signature: $SIG" \
  -d "$TAMPERED"
Enter fullscreen mode Exit fullscreen mode
401
Enter fullscreen mode Exit fullscreen mode

The stamp was real, but the letter changed after stamping. Gate stays shut. πŸ’ͺ

5. Valid signature, but Paystack's records disagree

This one uses a body with extra spaces too, just to prove raw-body hashing doesn't care about formatting:

PRETTY='{ "event": "charge.success", "data": { "reference": "order_002", "amount": 250000, "currency": "NGN", "status": "success" } }'
SIG=$(printf '%s' "$PRETTY" | openssl dgst -sha512 -hmac "$PAYSTACK_SECRET_KEY" | awk '{print $NF}')

curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:4000/webhooks/paystack \
  -H "Content-Type: application/json" \
  -H "x-paystack-signature: $SIG" \
  -d "$PRETTY"

curl -s localhost:4000/orders/order_002
Enter fullscreen mode Exit fullscreen mode
200
{"reference":"order_002","amount":250000,"currency":"NGN","status":"pending"}
Enter fullscreen mode Exit fullscreen mode

The signature passed (even with all those spaces), but our fake Paystack ledger says only 100 kobo was paid, not 250000. So the order stays pending and the server logs Verification failed for order_002. Baba Gate called the office, and the office said "no o". πŸ˜‚

no no NO

Before vs After

Before 😬 After 😎
Who can mark an order paid Anyone with curl Only letters stamped with your secret
Body used for the signature Re-built with JSON.stringify The raw bytes Paystack sent
Comparing signatures === timingSafeEqual with a length check
Retries / duplicates Customer gets value twice Logbook skips it
Amount check Trust the webhook Ask Paystack, compare with your own order
Slow work Inside the request, risking timeouts Reply 200 first, then process

Why we do it this way (and what changes in production)

This is pretty much the same shape I use for real payment flows. At Prooval, where I built the backend from scratch, we take payments through Stripe, Paystack and Fincra, and every one of them comes down to the same Baba Gate questions: is this really from the provider, have I seen it before, and do the numbers match?

A few things you should upgrade before real money flows through this:

  • Use your database for the logbook. An in-memory Set is wiped on every restart and isn't shared between servers. Save processed events in a table with a unique index on the key, so if two servers race, the database lets only one insert win.
  • Move the slow work to a queue. Right now we res.sendStatus(200) and then process in the same Node process. If the server dies in between, that event is gone, because Paystack already got its 200. A job queue (I wrote about BullMQ on LinkedIn recently) or at least a "received events" table you can replay fixes this.
  • Lock down the route. Paystack also publishes the IP addresses it sends webhooks from, so you can whitelist them as an extra layer. Check their webhook docs for the current list.
  • Switch the mock off. Set PAYSTACK_MOCK=false and use your real test secret key. The verifyTransaction function will call the real endpoint.

Celebrate

Recap (say it with me) πŸ—£οΈ

  1. A webhook URL is public. Anyone can POST to it.
  2. Paystack signs every webhook with HMAC SHA512 of the body, using your secret key, in x-paystack-signature.
  3. Hash the raw body (express.raw({ type: "application/json" })), not JSON.stringify(req.body).
  4. Mount the webhook route before express.json().
  5. Compare with timingSafeEqual, and check lengths first.
  6. Reply 200 fast, then do the work.
  7. The same event can come more than once. Keep a logbook.
  8. Before giving value, verify the transaction and compare amount and currency with your order.

Baba Gate would be proud. 🫑

I hope this was helpful. Don't forget to drop a like and follow πŸ˜‹πŸ˜‹

If you've been burned by a webhook bug before, tell me about it in the comments. I promise not to laugh. Too much. πŸ˜…

Thank you for taking the time to read πŸ₯°πŸ₯°

See you on the next one πŸ€—

Top comments (0)