McTaba Labs logo
Software EngineeringThe WhiteboardProjectsEventsMasterclassesWebinarsLearn
Book Call

500+ developers already in the community

Get answers, find accountability partners, and stay ahead

Join Discord →
McTaba Labs logo

Educating 1 million students in tech by 2030.

Programs

  • HighSchool Graduates Launchpad
  • AI & Business Automation
  • Corporate Training
  • McTaba Story & Mission
  • Full-Stack Curriculum
  • Course Pricing & Plans
  • Frequently Asked Questions

Resources

  • Join Discord Community
  • Tech Events in Nairobi
  • Masterclasses
  • Free Resources
  • Learn
  • Campus
  • Africa Tech Talent Report
  • Partner With Us
  • Start a Project
  • Our Work

Contact Us

  • 124 Manyani East Road, Lavington, Nairobi
  • +254727795603
  • Book a Discovery Call

Office Hours:

Mon-Fri: 9am - 2pm EAT

Sat: 10am - 12pm EAT

© 2027 McTaba Labs - A product of Edtech Industries. All rights reserved.

Privacy PolicyTerms of ServiceRefund Policy
  1. Learn
  2. Paystack for African Developers
  3. Replay Attacks on Paystack Webhooks
Bonaventure OgetoBy Bonaventure Ogeto|Updated July 22, 2026

Replay Attacks on Paystack Webhooks

Defend against replay attacks by making your webhook handler idempotent. Store every processed transaction reference in your database. Before granting value, check if the reference has already been processed. If it has, return 200 without taking action. This stops both malicious replays and legitimate Paystack retries from causing double processing.

In this article

  1. 1. What Is a Webhook Replay Attack
  2. 2. Why Signature Verification Alone Is Not Enough
  3. 3. Implementing Idempotent Webhook Processing
  4. 4. Handling Race Conditions
  5. 5. Server-Side Transaction Verification
  6. 6. Distinguishing Paystack Retries from Replay Attacks
  7. 7. Cleanup and Maintenance

What Is a Webhook Replay Attack

A replay attack is different from a fake webhook attack. In a fake webhook attack, the attacker crafts a new payload and sends it without a valid signature. Signature verification stops this completely.

In a replay attack, the attacker intercepts or captures a real webhook payload that Paystack sent previously. This payload has a valid signature because it was genuinely signed by Paystack. The attacker sends the exact same payload to your server again. Your signature verification passes because the payload has not been modified.

The result: your server processes the same payment event twice. If your webhook handler grants access on charge.success, the attacker gets access twice (or gets a refund processed twice, or gets a subscription extended twice).

How an attacker captures the original webhook:

  • They compromise your server logs and find previously received webhook payloads
  • They intercept network traffic (if your webhook URL uses HTTP instead of HTTPS)
  • They are a malicious insider who received a legitimate payment and captured the webhook
  • They find webhook payloads in your application's debug output or error tracking

Why Signature Verification Alone Is Not Enough

Signature verification answers one question: "Did Paystack send this payload?" For a replayed webhook, the answer is yes. Paystack did send it. The signature is valid. The payload is authentic. It is just being replayed.

You need a second question: "Have I already processed this event?" This is the idempotency check, and it is the defense against replays.

Signature verification and idempotency are complementary defenses:

Attack TypeSignature VerificationIdempotency Check
Fake webhook (forged payload)Blocks itWould also block (unknown reference)
Replay attack (real payload, resent)PassesBlocks it
Paystack retry (real payload, retried)PassesPrevents double processing

You need both. Signature verification without idempotency is vulnerable to replays. Idempotency without signature verification is vulnerable to forged payloads. Together, they cover all attack vectors.

Get weekly developer tips

Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.

No spam. Unsubscribe anytime.

Implementing Idempotent Webhook Processing

The core pattern: use the transaction reference as a unique key. Before processing, check if you have already processed a webhook for that reference. If you have, return 200 immediately without taking action.

var crypto = require('crypto');

// Assuming a database with a 'processed_webhooks' table
// Columns: reference (unique), event_type, processed_at

