<?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: Gokul Krishna</title>
    <description>The latest articles on DEV Community by Gokul Krishna (@gokul_krishna_5cb68b2138c).</description>
    <link>https://dev.to/gokul_krishna_5cb68b2138c</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%2F4197878%2F473bb15a-8309-4c9c-9e2b-cbf4d9d7f679.png</url>
      <title>DEV Community: Gokul Krishna</title>
      <link>https://dev.to/gokul_krishna_5cb68b2138c</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gokul_krishna_5cb68b2138c"/>
    <language>en</language>
    <item>
      <title>Reconciling Payments, Refunds and Settlements: A Developer's Guide for Indian Gateways</title>
      <dc:creator>Gokul Krishna</dc:creator>
      <pubDate>Wed, 28 Oct 2026 06:47:02 +0000</pubDate>
      <link>https://dev.to/gokul_krishna_5cb68b2138c/reconciling-payments-refunds-and-settlements-a-developers-guide-for-indian-gateways-5bag</link>
      <guid>https://dev.to/gokul_krishna_5cb68b2138c/reconciling-payments-refunds-and-settlements-a-developers-guide-for-indian-gateways-5bag</guid>
      <description>&lt;p&gt;Accepting payments is the visible part of a payment integration. Reconciliation is the part finance teams care about: proving that every rupee captured, refunded and charged as fees matches what landed in the bank account.&lt;/p&gt;

&lt;p&gt;This guide explains how settlements work with Indian gateways, which data to pull, and how to design a batch job that matches it all. The approach applies to Razorpay, Cashfree, PayU and others. Razorpay's settlement APIs are used as the concrete example.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ledgers that must agree
&lt;/h2&gt;

&lt;p&gt;Reconciliation compares three independent records:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your application database: orders, payments, refunds as your system understands them.&lt;/li&gt;
&lt;li&gt;The gateway's records: captured payments, refunds, fees, tax, adjustments and the settlements that group them.&lt;/li&gt;
&lt;li&gt;Your bank statement: the actual credits, each identified by a UTR (unique transaction reference).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Matching 1 to 2 tells you whether your app recorded every transaction correctly. Matching 2 to 3 tells you whether the money arrived.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a settlement is built
&lt;/h2&gt;

&lt;p&gt;Gateways do not send each payment to your bank individually. They batch transactions into settlements on a schedule. For Indian domestic payments, Razorpay's &lt;a href="https://razorpay.com/docs/payments/settlements/faqs/" rel="noopener noreferrer"&gt;settlement FAQs&lt;/a&gt; describe a default T+2 working day cycle, where T is the capture date. Working days exclude Sundays, the second and fourth Saturdays, and bank holidays, so long weekends shift settlements.&lt;/p&gt;

&lt;p&gt;Conceptually, each settlement amount is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;settled = captured payments
        - gateway fees
        - tax on fees
        - refunds
        +/- adjustments
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only captured payments are settled. Authorized but uncaptured payments do not appear.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pulling settlement data
&lt;/h2&gt;

&lt;p&gt;The most useful endpoint for reconciliation returns line items, not just totals. Razorpay's &lt;a href="https://razorpay.com/docs/api/settlements/fetch-recon/" rel="noopener noreferrer"&gt;settlement recon API&lt;/a&gt; is GET /v1/settlements/recon/combined with year and month as required parameters, plus optional day, count (up to 1000) and skip for pagination.&lt;/p&gt;

&lt;p&gt;Each record includes fields such as entity_id, type (payment, refund, transfer or adjustment), debit, credit, amount, fee, tax, settled, settlement_id, settlement_utr and order_receipt. All amounts are in paise.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.razorpay.com/v1/settlements/recon/combined&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_recon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key_secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;day&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;skip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key_secret&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;year&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;month&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;month&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;02&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;day&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;skip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;skip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&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;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;
        &lt;span class="n"&gt;skip&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The call uses HTTP basic authentication with your API key ID and key secret, so run it only on the server and load the credentials from a secrets store. Paginate until a page returns fewer rows than you asked for, because a busy day can easily exceed a single page.&lt;/p&gt;

&lt;p&gt;Store these rows as is, in a staging table, before transforming them. Raw data makes later investigations much easier.&lt;/p&gt;

&lt;p&gt;The order_receipt field is worth planning for at integration time. If you pass your internal order ID as the receipt when creating orders, every settled payment row carries a direct pointer back to your database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fees and GST
&lt;/h2&gt;

