Available for Hire
ZB

Memuat...

Back to Blog
Backend 5 min read Β· 1087 words Featured

Reliable Payment Webhooks: Idempotency, Signatures, and Reconciliation

The redirect page after checkout is not proof of payment. Notes on treating the webhook as the source of truth, handling duplicate deliveries, and preparing for the webhook that never arrives.

#payment-gateway #webhook #backend #duitku

Integrating a payment gateway looks simple in the documentation: create a transaction, send the user to the payment page, receive the callback, mark it paid. What the documentation leaves out is every way that integration can go wrong, and almost all of it only surfaces once real money is moving.

The Redirect Page Is Not Proof of Payment

The most common mistake, and the most expensive, is marking an order paid on the returnUrl page. The flow feels reasonable: the user finishes paying, the gateway sends them back to our site, we mark it paid.

The problem is that this page is controlled by the user’s browser. It may never open because the connection dropped right after a successful payment. It may open twice because the user hit refresh. Worst of all, the address can be guessed and opened directly without paying anything.

There is one rule: returnUrl is only for changing what is displayed, callbackUrl is for changing data. The redirect page may say β€œthank you, we are verifying your payment”, but the status in the database must only change because of a webhook from the gateway.

Verify the Signature, Always

Your callback endpoint is publicly reachable. Anyone who guesses the address can POST a body claiming an order has been paid.

Every gateway provides a signature mechanism. Duitku, for example, sends a signature that is a hash of the merchant code, amount, order id, and API key combined. Verification has to happen before a single row of data is touched:

import crypto from 'node:crypto';

function verifySignature(body, apiKey) {
  const { merchantCode, amount, merchantOrderId, signature } = body;
  const expected = crypto
    .createHash('md5')
    .update(`${merchantCode}${amount}${merchantOrderId}${apiKey}`)
    .digest('hex');

  // Constant-time comparison, so how long the check takes does not
  // reveal how many leading characters were already correct.
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(String(signature))
  );
}

Two things are easy to miss here. First, timingSafeEqual throws when the two buffers differ in length, so guard with a length check first. Second, never log the signature or the API key. Logs often end up in a third-party service.

Duplicate Deliveries Are Normal, Not a Bug

A gateway will resend a callback if it does not receive a 200 within its timeout. A slow network, a process restart, or one unusually heavy query is enough to trigger it. That means your endpoint will receive the same notification more than once.

If the handling is naive, a single payment can add stock twice, send two invoices, and deduct a balance twice.

The answer is idempotency. Store every incoming notification under a unique key from the gateway, and reject the ones already processed:

await prisma.$transaction(async (tx) => {
  // The reference column has a unique index. If it already exists this
  // throws, and the whole transaction rolls back with no side effects.
  try {
    await tx.paymentEvent.create({
      data: { reference: body.reference, payload: body },
    });
  } catch (e) {
    if (e.code === 'P2002') return; // already processed
    throw e;
  }

  const order = await tx.order.findUnique({
    where: { id: body.merchantOrderId },
  });

  if (!order || order.status === 'PAID') return;
  if (order.amount !== Number(body.amount)) {
    throw new Error('Amount mismatch');
  }

  await tx.order.update({
    where: { id: order.id },
    data: { status: 'PAID', paidAt: new Date() },
  });

  await tx.stockMovement.create({ data: buildMovement(order) });
});

Note the order.amount !== body.amount check. Without it, someone who manages to forge a callback could settle a ten million rupiah order by paying one thousand. The amount has to be matched, not just the status.

Note as well that everything sits inside one database transaction. If recording the stock movement fails, the order status rolls back with it. An order marked paid whose stock never moved is far harder to trace than one that failed outright.

Reply Fast, Do the Work Afterwards

Gateways have a timeout, typically five to ten seconds. If you send an email, generate a PDF, and fire a WhatsApp message inside the callback handler, sooner or later one will exceed that window and the gateway will resend the notification. You are back to the duplicate problem.

The safe pattern: inside the handler, do only what must be atomic, which is verifying the signature, recording the event, and changing the status. Push everything else onto a queue.

app.post('/webhook/payment', async (req, reply) => {
  if (!verifySignature(req.body, env.DUITKU_API_KEY)) {
    return reply.code(401).send('invalid signature');
  }

  await applyPayment(req.body);   // fast, transactional
  await queue.add('post-payment', { orderId: req.body.merchantOrderId });

  return reply.code(200).send('OK'); // reply as soon as possible
});

Always return 200 for a notification you have successfully processed, including duplicates. Returning an error on a duplicate simply makes the gateway retry forever.

Reconciliation for the Webhook That Never Arrives

This is the part most often forgotten. Webhooks get lost. Your server was down when the gateway tried, or the notification got stuck on their side. The result is a payment that has landed in the bank account while the order still reads pending, and the first person to notice is usually an angry customer.

Set up a scheduled job that sweeps pending orders and asks the gateway directly:

// Runs every ten minutes
const stale = await prisma.order.findMany({
  where: {
    status: 'PENDING',
    createdAt: { lt: new Date(Date.now() - 15 * 60 * 1000) },
  },
  take: 100,
});

for (const order of stale) {
  const remote = await gateway.checkTransaction(order.id);
  if (remote.status === 'SUCCESS') {
    await applyPayment(remote); // same function, idempotent
  }
}

Because applyPayment is already idempotent, calling it from two paths is safe. The webhook handles the fast path, reconciliation handles the leaks.

Testing Before Real Money Is Involved

A callback endpoint cannot simply be tested from localhost, because the gateway needs to reach it from the internet. Use a tunnel such as ngrok during development, and keep real payloads from sandbox mode as fixtures for automated tests.

At minimum, cover these four scenarios:

  1. A valid notification, order becomes paid
  2. The same notification delivered twice, the end result is a single effect
  3. An invalid signature, answered with 401 and no data changed
  4. An amount that does not match the order, rejected

All four are cheap to write and catch nearly every mistake that touches money.

Closing Note

What separates a calm payment integration from one that keeps you up at night is not the library you chose, it is your assumptions about reliability. Assume a notification can arrive twice, arrive late, never arrive, and be forged. Handle those four assumptions from the start and the rest is detail.

Share this article:

Enjoyed this article?

0 reactions