Proper QRIS Webhook Implementation: Receiving Payment Notifications, Retry Logic, and Idempotency

Proper QRIS Webhook Implementation: A Complete Guide to Reliable Payment Notifications
QRIS transactions in Indonesia surged 226.54% in the past year, with 50.50 million users and 32.71 million merchants, highlighting the critical need for reliable payment systems. This growth brings a new challenge: how do you ensure payment notifications reach your system on time and without duplication?
This article covers how to properly implement QRIS webhooks, from receiving payment notifications to implementing retry logic and idempotency to prevent duplicate transaction processing.
Why QRIS Webhooks Often Fail
One common problem faced by QRIS merchants is poor internet connectivity that prevents payment notifications from being delivered, causing sellers to either mistakenly deliver goods or wait for notifications that never arrive. This problem doesn't stem from QRIS or payment gateways themselves, but from insufficient preparation on the merchant side.
Svix's 2024 webhook reliability research shows that the average webhook consumer experiences a 3.5% failure rate, and 15% of webhook implementations lack proper retry mechanisms, leading to potential lost orders. That's a significant figure for businesses relying on online payments.
Payment gateways like Midtrans and Xendit automatically retry webhook delivery when notifications fail, which means merchants must ensure their webhook handlers are idempotent to avoid duplicate processing. Without proper preparation, your system could receive the same notification multiple times.
Basic Webhook Handler Structure
A good webhook handler must meet three main principles: fast response, asynchronous processing, and idempotency. Here's a basic structure example using Node.js/Express:
app.post('/webhook/qris', async (req, res) => {
// 1. Validate signature
const signature = req.headers['x-payment-signature'];
if (!verifySignature(req.body, signature)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// 2. Return 200 OK immediately (before processing)
res.status(200).json({ received: true });
// 3. Process asynchronously
processPaymentNotification(req.body).catch(err => {
console.error('Webhook processing failed:', err);
// Implement retry logic here
});
});
The key here is returning a 200 OK response before processing the transaction. This tells the payment gateway that the notification was received, while processing happens in the background. If your server crashes after sending 200 but before processing completes, you'll need a reconciliation mechanism.
Implementing Idempotency Correctly
Idempotency means performing the same operation multiple times yields the same result as performing it once. For QRIS webhooks, this means if the same payment notification is received twice, the system should only process it once.
The common pattern is to use the transaction ID from the payment gateway as the idempotency key:
async function processPaymentNotification(payload) {
const { transaction_id, status, amount } = payload;
// Check if already processed
const existing = await db.transactions.findOne({
payment_gateway_id: transaction_id
});
if (existing) {
// Already processed - verify no change in critical data
if (existing.status !== status) {
// Status changed legitimately (e.g., pending -> settled)
await updateTransactionStatus(existing, status);
}
return;
}
// Process new transaction
const transaction = await createTransaction(transaction_id, amount, status);
await fulfillOrder(transaction);
}
As of Semester I 2025, QRIS has reached 57 million users and 39.3 million merchants, 93.16% of which are SMEs, emphasizing SME dominance in QRIS adoption. For SMEs just starting to implement digital payment systems, understanding idempotency is crucial to prevent confusion and losses.
Proper Retry Logic
When webhook processing fails (database down, third-party API error, etc.), you can't rely on payment gateways to keep retrying forever. Midtrans and Xendit have their own retry limits. After that, notifications stop coming and your orders are left hanging.
Implement a queue system for retry logic:
async function processPaymentNotification(payload) {
try {
await processWithDb(payload);
} catch (error) {
// Add to retry queue with exponential backoff
await retryQueue.add('process-payment', {
payload,
attempt: 1
}, {
attempts: 5,
backoff: {
type: 'exponential',
delay: 2000 // 2s, 4s, 8s, 16s, 32s
}
});
}
}
// Queue processor
retryQueue.process(async (job) => {
const { payload, attempt } = job.data;
try {
await processWithDb(payload);
} catch (error) {
if (attempt >= 5) {
// Max retries reached - alert human
await alertTeam(payload, error);
throw error; // Mark as failed
}
throw error; // Trigger next retry
}
});
This pattern provides several benefits: automatic reprocessing with exponential backoff (avoiding thundering herd), a maximum retry limit before escalating to humans, and decoupling from the webhook endpoint that remains responsive.
Reconciliation: The Last Safety Net
Even with reliable webhooks, you still need periodic reconciliation processes to catch edge cases that slip through: notifications that are lost forever, bugs in idempotency logic, or severe system failures. This includes payments that succeeded on the payment gateway side but weren't recorded in your system.
// Run every hour
async function reconcileTransactions() {
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
// Fetch settled transactions from payment gateway
const gatewayTransactions = await paymentGateway.settlements.list({
created_after: yesterday
});
for (const gwTx of gatewayTransactions) {
const local = await db.transactions.findOne({
payment_gateway_id: gwTx.id
});
if (!local && gwTx.status === 'settled') {
// Found settled payment not in our system
console.warn('Unreconciled transaction:', gwTx.id);
await createTransactionFromGateway(gwTx);
await alertTeam('Reconciled transaction', gwTx.id);
}
}
}
This reconciliation isn't a replacement for reliable webhooks, but a complement. It catches what slipped through your primary defense net. For production systems, run this as a cron job every 1-6 hours depending on transaction volume.
Testing Webhooks in Development
One of the biggest challenges in developing webhooks is how to test in a local environment. Payment gateways can't send requests to your localhost. Use one of these approaches:
- Ngrok or similar - Tunnel public to your localhost. Practical for development but not ideal for production.
- Staging environment - Deploy to a publicly accessible staging server. Closer to production.
- Mocking payment gateway - Manually simulate webhook requests. Good for unit testing but doesn't catch actual integration.
A healthy approach is a combination: mocking for unit tests, staging for integration tests, and ngrok only for debugging development sessions.
Next Steps
Proper webhook implementation is the foundation of a reliable digital payment system. With QRIS growth continuing to surge, businesses with strong payment infrastructure will have a competitive advantage. But webhooks are only one part of the larger payment integration puzzle. For a complete guide on payment gateway and QRIS integration for your business, read our comprehensive payment gateway and QRIS integration guide covering the entire digital payment ecosystem.
If your business is just starting its digital transformation and needs guidance on the right technical infrastructure, the SME digital transformation guide provides a practical roadmap from manual to digital systems. Or if you're ready to implement a reliable payment system but unsure about the right architecture for your business, consult with our team to get a solution designed specifically for your needs.