&lt;p&gt;Gateway fees are deducted at source, and GST is charged on the fee. In Razorpay's &lt;a href="https://razorpay.com/docs/api/payments/fetch-with-id/" rel="noopener noreferrer"&gt;payment entity&lt;/a&gt;, fee is the total charged including tax, and tax is the tax component. In the recon data, fee is the processing fee and tax is the tax on that fee.&lt;/p&gt;

&lt;p&gt;Two practical points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Do not hard-code a fee percentage in your reconciliation logic. Rates vary by method, card type and negotiated pricing. Read the fee from the data.&lt;/li&gt;
&lt;li&gt;Keep fee and tax as separate columns. Finance teams typically need the tax total to tie back to the gateway's tax invoice for the period.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your expected net for a payment is amount minus fee, where fee already includes tax. Getting that relationship wrong, by subtracting tax twice, is a common source of small, persistent mismatches.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refunds and adjustments
&lt;/h2&gt;

&lt;p&gt;Refunds appear as debit rows in settlement data, linked to the original payment through payment_id. They reduce the settlement for the period in which they are processed, not the period of the original payment.&lt;/p&gt;

&lt;p&gt;Track refund status separately from payment status. Razorpay refunds move through pending, processed and failed, and its &lt;a href="https://razorpay.com/docs/webhooks/refunds/" rel="noopener noreferrer"&gt;refund webhooks&lt;/a&gt; include refund.created, refund.processed, refund.failed and refund.speed_changed. A payment's refund_status of partial or full tells you how much has been reversed overall.&lt;/p&gt;

&lt;p&gt;Adjustments are the catch-all: corrections and other manual entries, which can include dispute-related debits depending on the gateway. Never auto-match them. Route every adjustment row to a human review queue with its description.&lt;/p&gt;

&lt;h2&gt;
  
  
  Matching bank credits
&lt;/h2&gt;

&lt;p&gt;The settlement UTR is the bridge between gateway and bank. When Razorpay transfers a settlement, it fires the settlement.processed webhook, described in the &lt;a href="https://razorpay.com/docs/webhooks/settlements/" rel="noopener noreferrer"&gt;settlement webhook reference&lt;/a&gt;, with the settlement id, amount, fees, tax and utr.&lt;/p&gt;

&lt;p&gt;Match on UTR first, then confirm the amount:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;match_bank_credit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stmt_line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;settlements_by_utr&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;settlements_by_utr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stmt_line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unmatched_bank_credit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stmt_line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;stmt_line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount_paise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount_mismatch&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stmt_line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;matched&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bank statement formats vary. Normalise the UTR (trim spaces, uppercase) and convert amounts to paise before comparing, so floating point rupees never enter the comparison.&lt;/p&gt;

&lt;h2&gt;
  
  
  Designing the batch job
&lt;/h2&gt;

&lt;p&gt;A daily job works well for most businesses. A reasonable shape:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pull recon rows for the previous few days, not just yesterday, to catch late entries.&lt;/li&gt;
&lt;li&gt;Upsert into staging keyed by entity_id and settlement_id, so reruns are safe.&lt;/li&gt;
&lt;li&gt;Match payment rows to your orders using order_receipt or the gateway order ID.&lt;/li&gt;
&lt;li&gt;Match refund rows to your refund records using the refund ID.&lt;/li&gt;
&lt;li&gt;Sum each settlement's rows and compare with the settlement amount.&lt;/li&gt;
&lt;li&gt;Match settlements to bank credits by UTR.&lt;/li&gt;
&lt;li&gt;Write every unmatched or mismatched item to an exceptions table with a reason code.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Run the job in IST and store timestamps in UTC. Date boundaries are a frequent cause of rows appearing in the "wrong" day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mismatch causes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A payment captured in your app but not yet settled, because of the T+2 cycle or a holiday.&lt;/li&gt;
&lt;li&gt;A refund initiated from the dashboard and never recorded in your database.&lt;/li&gt;
&lt;li&gt;Partial settlements, where only part of the pending amount is settled in one transfer.&lt;/li&gt;
&lt;li&gt;Transactions on hold, flagged by on_hold in recon data.&lt;/li&gt;
&lt;li&gt;Late-authorised payments that your app recorded as failed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these resolves itself or needs a known action. The exceptions table should make the next step obvious.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Good reconciliation is mostly data hygiene: pass your own IDs to the gateway, store raw settlement rows, work in paise, keep fee and tax separate, and match bank credits by UTR. Build the job to be rerunnable, and route anything ambiguous to people rather than guessing.&lt;/p&gt;