async function handleWebhook(req, res) {
  // Step 1: Verify signature (see fake webhook attacks guide)
  var secret = process.env.PAYSTACK_SECRET_KEY;
  var signature = req.headers['x-paystack-signature'];
  var hash = crypto
    .createHmac('sha512', secret)
    .update(req.body)
    .digest('hex');

  if (hash !== signature) {
    return res.status(400).json({ error: 'Invalid signature' });
  }

  var event = JSON.parse(req.body.toString());
  var reference = event.data.reference;

  // Step 2: Idempotency check
  var existing = await db.query(
    'SELECT reference FROM processed_webhooks WHERE reference = $1',
    [reference]
  );

  if (existing.rows.length > 0) {
    console.log('Webhook already processed for: ' + reference);
    return res.status(200).json({ received: true, duplicate: true });
  }

  // Step 3: Process the event
  if (event.event === 'charge.success') {
    await grantAccess(event.data);
  }

  // Step 4: Record the processed reference
  await db.query(
    'INSERT INTO processed_webhooks (reference, event_type, processed_at) VALUES ($1, $2, NOW())',
    [reference, event.event]
  );

  return res.status(200).json({ received: true });
}

Return 200 for duplicates. If you return an error for a duplicate webhook, Paystack may keep retrying a legitimately retried webhook, wasting resources on both sides.

Handling Race Conditions

What if two identical webhooks arrive at the exact same time? Both threads check the database, both find no existing record, and both proceed to process the payment. You have double processing despite the idempotency check.

This is a race condition, and it is a real concern with Paystack retries and replay attacks. Fix it with a database-level uniqueness constraint:

CREATE TABLE processed_webhooks (
  id SERIAL PRIMARY KEY,
  reference VARCHAR(255) UNIQUE NOT NULL,
  event_type VARCHAR(100) NOT NULL,
  processed_at TIMESTAMP DEFAULT NOW()
);

With a UNIQUE constraint on the reference column, the second INSERT will fail with a duplicate key error even if both threads passed the SELECT check. Wrap the insert in a try-catch:

async function processWebhookSafely(event) {
  var reference = event.data.reference;

  try {
    // Try to insert first (atomic operation)
    await db.query(
      'INSERT INTO processed_webhooks (reference, event_type) VALUES ($1, $2)',
      [reference, event.event]
    );
  } catch (err) {
    if (err.code === '23505') {
      // Unique violation: already processed
      console.log('Duplicate webhook caught by constraint: ' + reference);
      return { duplicate: true };
    }
    throw err;
  }

  // Only reaches here if insert succeeded (first processing)
  if (event.event === 'charge.success') {
    await grantAccess(event.data);
  }

  return { duplicate: false };
}

The INSERT-first approach is more reliable than SELECT-then-INSERT because the database enforces atomicity at the constraint level. No race condition can bypass it.

Server-Side Transaction Verification

For high-value transactions, add a third defense layer: verify the transaction through the Paystack API before granting value.

var https = require('https');

function verifyWithPaystack(reference, expectedAmount, callback) {
  var options = {
    hostname: 'api.paystack.co',
    path: '/transaction/verify/' + reference,
    method: 'GET',
    headers: {
      Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
    },
  };

  var req = https.request(options, function(res) {
    var data = '';
    res.on('data', function(chunk) { data += chunk; });
    res.on('end', function() {
      var result = JSON.parse(data);

      if (!result.status) {
        return callback(new Error('Verification failed'));
      }

      if (result.data.status !== 'success') {
        return callback(new Error('Transaction not successful'));
      }

      if (result.data.amount !== expectedAmount) {
        return callback(new Error('Amount mismatch'));
      }

      callback(null, result.data);
    });
  });

  req.on('error', callback);
  req.end();
}

This API call confirms the transaction is real, successful, and for the expected amount. Even if an attacker somehow replays a webhook past your idempotency check, the Paystack API returns the canonical state of the transaction.

Distinguishing Paystack Retries from Replay Attacks

Paystack retries webhooks when your server does not respond with a 2xx status code. This is normal and expected behavior. Your idempotency logic should handle retries gracefully without treating them as attacks.

Differences between retries and attacks:

PropertyPaystack RetryReplay Attack
Source IPPaystack serversAttacker's IP
TimingMinutes to hours after originalAny time
SignatureValidValid (same payload)
PayloadIdentical to originalIdentical to original

Your code should handle both the same way: check if the reference has been processed, and if so, return 200 without re-processing. The distinction matters for logging and alerting, not for code logic.

If you want to detect potential replay attacks (as opposed to Paystack retries), log the source IP of webhook requests and alert if you see webhooks for already-processed references coming from non-Paystack IP addresses.

Cleanup and Maintenance

The processed_webhooks table grows over time. You can safely delete entries older than Paystack's retry window (typically 72 hours), but many teams keep them longer for audit purposes.

-- Delete entries older than 90 days
DELETE FROM processed_webhooks
WHERE processed_at < NOW() - INTERVAL '90 days';

If you keep entries for audit purposes, add an index on the reference column for fast lookups:

CREATE INDEX idx_processed_webhooks_reference
ON processed_webhooks (reference);

Monitor the table size and query performance. For high-volume integrations processing thousands of transactions per day, consider partitioning by date or archiving old records to a separate table.

Key Takeaways

  • ✓A replay attack sends a previously captured legitimate webhook to your server. The signature is valid because the payload is unchanged.
  • ✓Without idempotency checks, a replayed webhook causes double processing: double credits, double access grants, or double product shipments.
  • ✓Store every processed transaction reference in your database. Before processing, check if the reference exists. If it does, skip processing and return 200.
  • ✓Paystack itself retries webhooks when your server does not respond with 200. Your idempotency logic handles both malicious replays and legitimate retries.
  • ✓Signature verification alone does not stop replay attacks. The replayed payload has a valid signature.
  • ✓Server-side transaction verification via the Paystack API provides an additional check: confirm the transaction status and amount before granting value.

Frequently Asked Questions

Can I use an in-memory store (like a Set) instead of a database for idempotency?
Only if you have a single server process that never restarts. An in-memory store loses all data on restart, allowing replays of previously processed webhooks. A database is the reliable choice.
What if I use Redis for idempotency tracking?
Redis works well for this purpose. Use SETNX (set if not exists) for atomic deduplication. Set a TTL of 72 hours or more to cover the Paystack retry window. Be aware that Redis data is lost on restart unless you have persistence configured.
Should I block requests from non-Paystack IP addresses?
IP allowlisting adds a layer of defense but should not be your only protection. Paystack IP addresses can change, and you need to maintain the allowlist. Signature verification plus idempotency is the primary defense. IP filtering is optional hardening.
How many times does Paystack retry a failed webhook?
Paystack retries with exponential backoff over a 72-hour period. The exact number of retries and intervals may vary. Design your webhook handler to be idempotent regardless of how many times a webhook is delivered.
What if I need to reprocess a webhook intentionally?
If you need to reprocess a legitimate webhook (for example, after fixing a bug in your handler), delete the corresponding entry from your processed_webhooks table and trigger a retry from the Paystack dashboard, or call the transaction verify endpoint directly.

Learn Paystack Integration

KES 2,999

The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.

Start Paystack Course

Not ready? Create Free Account

Keep reading

Paystack Security, Keys and PCI Compliance: Complete Guide

A complete security reference for Paystack integrations. Covers API key types and rotation, secure key storage, PCI DSS compliance scope, price tampering prevention, webhook replay attacks, rate limiting, fraud signals, and logging without exposing secrets.

Read more

Never Commit Your Paystack Key: Detection and Remediation

Committing a Paystack secret key to version control is one of the most common and most dangerous mistakes in payment integrations. This guide shows you how to detect committed keys, set up automated prevention with pre-commit hooks, and remediate the damage when a key does slip through.

Read more

Price Tampering Attacks on Paystack Checkouts

Price tampering is the most common attack on Paystack integrations. The attacker modifies the amount in the frontend checkout initialization, paying a fraction of the actual price. This guide shows how the attack works and how server-side amount validation stops it completely.

Read more

Logging Payments Without Logging Secrets

Good payment logging helps you debug failed charges and reconcile transactions. Bad payment logging leaks your Paystack secret key, card tokens, or customer data into log files that attackers can read. This guide draws a clear line between safe and dangerous payment log data.

Read more

Paystack Sandbox Card Numbers Not Working

Paystack sandbox card numbers fail for three reasons: you are using Stripe test cards instead of Paystack ones, you forgot the PIN and OTP steps, or your integration is hitting the live API with test keys. Here is how to fix each one.

Read more

Explore related topics

M-Pesa & African Stack

M-Pesa Daraja API tutorials, USSD development, and WhatsApp Business API guides. The African Stack that global bootcamps do not teach.

Learn to Code in Kenya

Everything a Kenyan needs to know before writing their first line of code. Career paths, real costs, hardware requirements, and honest answers about what it takes.

Learning to Code

Roadmaps, language guides, and practical advice for learning to code and launching a programming career.

Breaking Into Tech

Practical, no-nonsense guides for anyone looking to start or change their career in tech. From choosing your first language to acing interviews.

The African Stack: Skills for This Market

The skills that make Kenyan developers hard to replace: M-Pesa, USSD, WhatsApp, fintech, and building for the African market.

Bonaventure Ogeto

Bonaventure Ogeto

Founder, McTaba Labs

Bonaventure is the founder of McTaba Labs, a coding bootcamp in Nairobi, Kenya. He has spent years building products for the African market and mentoring the next generation of developers across the continent.