<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: William Beaumont</title>
    <description>The latest articles on DEV Community by William Beaumont (@jaysononlyone).</description>
    <link>https://dev.to/jaysononlyone</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4237729%2F1fcfea9c-4ff2-4a7f-ad0f-072b0b97510c.jpg</url>
      <title>DEV Community: William Beaumont</title>
      <link>https://dev.to/jaysononlyone</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jaysononlyone"/>
    <language>en</language>
    <item>
      <title>How to stop duplicate payment callbacks from double-crediting orders (Indonesia QRIS &amp; virtual accounts)</title>
      <dc:creator>William Beaumont</dc:creator>
      <pubDate>Tue, 10 Nov 2026 19:17:00 +0000</pubDate>
      <link>https://dev.to/jaysononlyone/how-to-stop-duplicate-payment-callbacks-from-double-crediting-orders-indonesia-qris-virtual-5h7f</link>
      <guid>https://dev.to/jaysononlyone/how-to-stop-duplicate-payment-callbacks-from-double-crediting-orders-indonesia-qris-virtual-5h7f</guid>
      <description>&lt;p&gt;A customer pays, the order becomes paid, and your webhook endpoint loses its connection before the acknowledgement reaches the provider. The provider sends the notification again. Nothing unusual has happened to the payment, but your application now has another opportunity to credit the customer.&lt;/p&gt;

&lt;p&gt;This is the failure sequence I would start with when reviewing a QRIS or virtual account integration. It is a hypothetical example, not a reported production incident. Its value is that every component can behave reasonably while the combined system still produces a duplicate credit.&lt;/p&gt;

&lt;p&gt;The design question is simple: which durable record prevents a second delivery from causing a second business effect?&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate the delivery from the payment
&lt;/h2&gt;

&lt;p&gt;A webhook delivery is a message about a payment. It is not itself a new payment, and its arrival time does not establish the order of payment events.&lt;/p&gt;

&lt;p&gt;Keep separate identifiers for the merchant order, provider transaction, and notification event where available. Scope provider identifiers to the integration and merchant account unless the documented contract guarantees a wider uniqueness scope.&lt;/p&gt;

&lt;p&gt;An event identifier helps detect repeated messages. A provider transaction reference helps identify the money movement. An order identifier ties that movement to your business obligation. These keys answer different questions; replacing them all with one &lt;code&gt;orderId&lt;/code&gt; makes reconciliation harder.&lt;/p&gt;

&lt;p&gt;For QRIS and virtual accounts, also confirm how the approved integration maps instructions to orders. Do not assume that a QR instruction is reusable or that a virtual account identifies exactly one invoice. A reusable account number alone is an unsafe deduplication key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put the invariant in the database
&lt;/h2&gt;

&lt;p&gt;The invariant for a single-payment order is: one accepted payment can create at most one order credit. For split payments or other models, define a different explicit invariant before writing the handler.&lt;/p&gt;

&lt;p&gt;A memory cache cannot enforce this across restarts and multiple workers. A database query followed by an unconditional insert also leaves a race: two workers can both observe that no credit exists.&lt;/p&gt;

&lt;p&gt;Use database constraints and a transaction around the business mutation. A useful minimal design includes a webhook inbox, a payment allocation record, an order credit ledger, and an outbox for downstream fulfilment. Give each its own uniqueness rule. The inbox can have a unique event key; the order credit should have a unique business key such as &lt;code&gt;(order_id, credit_kind)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The payment allocation also needs a constraint preventing the same scoped provider transaction from being assigned to two orders. Otherwise, order-level protection can still allow one payment to fund two purchases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authenticate first, then commit the decision
&lt;/h2&gt;

&lt;p&gt;Verify the provider's documented webhook authentication before trusting the payload. Preserve raw request bytes if signature verification requires them. Validate the merchant account, order mapping, amount, currency, and payment status against your records.&lt;/p&gt;