&lt;p&gt;Disclosure: The author works with the Razorpay team on content.&lt;/p&gt;

</description>
      <category>payments</category>
      <category>backend</category>
      <category>python</category>
      <category>database</category>
    </item>
    <item>
      <title>Idempotent Payment Processing: Handling Duplicate Webhooks, Retries and Race Conditions</title>
      <dc:creator>Gokul Krishna</dc:creator>
      <pubDate>Wed, 21 Oct 2026 08:58:42 +0000</pubDate>
      <link>https://dev.to/gokul_krishna_5cb68b2138c/idempotent-payment-processing-handling-duplicate-webhooks-retries-and-race-conditions-4ng2</link>
      <guid>https://dev.to/gokul_krishna_5cb68b2138c/idempotent-payment-processing-handling-duplicate-webhooks-retries-and-race-conditions-4ng2</guid>
      <description>&lt;p&gt;Payment systems fail in a particular way. The money moves correctly, but your application credits the customer twice, ships an order twice, or marks a paid order as failed. These bugs usually come from three sources: duplicate webhooks, retried API calls and concurrent code paths racing on the same order.&lt;/p&gt;

&lt;p&gt;This article covers the patterns that prevent them. They apply to any gateway. Razorpay's documented behaviour is used as the concrete example.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why duplicates are normal, not exceptional
&lt;/h2&gt;

&lt;p&gt;Webhook delivery is at least once. Razorpay's &lt;a href="https://razorpay.com/docs/webhooks/best-practices/" rel="noopener noreferrer"&gt;webhook best practices&lt;/a&gt; are explicit about what that means in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The same event can be delivered more than once, and this is expected.&lt;/li&gt;
&lt;li&gt;Any non-2xx response is treated as a failure and retried.&lt;/li&gt;
&lt;li&gt;If your server does not respond within 5 seconds, the delivery is marked as a timeout and sent again, even if you actually processed it.&lt;/li&gt;
&lt;li&gt;Retries follow exponential backoff for 24 hours.&lt;/li&gt;
&lt;li&gt;Events may arrive out of order.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So your handler must produce the same end state whether it sees an event once, three times, or after a later event has already arrived.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: Deduplicate by event ID
&lt;/h2&gt;

