Bonaventure OgetoBy Bonaventure Ogeto|

Idempotent Paystack Webhook Handlers: The Complete Pattern

Paystack can send the same webhook event multiple times due to retries, network issues, or internal recovery processes. An idempotent handler produces the same result whether it processes an event once or ten times. The standard pattern is to use the transaction reference (or event-specific ID) as a deduplication key, check a processed_events table before processing, and wrap the deduplication insert and business logic in a single database transaction so they succeed or fail together.

Why Duplicates Happen

Paystack sends webhooks over HTTP. HTTP is not a guaranteed-delivery protocol. Things go wrong. When they do, Paystack retries. Here are the specific scenarios that produce duplicates:

Your server was slow to respond. Paystack has a timeout window. If your webhook handler takes too long to return a 200, Paystack assumes the delivery failed and retries. But your handler may have already started processing the event. Now you have two deliveries for the same event, and the first one is still running when the second arrives. See why you must return 200 immediately.

Network issues between Paystack and your server. Paystack sends the webhook, your server receives it and returns 200, but the response is lost in transit. Paystack never sees the 200, so it retries.

Paystack internal recovery. During maintenance or recovery from an outage, Paystack may re-deliver events that were in flight during the disruption. This is by design, because under-delivering is worse than over-delivering for payment events.

You replayed the event manually. During debugging, someone might trigger a replay from the Paystack dashboard or your own dead letter queue. If your handler is not idempotent, this replay creates a problem instead of fixing one. See replaying failed webhooks safely.

The common thread: duplicates are not a bug. They are a feature of any reliable webhook system. Your handler must be ready for them.

What Idempotency Actually Means

An operation is idempotent if performing it multiple times produces the same result as performing it once. For a webhook handler, this means:

  • If you receive charge.success for reference order-ref-001 three times, the customer gets credited once, not three times.
  • If you receive transfer.success for the same transfer twice, the payout is recorded once.
  • If you receive subscription.create for the same subscription twice, the customer gets one active subscription, not two.

Idempotency is not the same as "ignoring the event." If the first delivery fails partway through (the database write succeeded but the email failed), a retry should not skip the event entirely. It should re-run the parts that did not succeed while not repeating the parts that did.

This is the subtle part. Simple deduplication (check if you have seen this reference, skip if yes) handles most cases. But if your processing has multiple steps and can fail partway through, you need a more thoughtful approach.

Choosing the Right Idempotency Key

The idempotency key is the unique identifier you use to detect duplicates. Different Paystack events use different keys:

Event Idempotency Key Where in the Payload
charge.success Transaction reference data.reference
transfer.success / transfer.failed / transfer.reversed Transfer reference data.reference
subscription.create / subscription.disable Subscription code + event type data.subscription_code + event.event
invoice.create / invoice.update / invoice.payment_failed Invoice code + event type data.invoice_code + event.event
refund.processed / refund.failed Refund ID data.id
dispute.create / dispute.remind Dispute ID + event type data.id + event.event
dedicatedaccount.assign.success Customer code + bank slug data.customer.customer_code + data.dedicated_account.bank.slug
customeridentification.success / failed Customer code + event type data.customer_code + event.event

For charge.success, the transaction reference is the best key because you control it. You set the reference when you initialize the transaction. It is unique per payment attempt by design. If Paystack sends you the same reference twice, it is the same payment, and you should not credit twice.

For subscription and invoice events, you need to combine the subscription or invoice identifier with the event type. A single subscription can generate both subscription.create and subscription.disable events, and you need to process both. The combination ensures you deduplicate within an event type but still process different events for the same subscription.

The processed_events Table

The core of the pattern is a database table that records every event you have successfully processed:

CREATE TABLE processed_events (
  id SERIAL PRIMARY KEY,
  idempotency_key VARCHAR(255) UNIQUE NOT NULL,
  event_type VARCHAR(100) NOT NULL,
  payload JSONB,
  processed_at TIMESTAMPTZ DEFAULT NOW()
);

-- The UNIQUE constraint is the most important part.
-- It prevents duplicate inserts even if two webhook deliveries
-- arrive at the exact same moment.
CREATE INDEX idx_processed_events_key ON processed_events(idempotency_key);

