Bonaventure OgetoBy Bonaventure Ogeto|

Webhook Ordering: What Paystack Does Not Guarantee

Paystack does not guarantee that webhook events arrive in the order they occurred. Network delays, retries, internal queue processing, and infrastructure differences all contribute to out-of-order delivery. Your webhook handlers must treat each event as independent and never assume a prior event has already been processed.

What Ordering Means and Why It Breaks

When a customer pays and then requests a refund, the events happen in this logical order:

  1. charge.success (customer pays)
  2. refund.processed (refund completes)

You would naturally expect your webhook handler to receive them in the same order. Often it does. But Paystack does not promise this, and several real-world factors cause events to arrive out of sequence.

Network latency varies. Paystack sends each webhook as an independent HTTP request. The charge.success request might hit a slow route through the internet while the refund.processed request takes a faster path. The second event arrives first.

Retries shuffle the order. Your server was briefly unreachable when charge.success was sent. Paystack retries it 60 seconds later. Meanwhile, the refund.processed event was sent and delivered successfully on the first attempt. Now your handler sees the refund before the charge. See webhook retries for how the retry schedule works.

Internal processing times differ. Paystack processes different event types through different internal systems. A charge confirmation might clear faster than a transfer notification. Two events that happened at the same wall-clock time might be dispatched seconds or minutes apart.

Concurrent processing on your end. If your webhook handler runs on multiple server instances (load balanced), two events for the same customer might be processed simultaneously by different servers. The order in which they finish depends on server load, database contention, and other factors outside your control.

This article is part of the Paystack webhooks engineering guide.

Real-World Out-of-Order Scenarios

Here are specific scenarios where out-of-order events cause real bugs if your handlers assume ordering:

Scenario 1: Refund before charge

Your refund.processed handler looks up the order by reference, finds it in "pending" status (because charge.success has not arrived yet), and either crashes or ignores the refund. When charge.success finally arrives, the order is marked as "paid" and the refund is never recorded. The customer was refunded but your system shows them as paid.

Scenario 2: Subscription disable before create

A customer creates a subscription and cancels it almost immediately. subscription.disable arrives first. Your handler looks for the subscription in the database, finds nothing, and discards the event. Then subscription.create arrives and creates an active subscription. The cancellation was lost.

Scenario 3: Transfer reversed before transfer success

A bank account transfer succeeds and then gets reversed. transfer.reversed arrives first. Your payout record does not exist yet (or shows "processing"), so the reversal is ignored. Then transfer.success arrives and marks the payout as "success." The vendor was not actually paid, but your system thinks they were.

Scenario 4: Invoice update before invoice create

For subscription renewals, Paystack sends invoice.create followed by invoice.update (after the charge). If invoice.update arrives first, you have no invoice record to update.

Each of these scenarios results in data that does not match reality. The customer's experience and your internal records diverge, and these bugs are hard to catch because they happen intermittently.

The Check-State-First Pattern

The simplest defense against out-of-order events: before acting on an event, check the current state of the entity it references. If the state does not match what you expect, handle it gracefully.

async function handleRefundProcessed(data, client) {
  const transactionRef = data.transaction.reference;

  // Check the current order state
  const result = await client.query(
    'SELECT status FROM orders WHERE payment_reference = $1',
    [transactionRef]
  );

  if (result.rows.length === 0) {
    // Order does not exist yet. charge.success has not been processed.
    // Option A: Queue this event for reprocessing after a delay
    console.log(
      'Order not found for refund. Reference: ' + transactionRef +
      '. Queueing for retry.'
    );
    await retryQueue.add('delayed-refund', {
      event: 'refund.processed',
      data: data,
      attemptNumber: 1,
    }, { delay: 5000 }); // Retry in 5 seconds
    return;
  }

  const currentStatus = result.rows[0].status;

  if (currentStatus === 'refunded') {
    // Already processed this refund. Skip (idempotency).
    return;
  }

  if (currentStatus === 'pending') {
    // charge.success has not been processed yet.
    // The order exists but is not paid.
    // Queue for retry, same as the "not found" case.
    console.log(
      'Order is still pending. Refund arrived before charge. ' +
      'Reference: ' + transactionRef
    );
    await retryQueue.add('delayed-refund', {
      event: 'refund.processed',
      data: data,
      attemptNumber: 1,
    }, { delay: 5000 });
    return;
  }

  if (currentStatus === 'paid') {
    // This is the expected state. Process the refund.
    await client.query(
      'UPDATE orders SET status = $1, refunded_at = NOW() WHERE payment_reference = $2',
      ['refunded', transactionRef]
    );
  }
}

