All posts
20 April 20268 min read

How KoboSync Handles Paystack Webhooks Without Losing a Single Transaction

A technical deep-dive into how KoboSync ingests Paystack webhook events — from HMAC signature verification to the pending writes system that guarantees your ledger stays complete even when Google Sheets is temporarily unreachable.

How KoboSync Handles Paystack Webhooks Without Losing a Single Transaction

Every time a customer completes a payment on your Paystack checkout, Paystack fires a POST request to a URL you configure. Miss that request — or handle it incorrectly — and the sale never reaches your ledger. No spreadsheet row. No VAT entry. No customer record. It simply disappears.

This is the webhook problem. And it is harder than it looks.

In this post I want to walk through exactly how KoboSync ingests Paystack webhook events, why we made the architectural decisions we did, and the specific failure modes we built around. If you run a business on Paystack and care about whether your accounting records are accurate, this is worth reading.

Why Webhooks Are Unreliable By Default

Paystack sends your webhook and expects a 200 OK response within a few seconds. If your server is slow, down, or returns a non-200 status, Paystack retries. The retry schedule is roughly: immediately, then 15 minutes, then 1 hour, then 6 hours, then 24 hours.

This sounds like a safety net. In practice it creates two problems:

Problem 1: If you return 200 immediately and then crash, Paystack thinks you succeeded. It will not retry. The transaction is gone.

Problem 2: If you return non-200 because your Google Sheets write failed, Paystack keeps retrying the same event. Your sheet eventually gets the row — but only because Paystack hammered your endpoint for 24 hours. This is a retry storm, and it causes duplicate entries if you don't guard against it.

KoboSync solves both problems with a two-stage architecture: acknowledge first, process durably second.

Stage 1 — The Webhook Route

When a Paystack event arrives at POST /api/webhook/{slug}, the first thing we do is verify the request is actually from Paystack.

Paystack signs every webhook body using your secret key with HMAC-SHA512. The signature arrives in the x-paystack-signature header. We recompute the hash ourselves and compare:

code
const hash = crypto
  .createHmac("sha512", secret)
  .update(rawBody)     // raw text body — NOT JSON.parse'd
  .digest("hex");
 
if (hash !== signature) {
  return NextResponse.json({ received: false }, { status: 200 });
  // 200 not 401 — we don't want Paystack to retry a fraudulent request
}

One critical detail: we read the body with req.text() before any parsing. JSON.parse followed by JSON.stringify does not necessarily produce byte-identical output to the original payload. If even one whitespace character differs, the HMAC hash will not match and we will reject legitimate events. This is one of the most common webhook verification bugs.

We also validate that the signature is valid hex before running the comparison:

code
if (!/^[0-9a-f]+$/i.test(signature)) {
  // Malformed header — reject without running crypto
}

This distinction — malformed header versus wrong HMAC — is logged separately in our webhook inspector, which helps merchants debug misconfigured bridges.

After signature verification, we immediately return 200 OK to Paystack. The rest of the processing happens asynchronously. This is intentional.

code
// Return 200 before doing any work
res.sendStatus(200);
 
// Enqueue for async processing
await getTransactionQueue().add("process-transaction", {
  bridgeId, provider, payload,
});

Stage 2 — The Transaction Queue

The webhook route enqueues a job to BullMQ (backed by Redis) and returns. Paystack is satisfied. The merchant's customer has their confirmation. Our work is just beginning.

A dedicated worker picks up the job and runs the full processing pipeline:

  1. Parse the provider-specific payload into a normalised UnifiedTransaction shape
  2. Run a duplicate guard against the database
  3. Calculate fees and net profit (₦X gross − gateway fee = ₦Y net)
  4. Compute NGN equivalents for non-NGN transactions using locked exchange rates
  5. Extract VAT if the bridge has VAT mode enabled
  6. Save the transaction to PostgreSQL
  7. Enqueue a sheet-sync job to write the row to Google Sheets
  8. Enqueue an invoice job if auto-invoicing is enabled
  9. Broadcast a real-time update to the merchant's dashboard

Steps 1–6 are synchronous within the worker. Steps 7–9 are further enqueued so a slow Google Sheets API call cannot block the transaction record from being created.

The Duplicate Guard

Paystack occasionally sends the same webhook twice — usually because your server was slow and Paystack's timeout fired before it received your 200. If you don't guard against this, you get duplicate rows in your sheet and inflated revenue figures.