The UNIQUE constraint on idempotency_key is the real safeguard. Even if your application-level check (SELECT before INSERT) has a race condition (two webhook deliveries arrive simultaneously, both check, both find nothing, both try to insert), the database will reject the second insert with a unique constraint violation. One of the two deliveries will succeed, the other will fail, and no duplicate processing occurs.

The payload column is optional but useful. Storing the raw payload lets you debug issues later, replay events for testing, and satisfy audit requirements. If storage is a concern, you can store just the key fields (reference, amount, customer) instead of the full payload.

For high-throughput systems, add a TTL (time-to-live) policy. You probably do not need to keep deduplication records forever. A 30-day retention covers Paystack's retry window with plenty of margin:

-- Clean up old records monthly
DELETE FROM processed_events WHERE processed_at < NOW() - INTERVAL '30 days';

The Complete Idempotent Handler

Here is the full production-ready pattern for a charge.success handler:

const crypto = require('crypto');
const express = require('express');
const { Pool } = require('pg');

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

const app = express();

app.post(
  '/webhooks/paystack',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    // 1. Verify signature
    const secret = process.env.PAYSTACK_SECRET_KEY;
    const signature = req.headers['x-paystack-signature'];
    const hash = crypto
      .createHmac('sha512', secret)
      .update(req.body)
      .digest('hex');

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

    // 2. Return 200 immediately
    res.status(200).send('OK');

    // 3. Parse and process
    const event = JSON.parse(req.body.toString());

    try {
      await processEvent(event);
    } catch (err) {
      console.error(
        'Error processing ' + event.event + ': ' + err.message
      );
    }
  }
);

async function processEvent(event) {
  switch (event.event) {
    case 'charge.success':
      await idempotentProcess(
        event.data.reference,
        event.event,
        event.data,
        handleChargeSuccess
      );
      break;
    case 'transfer.success':
    case 'transfer.failed':
    case 'transfer.reversed':
      await idempotentProcess(
        event.data.reference + ':' + event.event,
        event.event,
        event.data,
        handleTransferEvent
      );
      break;
    case 'refund.processed':
    case 'refund.failed':
      await idempotentProcess(
        'refund:' + event.data.id,
        event.event,
        event.data,
        handleRefundEvent
      );
      break;
    default:
      console.log('Unhandled event: ' + event.event);
  }
}