This handler does not assume the order is in any particular state. It checks, and adapts. If the order is not ready for a refund, the event goes back into a queue for a delayed retry. After a few seconds, the charge.success event has probably been processed, and the retry succeeds.

The retry has a limit. After 3 or 5 attempts, send the event to a dead letter queue and alert your team.

The State Machine Approach

For entities with well-defined lifecycle states (orders, subscriptions, payouts), a state machine makes out-of-order handling explicit. Define valid transitions and reject anything else.

// Valid state transitions for orders
const ORDER_TRANSITIONS = {
  pending: ['paid', 'failed'],
  paid: ['refunded', 'disputed'],
  failed: ['paid'],  // A retry might succeed after a failure
  refunded: [],      // Terminal state
  disputed: ['resolved', 'refunded'],
};

// Valid state transitions for payouts
const PAYOUT_TRANSITIONS = {
  pending: ['processing'],
  processing: ['success', 'failed'],
  success: ['reversed'],
  failed: [],
  reversed: [],
};

function isValidTransition(transitions, currentState, newState) {
  const allowed = transitions[currentState];
  if (!allowed) return false;
  return allowed.includes(newState);
}

async function handleOrderStateChange(reference, newStatus, client) {
  const result = await client.query(
    'SELECT status FROM orders WHERE payment_reference = $1 FOR UPDATE',
    [reference]
  );

  if (result.rows.length === 0) {
    // Order not found. Probably an out-of-order event.
    return { success: false, reason: 'not_found' };
  }

  const currentStatus = result.rows[0].status;

  if (currentStatus === newStatus) {
    // Already in the target state. Idempotent skip.
    return { success: true, reason: 'already_in_state' };
  }

  if (!isValidTransition(ORDER_TRANSITIONS, currentStatus, newStatus)) {
    // Invalid transition. Log it for investigation.
    console.warn(
      'Invalid order transition: ' + currentStatus + ' -> ' + newStatus +
      ' for reference ' + reference
    );
    return { success: false, reason: 'invalid_transition' };
  }

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

  return { success: true, reason: 'transitioned' };
}

The FOR UPDATE lock prevents two concurrent webhook deliveries from reading the same state and both trying to transition. One gets the lock, transitions, and commits. The other waits, reads the new state, and either transitions (if valid) or skips.

When a transition is invalid, log it but do not crash. An invalid transition usually means an event arrived out of order. Depending on your business rules, you can queue the event for retry, send it to a dead letter queue, or simply log and ignore it.

Using the Verify API as a Fallback

When an event references a transaction you have not seen, you can call the Paystack Verify API to get the current state of the transaction. This fills in the gap left by the missing event.

async function verifyWithPaystack(reference) {
  const response = await fetch(
    'https://api.paystack.co/transaction/verify/' + encodeURIComponent(reference),
    {
      headers: {
        'Authorization': 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
      },
    }
  );

  if (!response.ok) {
    throw new Error('Paystack verify failed: ' + response.status);
  }

  const result = await response.json();
  return result.data;
}

async function handleRefundWithFallback(refundData, client) {
  const transactionRef = refundData.transaction.reference;

  const order = await client.query(
    'SELECT status FROM orders WHERE payment_reference = $1',
    [transactionRef]
  );

  if (order.rows.length === 0 || order.rows[0].status === 'pending') {
    // The charge.success event has not been processed yet.
    // Verify the transaction with Paystack directly.
    const txn = await verifyWithPaystack(transactionRef);

    if (txn.status === 'success') {
      // The charge did succeed. Process it now, then process the refund.
      await client.query(
        'INSERT INTO orders (payment_reference, status, amount, paid_at) ' +
        'VALUES ($1, $2, $3, $4) ' +
        'ON CONFLICT (payment_reference) DO UPDATE SET status = $2, amount = $3, paid_at = $4',
        [transactionRef, 'paid', txn.amount, txn.paid_at]
      );
    }
  }

  // Now process the refund
  await client.query(
    'UPDATE orders SET status = $1, refunded_at = NOW() WHERE payment_reference = $2',
    ['refunded', transactionRef]
  );
}

This approach is more aggressive than queuing for retry. Instead of waiting for the missing event, you fetch the truth from Paystack and fill in the gap yourself. The downside is that it adds an API call to your webhook processing, which increases latency and introduces a dependency on Paystack's API being available.

Use this approach for critical events (refunds, reversals) where delayed processing would cause customer confusion. For less critical events, a simple delayed retry is usually sufficient.

Do not call the Verify API for every event. Only use it when you detect an out-of-order situation (missing prior state). For the vast majority of events, the normal webhook flow works fine.

Logging and Monitoring Out-of-Order Events