&lt;p&gt;Do not invent a universal signature header, success status, or acknowledgement body. Those are integration contracts. The following Node-style pseudocode deliberately hides them behind adapter functions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;webhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;adapter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verifyAndNormalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// Adapter rejects unauthenticated or malformed input.&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;fresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertIfAbsent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// Uses a stable, scoped event ID if the contract provides one.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lockForUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;validatePaymentForOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exceptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;creditPosted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Different payment references need review, not another credit.&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recordObservation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allocations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;claimUniquePayment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertUniqueOrderCredit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;markPaid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;paymentRef&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;outbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertUnique&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sb"&gt;`fulfil:${order.id}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"FULFIL_ORDER"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="n"&gt;adapter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendDocumentedAcknowledgement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These helpers imply actual SQL constraints, row locking, and conflict handling, not merely application checks. &lt;code&gt;validatePaymentForOrder&lt;/code&gt; must reject ineligible states and unverified success claims. An allocation conflict must roll back the transaction and enter a durable investigation path; it must never fall through to a credit.&lt;/p&gt;

&lt;p&gt;When no stable event ID exists, store delivery observations without pretending a generated local ID detects duplicates. Payment allocation and order credit constraints remain the business protection. A payload hash alone is not a reliable payment identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Acknowledgement is a durability boundary
&lt;/h2&gt;

&lt;p&gt;Return the documented acknowledgement once the event and its disposition are durable. An authenticated amount mismatch can be recorded for investigation without accepting it as a valid credit. Invalid authentication should follow the integration's rejection procedure.&lt;/p&gt;

&lt;p&gt;If the transaction fails, do not send a success acknowledgement claiming the work was stored. Use the provider's documented error behavior and your recovery process. Conversely, if the commit succeeds and the response disappears, the next delivery should find durable evidence and receive the normal acknowledgement without another credit.&lt;/p&gt;

&lt;p&gt;Keep slow fulfilment outside the webhook transaction. The outbox closes the gap between committing the order and scheduling that work. Its consumer still needs idempotency: a worker can perform an action and crash before recording completion. Pass the stable fulfilment key to an idempotent downstream operation, or provide a recovery procedure for uncertain side effects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not erase a second real payment
&lt;/h2&gt;

&lt;p&gt;Two callbacks with the same transaction reference may describe one payment. Two distinct transaction references for one order may describe two payments. Suppressing both as “already paid” would hide money that finance needs to account for.&lt;/p&gt;

&lt;p&gt;Record the additional payment observation and reconcile it through the approved query or reporting process. An extra payment does not authorize another fulfilment, and it should not trigger an automatic refund based only on an unfamiliar callback.&lt;/p&gt;

&lt;p&gt;Similarly, an old pending notification must not downgrade a verified paid order. Refunds and reversals belong to explicit lifecycle transitions with their own ledger adjustments. A status field that accepts whichever message arrived last is not a payment state machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the spaces between successful steps
&lt;/h2&gt;

&lt;p&gt;I would make the test harness pause at the commit boundary, then kill the connection before the acknowledgement. Replay the same verified event and inspect the ledger, order, outbox, and acknowledgement together.&lt;/p&gt;

&lt;p&gt;Also send concurrent deliveries to separate workers. Deliver two different event IDs for the same transaction. Send an older pending event after success. Try a second transaction for the same order, an amount mismatch, and a reused transaction reference against another order.&lt;/p&gt;

&lt;p&gt;The expected result is more precise than “the endpoint returned success”: one valid order credit, one intended fulfilment, preserved evidence for exceptions, and no silent state regression.&lt;/p&gt;

&lt;p&gt;Finally, make missing callbacks recoverable. A reconciliation job should compare provider records with local payments and orders, then route verified differences through the same guarded transition logic. Separate provider-pending payments, missing notifications, and failures to apply a received notification in operational views. They require different investigations.&lt;/p&gt;

&lt;p&gt;A useful support record contains scoped references, amount, currency, event and receipt timestamps, verification outcome, and processing disposition. Keep secrets and full customer banking details out of ordinary logs.&lt;/p&gt;

&lt;p&gt;For a compact checklist to use during implementation review, PAYJN's guide to &lt;a href="https://www.payjn.com/guides/payment-webhook-duplicate-events/" rel="noopener noreferrer"&gt;handling duplicate payment webhook events&lt;/a&gt; covers acknowledgement, order credit protection, and conflicting notifications.&lt;/p&gt;

</description>
      <category>webhooks</category>
      <category>node</category>
      <category>api</category>
      <category>fintech</category>
    </item>
  </channel>
</rss>