async function idempotentProcess(key, eventType, data, handler) {
  const client = await pool.connect();

  try {
    await client.query('BEGIN');

    // Try to insert the deduplication record.
    // If it already exists, the UNIQUE constraint will cause an error.
    try {
      await client.query(
        'INSERT INTO processed_events (idempotency_key, event_type, payload) ' +
        'VALUES ($1, $2, $3)',
        [key, eventType, JSON.stringify(data)]
      );
    } catch (err) {
      if (err.code === '23505') {
        // Unique constraint violation. Already processed.
        await client.query('ROLLBACK');
        console.log('Duplicate event, skipping: ' + key);
        return;
      }
      throw err; // Some other database error
    }

    // Run the business logic within the same transaction
    await handler(data, client);

    await client.query('COMMIT');
    console.log('Processed event: ' + key);
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}

async function handleChargeSuccess(data, client) {
  const { reference, amount, currency } = data;
  const customerEmail = data.customer.email;

  // Update the order status
  await client.query(
    'UPDATE orders SET status = $1, paid_at = NOW(), ' +
    'amount_paid = $2 WHERE payment_reference = $3',
    ['paid', amount, reference]
  );

  // The email goes in a queue, outside the transaction.
  // We do not want email failure to roll back the payment.
}

async function handleTransferEvent(data, client) {
  const { reference, status } = data;

  await client.query(
    'UPDATE payouts SET status = $1, updated_at = NOW() ' +
    'WHERE reference = $2',
    [status, reference]
  );
}

async function handleRefundEvent(data, client) {
  const refundId = data.id;
  const transactionRef = data.transaction.reference;

  await client.query(
    'INSERT INTO processed_refunds (refund_id, transaction_ref, ' +
    'amount, status, processed_at) VALUES ($1, $2, $3, $4, NOW())',
    [refundId, transactionRef, data.deducted_amount || 0, data.status]
  );
}

app.listen(3000);

The key design decisions in this implementation:

  1. The deduplication insert and business logic share a transaction. If the order update fails, the deduplication record also rolls back. A retry will correctly try again. If both succeed, a duplicate delivery hits the early return.
  2. The unique constraint violation (error code 23505) is the duplicate detector. We catch it specifically and treat it as a "skip" signal, not an error.
  3. Side effects like emails happen outside the transaction. If the database work succeeds but the email fails, that is acceptable. The reverse (email sent, database rolled back) would be worse.
  4. Each event type builds its own idempotency key. Transfer events append the event type to the reference because the same transfer can fire transfer.success and transfer.reversed, and both need processing.

Handling Race Conditions

The application-level check-then-insert pattern (SELECT to see if the key exists, then INSERT if not) has a race condition. If two webhook deliveries arrive at the same instant:

  1. Delivery A: SELECT finds no row. Proceeds to INSERT and process.
  2. Delivery B: SELECT also finds no row (A has not committed yet). Also proceeds to INSERT and process.
  3. Both try to INSERT. One succeeds, one hits the unique constraint.

The unique constraint prevents duplicate inserts, but both deliveries have already started their business logic. If the business logic runs before the INSERT (which is the naive approach), you still get double-processing.

That is why the pattern above inserts the deduplication record first, inside the transaction, before running any business logic. If the insert fails due to a duplicate, the handler returns immediately without touching business data.

An alternative approach for databases that support it is to use a row-level lock:

async function idempotentProcessWithLock(key, eventType, data, handler) {
  const client = await pool.connect();

  try {
    await client.query('BEGIN');

    // Use an advisory lock based on the key hash
    const lockId = hashToInt(key);
    await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]);

    // Now check if already processed (safe because we hold the lock)
    const existing = await client.query(
      'SELECT id FROM processed_events WHERE idempotency_key = $1',
      [key]
    );

    if (existing.rows.length > 0) {
      await client.query('ROLLBACK');
      return; // Already processed
    }

    // Insert deduplication record
    await client.query(
      'INSERT INTO processed_events (idempotency_key, event_type, payload) ' +
      'VALUES ($1, $2, $3)',
      [key, eventType, JSON.stringify(data)]
    );

    // Run business logic
    await handler(data, client);

    await client.query('COMMIT');
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}

function hashToInt(str) {
  let hash = 0;
  for (let i = 0; i < str.length; i++) {
    const char = str.charCodeAt(i);
    hash = ((hash << 5) - hash) + char;
    hash = hash & hash; // Convert to 32-bit integer
  }
  return Math.abs(hash);
}

The advisory lock approach serializes processing of the same idempotency key. Only one delivery can hold the lock at a time. The second delivery waits until the first commits or rolls back, then checks the deduplication table and skips if the first delivery succeeded.

For most Paystack integrations, the simpler unique-constraint approach is sufficient. Advisory locks are useful when you process very high volumes and want to avoid the overhead of catching constraint violation exceptions.

What About Non-Database Side Effects

The pattern above works perfectly for database operations because they participate in the transaction. But what about side effects that cannot be rolled back?

  • Sending an email or SMS
  • Calling a third-party API (shipping, notifications)
  • Publishing a message to a queue
  • Writing to an external log or analytics service

These cannot be undone if the database transaction rolls back. The approach is to defer them until after the transaction commits:

async function idempotentProcess(key, eventType, data, handler) {
  const client = await pool.connect();
  const sideEffects = []; // Collect side effects during processing

  try {
    await client.query('BEGIN');

    try {
      await client.query(
        'INSERT INTO processed_events (idempotency_key, event_type, payload) ' +
        'VALUES ($1, $2, $3)',
        [key, eventType, JSON.stringify(data)]
      );
    } catch (err) {
      if (err.code === '23505') {
        await client.query('ROLLBACK');
        return;
      }
      throw err;
    }

    // Handler returns side effects to run after commit
    const effects = await handler(data, client);
    if (effects) sideEffects.push(...effects);

    await client.query('COMMIT');
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }

  // Run side effects AFTER successful commit
  for (const effect of sideEffects) {
    try {
      await effect();
    } catch (err) {
      console.error('Side effect failed: ' + err.message);
      // Log but do not throw. The main work is done.
    }
  }
}