Out-of-order events are normal. They happen. But you should track their frequency. A sudden increase might indicate an infrastructure issue on your end (slow database, overloaded webhook handler) or a disruption at Paystack.

async function logOutOfOrderEvent(eventType, reference, expectedState, actualState) {
  await db.query(
    'INSERT INTO out_of_order_events ' +
    '(event_type, reference, expected_state, actual_state, detected_at) ' +
    'VALUES ($1, $2, $3, $4, NOW())',
    [eventType, reference, expectedState, actualState]
  );

  // Increment a metric for monitoring
  metrics.increment('webhook.out_of_order', {
    event_type: eventType,
    expected: expectedState,
    actual: actualState,
  });
}

// Usage in a handler
async function handleTransferReversed(data, client) {
  const result = await client.query(
    'SELECT status FROM payouts WHERE reference = $1',
    [data.reference]
  );

  if (result.rows.length === 0) {
    await logOutOfOrderEvent(
      'transfer.reversed',
      data.reference,
      'success',
      'not_found'
    );
    // Queue for retry...
    return;
  }

  if (result.rows[0].status !== 'success') {
    await logOutOfOrderEvent(
      'transfer.reversed',
      data.reference,
      'success',
      result.rows[0].status
    );
  }

  // Process the reversal...
}

Set up an alert if out-of-order events exceed a threshold (say, more than 5% of total events in an hour). Under normal conditions, out-of-order delivery is rare. If it becomes frequent, investigate your webhook processing latency and queue depth.

For a complete monitoring setup, see monitoring and alerting on webhook failures.

Design Principles for Order-Independent Handlers

These principles apply to every webhook handler you build, not just Paystack:

  1. Treat every event as independent. Do not assume any other event has been processed before this one. Check the database state.
  2. Use the latest information. If a transfer.reversed arrives and the payout record shows "processing" (not "success"), the reversal still tells you the outcome. Update to "reversed" regardless of the intermediate state you missed.
  3. Prefer upserts over inserts. When creating records from webhook events, use INSERT ... ON CONFLICT DO UPDATE so that a late-arriving "create" event does not fail because a subsequent event already created the record.
  4. Timestamp everything. Store when each event was processed. When debugging, timestamps tell you the sequence your system saw, which might differ from the sequence Paystack sent.
  5. Never delete records from webhook processing. Mark them as cancelled, reversed, or inactive. Deletion loses the audit trail and makes out-of-order debugging impossible.

If you follow these principles, out-of-order delivery becomes a non-issue. Each handler makes the correct decision based on the current state, regardless of what came before.

For the full reliability story, see the Paystack webhooks engineering guide. For handling the specific race condition between webhooks and browser redirects, see race conditions between webhooks and redirect callbacks.

Key Takeaways

  • Paystack does not guarantee webhook delivery order. Events can arrive in any sequence regardless of when they occurred.
  • Retries make ordering worse. A retried charge.success can arrive after a refund.processed for the same transaction.
  • Never write handlers that assume a previous event has been processed. Always check the current state before acting.
  • Use the Paystack Verify API to fetch the current transaction status when an event references a prior state you have not seen.
  • State machine patterns work well: define valid transitions and reject or queue events that arrive for unexpected states.
  • Log out-of-order events for visibility. They are normal, but spikes in out-of-order delivery may indicate infrastructure issues.

Frequently Asked Questions

Does Paystack guarantee webhook delivery order?
No. Paystack does not guarantee that webhooks arrive in the order events occurred. Network conditions, retries, and internal processing differences all contribute to potential out-of-order delivery. Build your handlers to work regardless of arrival order.
How common is out-of-order webhook delivery from Paystack?
Under normal conditions, most webhooks arrive in chronological order. Out-of-order delivery happens occasionally, often triggered by retries (your server was slow, so a later event arrives before the retried earlier event). The exact frequency depends on your infrastructure and processing speed.
What should I do when a refund webhook arrives before the charge webhook?
Do not discard the refund event. Either queue it for delayed retry (give the charge event time to arrive and be processed) or call the Paystack Verify API to confirm the charge succeeded, process the charge yourself, and then process the refund. Never silently ignore the event.
Should I use timestamps to detect out-of-order events?
Timestamps help with logging and debugging, but do not rely on them for ordering decisions. The paid_at timestamp in a charge.success event tells you when the payment happened, not when the webhook was sent or delivered. Use the current database state of the entity to make processing decisions.
Can I prevent out-of-order delivery by processing webhooks sequentially?
Processing events sequentially (one at a time) for the same entity reduces but does not eliminate the problem. It helps with the concurrent processing case, but network-level reordering still happens before events reach your server. Sequential processing also reduces throughput. A state-checking approach is more robust and scales better.

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