&lt;p&gt;Most gateways attach a unique identifier to each event. Razorpay sends it in the &lt;code&gt;x-razorpay-event-id&lt;/code&gt; header. Record it with a unique constraint, inside the same transaction as the work it triggers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;processed_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;event_id&lt;/span&gt;    &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;event_type&lt;/span&gt;  &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;received_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&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;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;handleEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&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="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tx&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="nx"&gt;t&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="nx"&gt;inserted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`INSERT INTO processed_events (event_id, event_type)
       VALUES ($1, $2) ON CONFLICT DO NOTHING`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inserted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rowCount&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&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="c1"&gt;// already handled&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;applyEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&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;Do not check with a SELECT and then insert. Two deliveries arriving together can both pass the SELECT. Let the database constraint decide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: Make the business effect idempotent
&lt;/h2&gt;

&lt;p&gt;Event-ID deduplication is necessary but not sufficient. Different events can describe the same business fact. For example, Razorpay fires both &lt;code&gt;payment.captured&lt;/code&gt; and &lt;code&gt;order.paid&lt;/code&gt; when a payment on an order is captured. If both handlers fulfil the order, you ship twice.&lt;/p&gt;

&lt;p&gt;Key every side effect by the business entity, not the event:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One fulfilment per order: a unique constraint on &lt;code&gt;fulfilments(order_id)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;One ledger credit per payment: a unique constraint on &lt;code&gt;ledger_entries(payment_id, entry_type)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;One refund per refund request: your own refund request ID, stored before calling the gateway.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With these constraints in place, a duplicate that slips through still cannot create a second row.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: Idempotent outbound calls
&lt;/h2&gt;

&lt;p&gt;Retries also happen in the other direction. Your server calls the gateway, the network times out, and you do not know whether the request succeeded.&lt;/p&gt;

&lt;p&gt;Some APIs accept an idempotency key header for this. Where an API does not, look for a field that serves the same purpose. Razorpay's &lt;a href="https://razorpay.com/docs/api/refunds/create-normal/" rel="noopener noreferrer"&gt;create refund API&lt;/a&gt; accepts a &lt;code&gt;receipt&lt;/code&gt; value, and the documented errors include "Duplicate receipt found for this refund request", so passing your own refund request ID as the receipt protects against double refunds on retry.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount_paise&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.razorpay.com/v1/payments/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;payment_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/refund&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;KEY_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEY_SECRET&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;amount_paise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;receipt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&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="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# retry with the same request_id on timeout
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For order creation, store the gateway order ID against your internal order the first time, and reuse it on retry rather than creating a new one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Order and payment state machines
&lt;/h2&gt;

&lt;p&gt;Orders and payments are separate entities with separate lifecycles. Conflating them is a common source of state bugs.&lt;/p&gt;

&lt;p&gt;Razorpay's &lt;a href="https://razorpay.com/docs/payments/orders/" rel="noopener noreferrer"&gt;orders documentation&lt;/a&gt; describes order states of created, attempted and paid. An order can have several payment attempts, but only one successful payment. Payments move through created, authorized, captured, refunded and failed.&lt;/p&gt;

&lt;p&gt;Model your own order as a state machine with allowed transitions, and apply updates only when they move forward:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;NEXT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;          &lt;span class="c1"&gt;// late success is possible&lt;/span&gt;
  &lt;span class="na"&gt;paid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;refunded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;partially_refunded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;partially_refunded&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;refunded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;canMove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&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="nx"&gt;NEXT&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;to&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;Note the transition from failed to paid. Razorpay documents &lt;a href="https://razorpay.com/docs/payments/payments/late-authorisation/" rel="noopener noreferrer"&gt;late authorisation&lt;/a&gt;: if the bank does not respond in time, a payment can be marked failed and later move to authorized when the bank confirms. Razorpay polls the bank for up to 3 days, and uncaptured payments where money was debited are refunded automatically. If your state machine treats failed as terminal, you can end up with a customer who paid but never received the order.&lt;/p&gt;

&lt;p&gt;Apply transitions with a conditional update so the database enforces them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'paid'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payment_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'failed'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If zero rows are updated, someone else already moved the order. That is not an error; log it and return.&lt;/p&gt;

&lt;h2&gt;
  
  
  The callback versus webhook race
&lt;/h2&gt;

&lt;p&gt;On a successful checkout, two things usually happen almost at the same time. The browser returns to your app with the payment details, and the gateway sends a webhook to your server. Either can arrive first. Sometimes the browser never returns at all, because the user closed the tab.&lt;/p&gt;

&lt;p&gt;The fix is to route both paths through one function that is safe to run twice:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Verify the input. For Razorpay's checkout response, that means checking the payment signature; for the webhook, checking the webhook signature.&lt;/li&gt;
&lt;li&gt;Lock or conditionally update the order row.&lt;/li&gt;
&lt;li&gt;Apply the transition and side effects only if the order was not already paid.&lt;/li&gt;
&lt;li&gt;Return the current state to the caller.
&lt;/li&gt;
&lt;/ol&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;markPaid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;paymentId&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="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tx&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="nx"&gt;t&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="nx"&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="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT * FROM orders WHERE id = $1 FOR UPDATE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;orderId&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="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;none&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;UPDATE orders SET status='paid', payment_id=$2 WHERE id=$1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;paymentId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;none&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INSERT INTO fulfilments (order_id) VALUES ($1) ON CONFLICT DO NOTHING&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;orderId&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="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paid&lt;/span&gt;&lt;span class="dl"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Treat the browser callback as a user experience signal and the webhook (or a server-side fetch of the payment) as the authoritative one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Respond fast, process later
&lt;/h2&gt;

&lt;p&gt;Given the 5 second timeout, do minimal work in the webhook request: verify, record the event, enqueue, return 200. Run fulfilment, emails and ledger writes in a worker. The worker reuses the same idempotent functions, so a retried job is harmless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add a reconciliation sweep
&lt;/h2&gt;

&lt;p&gt;Even with all of this, events can be missed, for example if your endpoint was down beyond the retry window. Schedule a job that finds orders stuck in pending or failed for longer than expected, fetches their payments from the gateway API, and feeds the result into the same &lt;code&gt;markPaid&lt;/code&gt; function.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Deduplicate events with a database constraint, not a read-then-write check.&lt;/li&gt;
&lt;li&gt;Key side effects by business IDs: order, payment, refund request.&lt;/li&gt;
&lt;li&gt;Use idempotency keys or equivalent fields on outbound calls.&lt;/li&gt;
&lt;li&gt;Model orders and payments separately, and allow late success.&lt;/li&gt;
&lt;li&gt;Funnel callback, webhook and sweep through one idempotent function.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Disclosure: The author works with the Razorpay team on content.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>backend</category>
      <category>payments</category>
      <category>database</category>
    </item>
  </channel>
</rss>
