The Double-Grant Bug: Webhook and Callback Both Firing
The double-grant bug happens when both your webhook handler and your redirect callback independently verify a Paystack transaction and both grant value. Fix it by using an idempotency lock: before granting value, atomically check and set a "paid" flag on the order. Use SELECT FOR UPDATE in PostgreSQL or a unique constraint to ensure only one handler grants value.
How the Bug Happens
The timeline looks like this:
- Customer pays successfully on your Paystack checkout.
- Paystack sends a
charge.successwebhook to your server. - Your webhook handler verifies the transaction, sees
status: "success", and grants value: marks the order as paid, adds credits to the account, sends a confirmation email. - Meanwhile (sometimes milliseconds later, sometimes seconds later), the customer's browser redirects to your callback URL with the reference in the query string.
- Your callback handler also verifies the transaction, also sees
status: "success", and also grants value: marks the order as paid again, adds credits again, sends another email.
The customer ends up with double credits, two confirmation emails, or two shipments. Your revenue numbers are wrong. Your inventory is off.
This bug is especially common because each handler works perfectly in isolation. If you test the webhook path alone, it works. If you test the redirect path alone, it works. The bug only appears when both fire for the same transaction, which is exactly what happens in production every single time a payment succeeds.
For the full verification pipeline, see the complete verification and reconciliation guide.
Why Both Fire Every Time
Some developers think this is a rare race condition. It is not. It happens on every successful payment. Paystack always sends the webhook. The browser always redirects to the callback URL (unless the customer closes the tab). Both will run for every payment.
The only question is which one runs first. Sometimes the webhook arrives before the redirect. Sometimes the redirect hits your server before the webhook. Sometimes they arrive at almost the same instant and hit different server processes simultaneously.
The timing depends on factors you cannot control: network latency between Paystack and your server, how fast the browser follows the redirect, whether the customer is on a slow mobile network, whether your server has multiple instances behind a load balancer.
You cannot predict or control the order. You must handle both arriving in any order, including simultaneously.
The Idempotency Lock Pattern
The fix is an idempotency lock. Before granting value, atomically check whether value has already been granted for this reference. If it has, skip. If it has not, mark it as granted and proceed.
The critical word is "atomically." You cannot do a SELECT followed by a separate UPDATE, because a race condition between the two statements could let both handlers through. You need a single atomic operation.
In PostgreSQL, use SELECT ... FOR UPDATE inside a transaction:
// Idempotent value granting with PostgreSQL
async function grantValueOnce(reference, grantFn) {
var client = await pool.connect();
try {
await client.query('BEGIN');
// Lock the row. Any concurrent request blocks here until this transaction ends.
var result = await client.query(
'SELECT id, status FROM orders WHERE paystack_reference = $1 FOR UPDATE',
[reference]
);
if (result.rows.length === 0) {
await client.query('ROLLBACK');
return { granted: false, reason: 'order_not_found' };
}
var order = result.rows[0];
if (order.status === 'paid') {
// Another handler already granted value
await client.query('ROLLBACK');
return { granted: false, reason: 'already_paid' };
}
// Grant value (the callback you pass in)
await grantFn(client, order.id);
// Mark as paid
await client.query(
'UPDATE orders SET status = $1, paid_at = NOW() WHERE id = $2',
['paid', order.id]
);
await client.query('COMMIT');
return { granted: true, order_id: order.id };
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
Use this same function in both your webhook handler and your redirect callback handler. The first one to run acquires the row lock, grants value, and sets status to "paid". The second one sees status "paid" and returns immediately without granting anything.
Using the Lock in Both Handlers
Here is how you wire the idempotency lock into your webhook and callback handlers:
// Webhook handler
app.post('/webhooks/paystack', async (req, res) => {
// Validate HMAC signature first (not shown)
var event = req.body;
if (event.event !== 'charge.success') {
return res.sendStatus(200);
}
var reference = event.data.reference;
// Verify with Paystack (belt and suspenders)
var verification = await verifyTransaction(reference);
if (!verification.status || verification.data.status !== 'success') {
return res.sendStatus(200);
}
// Grant value using the idempotency lock
var result = await grantValueOnce(reference, async function(client, orderId) {
// Add credits, update inventory, etc.
await client.query(
'UPDATE user_credits SET balance = balance + $1 WHERE user_id = (SELECT user_id FROM orders WHERE id = $2)',
[verification.data.amount, orderId]
);
// Record which handler granted value (useful for debugging)
await client.query(
'UPDATE orders SET granted_by = $1 WHERE id = $2',
['webhook', orderId]
);
});
console.log('Webhook grant result for ' + reference + ': ' + JSON.stringify(result));
return res.sendStatus(200);
});
// Redirect callback handler
app.get('/payment/callback', async (req, res) => {
var reference = req.query.reference;
if (!reference) {
return res.redirect('/payment/error');
}
var verification = await verifyTransaction(reference);
if (!verification.status || verification.data.status !== 'success') {
return res.redirect('/payment/failed');
}
// Same idempotency lock, same grantValueOnce function
var result = await grantValueOnce(reference, async function(client, orderId) {
await client.query(
'UPDATE user_credits SET balance = balance + $1 WHERE user_id = (SELECT user_id FROM orders WHERE id = $2)',
[verification.data.amount, orderId]
);
await client.query(
'UPDATE orders SET granted_by = $1 WHERE id = $2',
['callback', orderId]
);
});
return res.redirect('/payment/success?order=' + result.order_id);
});
Both handlers call the same grantValueOnce function. Only the first one through the lock actually executes the grant. The second one sees "already_paid" and skips.
The Simpler Alternative: Webhook-Only Granting
If the idempotency lock feels like too much machinery, there is a simpler pattern: only grant value in the webhook handler. The redirect callback never grants value. It only checks the order status and displays the appropriate page.
// Webhook handler - this is the ONLY place that grants value
app.post('/webhooks/paystack', async (req, res) => {
// Validate signature, verify transaction...
var reference = req.body.data.reference;
await db.query(
'UPDATE orders SET status = $1, paid_at = NOW() WHERE paystack_reference = $2 AND status != $3',
['paid', reference, 'paid']
);
// Grant credits, send email, etc.
return res.sendStatus(200);
});
// Redirect callback - display only, never grants
app.get('/payment/callback', async (req, res) => {
var reference = req.query.reference;
var order = await db.query(
'SELECT id, status FROM orders WHERE paystack_reference = $1',
[reference]
);
if (order.rows.length === 0) {
return res.redirect('/payment/error');
}
if (order.rows[0].status === 'paid') {
// Webhook already processed this
return res.redirect('/payment/success?order=' + order.rows[0].id);
}
// Webhook hasn't arrived yet. Show a "processing" page.
return res.redirect('/payment/processing?order=' + order.rows[0].id);
});
This approach is simpler and works well for most applications. The trade-off is that the customer might see a "processing" page if the redirect arrives before the webhook. You can handle this with a polling mechanism on the processing page that checks the order status every few seconds.
Idempotency Without PostgreSQL FOR UPDATE
If you use MongoDB, you do not have SELECT FOR UPDATE. Use findOneAndUpdate with a condition:
// MongoDB idempotency lock
async function grantValueOnceMongo(reference, grantFn) {
// Atomically find and update the order - only if status is NOT 'paid'
var order = await db.collection('orders').findOneAndUpdate(
{ paystack_reference: reference, status: { $ne: 'paid' } },
{ $set: { status: 'paid', paid_at: new Date() } },
{ returnDocument: 'before' }
);
if (!order) {
// Either order not found or already paid
var existing = await db.collection('orders').findOne({ paystack_reference: reference });
if (existing && existing.status === 'paid') {
return { granted: false, reason: 'already_paid' };
}
return { granted: false, reason: 'order_not_found' };
}
// Status was just set to 'paid'. Grant value.
await grantFn(order._id);
return { granted: true, order_id: order._id };
}
The key is the status: { $ne: 'paid' } condition. MongoDB's findOneAndUpdate is atomic at the document level. Only one handler will match the condition and update the status. The other will get null back.
For MySQL, use a similar pattern with SELECT ... FOR UPDATE inside a transaction, just like the PostgreSQL example. For Redis-backed locks, use SET key value NX EX ttl to acquire a lock on the reference.
Detecting Double Grants After the Fact
If you suspect your integration might have the double-grant bug, here is how to detect it:
-- Find orders that were granted value more than once
-- (requires a ledger or audit log)
SELECT reference, COUNT(*) as grant_count
FROM payment_events
WHERE event_type = 'value_granted'
GROUP BY reference
HAVING COUNT(*) > 1;
If you do not have an audit log, look for indirect evidence:
- Users with credit balances that are exactly double what they should be.
- Two confirmation emails sent for the same order within seconds of each other.
- Inventory counts that do not match the number of orders.
- Revenue that is double the settlement amount from Paystack.
Once you find affected transactions, you will need to manually reverse the extra grants. Then deploy the idempotency fix before processing any more payments.
For a complete audit checklist, see the auditing an inherited integration guide.
Testing for Double Grants
Add this test to your integration test suite. It simulates both handlers firing simultaneously for the same reference:
// test/double-grant.test.js
var assert = require('assert');
it('should grant value only once when webhook and callback fire simultaneously', async function() {
// Set up: create an order with status 'pending'
var reference = 'test_double_' + Date.now();
await db.query(
'INSERT INTO orders (paystack_reference, expected_amount, expected_currency, status) VALUES ($1, $2, $3, $4)',
[reference, 500000, 'NGN', 'pending']
);
// Simulate both handlers firing at the same time
var results = await Promise.all([
grantValueOnce(reference, async function(client, orderId) {
await client.query(
'UPDATE user_credits SET balance = balance + 500000 WHERE user_id = 1'
);
}),
grantValueOnce(reference, async function(client, orderId) {
await client.query(
'UPDATE user_credits SET balance = balance + 500000 WHERE user_id = 1'
);
}),
]);
// Exactly one should have granted
var grantCount = results.filter(function(r) { return r.granted; }).length;
assert.strictEqual(grantCount, 1, 'Expected exactly one grant, got ' + grantCount);
// Check the actual credit balance
var credits = await db.query('SELECT balance FROM user_credits WHERE user_id = 1');
// Balance should only have increased by 500000, not 1000000
});
Run this test multiple times. Race conditions are timing-dependent, so a single successful run does not guarantee correctness. Run it 100 times in a loop to build confidence.
Key Takeaways
- ✓The double-grant bug happens when both your webhook handler and redirect callback verify and grant value for the same transaction.
- ✓Each handler works correctly in isolation. The bug only appears when both run for the same payment.
- ✓Fix it with an idempotency lock using SELECT FOR UPDATE in PostgreSQL or an atomic compare-and-set operation.
- ✓A simpler alternative: only grant value in the webhook handler. The redirect callback only checks status and displays a page.
- ✓Always test for double-grants by simulating concurrent webhook and callback requests for the same reference.
- ✓Log which handler granted value for each transaction. This makes debugging much easier.
Frequently Asked Questions
- Can I just use a simple boolean flag instead of SELECT FOR UPDATE?
- A simple boolean flag without a database lock creates a time-of-check-to-time-of-use (TOCTOU) race condition. Handler A reads the flag as false, handler B reads the flag as false, both set it to true and both grant value. You need an atomic operation that combines the check and the update.
- What if my webhook handler crashes after granting value but before setting the status?
- This is why the status update and value grant should be in the same database transaction. If the transaction rolls back, neither the status change nor the value grant persists, and the next handler (or webhook retry) will process it correctly.
- Is the double-grant bug specific to Paystack?
- No. Any payment gateway that sends webhooks and also redirects the customer to a callback URL can trigger this bug. The idempotency lock pattern applies to Stripe, Flutterwave, and every other gateway.
- How do I know which handler granted value for a specific transaction?
- Add a "granted_by" column to your orders table that records whether the webhook or the callback handler granted value. This is invaluable for debugging. If you see most grants coming from one handler, you can simplify to the webhook-only pattern.
- Does the webhook-only pattern mean I do not need a callback URL?
- You still need a callback URL for the customer experience. After paying, the customer needs to see a success or processing page, not a blank screen. The callback URL handles the UX. It just does not grant value.
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