We use a partial unique index in PostgreSQL:

code
CREATE UNIQUE INDEX unq_bridge_reference_success
ON transactions (bridge_id, reference)
WHERE status = 'success';

The WHERE status = 'success' clause means the constraint only applies to successfully processed transactions. A transaction that failed or was marked as ignored can share a reference with a success record — this matters because a webhook retry after a genuine failure should succeed the second time.

The worker checks for the reference before inserting:

code
const existing = await transactionsRepository.findByReference(bridgeId, unified.reference);
if (existing) {
  // Log as "ignored" in the webhook inspector
  return null;
}

The Sheet Sync Problem

Here is where it gets interesting.

Google Sheets is not a database. It has rate limits, quota limits, OAuth tokens that expire, and a hard cap of 10 million cells per spreadsheet. We return 200 to Paystack before the sheet write happens. What happens when the sheet write fails?

In a naïve implementation: the transaction is in the database but missing from the merchant's spreadsheet. They think their ledger is complete. It is not.

We solve this with a pending writes system. When the Google Sheets sync fails for any reason — rate limit, expired token, permission denied — we persist the failed write to a pending_sheet_writes table:

code
// sheets-sync.worker.ts — on any error requiring merchant action
await pendingSheetWritesRepository.create({
  bridgeId,
  transactionId,
  rowData,       // the exact row we tried to write
  sheetId,
  failureReason: err.message,
});
 
await bridgesRepository.updateHealthStatus(bridgeId, "reauth_required");

The rowData is the complete, pre-formatted object we were going to write. When the merchant reconnects their Google account, a drain worker processes the queue and writes all pending rows in the correct order. The merchant gets an email: "3 transactions waiting to sync — your ledger is now up to date."

The INVALID_GRANT Problem

The most common reason sheet writes fail is an expired or revoked Google OAuth refresh token. This happens when:

  • The merchant changes their Google account password
  • The merchant removes KoboSync from their Google account's connected apps
  • The token has been unused for 6 months
  • Google detects suspicious activity and revokes all third-party tokens

When getFreshAccessToken throws INVALID_GRANT, we stop retrying immediately. No amount of retrying will fix a revoked token. We mark the bridge as reauth_required, persist all pending writes to the database (not just Redis, which has a 24-hour TTL), and send the merchant a notification:

"KoboSync has lost access to your Google account. 4 transactions are waiting to sync. Reconnect your Google account to resume automatic accounting."

When the merchant clicks "Reconnect Google" and completes the OAuth flow, the auth callback triggers a drain job. Every pending write is processed, and the merchant gets a confirmation: "4 transactions synced to your sheet."

What This Means For Your Business

If you are using KoboSync, here is what this architecture means in practice:

A sale is never lost. Once your payment gateway sends the webhook and we return 200, that transaction is durably recorded in our database regardless of what happens to Google Sheets, your OAuth token, or our servers.

Your sheet is eventually consistent, not immediately consistent. There is a small window (usually under 3 seconds) between a payment completing and the row appearing in your spreadsheet. This is normal and intentional. The database is the source of truth; the sheet is a view of it.

The webhook inspector shows you everything. Every inbound webhook, whether processed, duplicated, or rejected, appears in your bridge's webhook inspector with a timestamp, the parsed payload, and the agent's action note. If a row is missing from your sheet, the inspector tells you exactly what happened and why.

You should monitor your bridge health. The health indicator on each bridge card tells you whether the agent is actively writing to your sheet. An amber "Re-auth needed" badge means transactions are queued and waiting. Green means your ledger is live.

The Broader Lesson

The webhook pattern — acknowledge immediately, process durably — is one of the most important patterns in building reliable payment integrations. The mistake most integrations make is trying to do too much in the webhook handler itself: write to the database, send the email, update the spreadsheet, all before returning 200.

That approach is brittle. Any one of those operations can fail, and when it does, you either lose data or create duplicates.

The correct mental model is: the webhook handler's only job is to verify authenticity and enqueue work. Everything else is someone else's problem — specifically, the worker's problem.

This is not a new idea. But it is surprising how many production integrations still do it the naïve way.


KoboSync is an automated accounting agent for Nigerian merchants. It connects your Paystack, Flutterwave, or Monnify account to Google Sheets and reconciles every sale automatically. Try it free →