async function handleChargeSuccess(data, client) {
  const { reference, amount } = data;

  await client.query(
    'UPDATE orders SET status = $1, paid_at = NOW() ' +
    'WHERE payment_reference = $2',
    ['paid', reference]
  );

  // Return side effects as functions
  return [
    () => emailQueue.add('payment-receipt', {
      to: data.customer.email,
      amount: amount,
      reference: reference,
    }),
    () => analyticsQueue.add('payment-completed', {
      reference: reference,
      amount: amount,
    }),
  ];
}

This approach ensures that side effects only run after the database transaction commits. If the transaction rolls back, the side effects are never executed. If a side effect fails (email service is down), the database state is already committed and consistent. The side effect can be retried independently.

Testing Idempotency

Write explicit tests for duplicate handling. Send the same event payload twice and verify that the business effect only happens once:

// Test: duplicate charge.success should not double-credit
describe('idempotent charge.success handling', () => {
  it('processes the event only once on duplicate delivery', async () => {
    const payload = {
      event: 'charge.success',
      data: {
        reference: 'test-ref-001',
        amount: 50000,
        currency: 'NGN',
        customer: { email: 'test@example.com' },
      },
    };

    // First delivery: should process
    await processEvent(payload);

    const order1 = await db.query(
      'SELECT status, amount_paid FROM orders ' +
      'WHERE payment_reference = $1',
      ['test-ref-001']
    );
    expect(order1.rows[0].status).toBe('paid');
    expect(order1.rows[0].amount_paid).toBe(50000);

    // Second delivery: should skip
    await processEvent(payload);

    // Verify the order was not modified again
    const order2 = await db.query(
      'SELECT status, amount_paid FROM orders ' +
      'WHERE payment_reference = $1',
      ['test-ref-001']
    );
    expect(order2.rows[0].status).toBe('paid');
    expect(order2.rows[0].amount_paid).toBe(50000);

    // Verify only one processed_events record exists
    const events = await db.query(
      'SELECT COUNT(*) as count FROM processed_events ' +
      'WHERE idempotency_key = $1',
      ['test-ref-001']
    );
    expect(parseInt(events.rows[0].count, 10)).toBe(1);
  });
});

Also test the rollback scenario: if the business logic throws an error, the deduplication record should also roll back, allowing a retry to succeed on the next attempt.

For the full testing setup, see simulating Paystack webhook events for automated tests.

Key Takeaways

  • Paystack can deliver the same webhook event multiple times. Retries, network timeouts, and internal recovery all cause duplicates.
  • Without idempotency, a duplicate charge.success event will credit a customer twice for a single payment.
  • Use the transaction reference as the primary idempotency key for charge and transfer events. Use the refund ID, dispute ID, or subscription code for other event types.
  • The processed_events table pattern: before processing, check if the key exists. If it does, skip. If it does not, insert the key and do the work in the same database transaction.
  • A UNIQUE constraint on the idempotency key column provides database-level protection against race conditions where two webhook deliveries arrive simultaneously.
  • Always wrap the deduplication insert and business logic in a single transaction. If the business logic fails, the deduplication record rolls back too, allowing a retry to succeed.

Frequently Asked Questions

What is the best idempotency key for Paystack charge.success events?
The transaction reference (data.reference) is the best key. You control this value when you initialize the transaction, it is unique per payment, and it is always present in the webhook payload. If you receive the same reference twice, it is the same payment.
How long should I keep records in the processed_events table?
Keep them for at least 30 days, which covers the Paystack retry window with margin. For audit and compliance purposes, you may want to keep them longer (90 days or a year). Set up a scheduled job to clean up old records if storage is a concern.
Can I use Redis instead of a database table for deduplication?
You can use Redis SET with NX (set if not exists) and an expiry for deduplication, and it is faster than a database check. However, Redis does not participate in database transactions. If your business logic fails after the Redis key is set, the event will not be retried. For critical payment events, a database-backed approach within the same transaction is safer.
What happens if my handler fails after inserting the deduplication record?
If the deduplication insert and the business logic are in the same database transaction (as shown in this guide), both roll back when the handler fails. The deduplication record is removed, and the next retry will process the event normally. This is why the single-transaction pattern is essential.
Should I deduplicate webhook events that do not grant value, like subscription.not_renew?
Yes. Even events that do not grant monetary value can cause issues if processed twice. For example, processing subscription.not_renew twice might send two cancellation emails or schedule two downgrade jobs. Deduplication is cheap and prevents subtle bugs across all event types.

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