DEV Community

nishant pokharel
nishant pokharel

Posted on Originally published at nishantpokharel.com.np AI-assisted

Payment Integration Mistakes and How to Fix Them

I've integrated four payment processors in production: eSewa, IMEPay, Dodo Payments and Paddle. eSewa and IMEPay are redirect-based local gateways. Dodo and Paddle are international providers that talk to you through webhooks. The APIs are nothing alike, but the bugs are the same ones every time.

This is the list I'd hand to anyone starting a payments integration: what goes wrong, why, and what I do about it now. It's built from my own work and from the providers' docs. The recommendations at the end are my opinions, and providers differ, so check anything important against the docs for the one you're using.

The short version

  • Don't mark an order paid from the browser redirect. Confirm it on your server.
  • Verify webhook signatures on the raw body, and make the handler safe to run twice.
  • Record the payment and grant access in one transaction, so a retry can finish a half-done job.
  • Check amount and currency, store money exactly, and reconcile against the provider regularly.

The mistakes

Trusting the success page as proof of payment

After paying, many gateways send the customer back to your site with something like /success?status=ok in the URL. It's tempting to mark the order as paid right there.

Don't. The customer controls that URL. Anyone can type it, edit it, or open it again later.

Diagram comparing a wrong flow, where the browser success redirect marks the order paid, with a right flow, where the server verifies with the provider status API or a signed webhook first.

The redirect is a hint to go and check, not proof.

Treat the redirect as a signal to go and check. Your server asks the provider "was this order really paid?" through their verification or status API, or waits for a verified webhook. Only then does the order become paid.

Skipping webhook signature verification

A webhook is just an HTTP request to a public URL on your server. If you don't check who sent it, anyone can POST "payment succeeded" to it.

Providers solve this by signing every webhook, and you check the signature before trusting anything inside.

The catch is that the signature is calculated from the exact bytes the provider sent, called the raw body. If your framework turns the JSON into an object first and you verify against that object, the bytes no longer match and verification fails. Many people then "fix" it by switching verification off. Keep the raw body instead. In NestJS that's rawBody: true, and it's worth checking that the route you care about really receives it untouched.

Two more things:

  • Use the provider's official SDK or method. Paddle's signature docs say not to change the raw body at all, not even whitespace, and their SDK helpers reject requests whose timestamp is outside a tolerance window (the docs list five seconds as the default, so check the version you use). Each provider sets its own rules, and many follow the Standard Webhooks spec.
  • A valid signature only proves who sent the request. It doesn't stop the same webhook being processed twice. That's the next point.

Not handling duplicate webhooks (idempotency)

Providers send each webhook at least once. If your server is slow or returns an error, they send it again. Sometimes you get duplicates even when nothing failed.

So your handler has to be safe to run twice. If "payment succeeded" adds credits or sends a receipt, running it twice means double credits and two receipts.

Two different problems need two different checks:

  • The same webhook delivered twice. Remember each event id in a table with a unique rule, and skip events you've already seen.
  • The same payment recorded twice. Put a unique rule on the payment id.

Don't use one key for both. One payment creates several events over time: paid, refunded, disputed. If you skip events by payment id, you'll skip the refund, because you've "already seen" that payment.

// Payment-level: only the first "succeeded" delivery inserts a row.
await db.query(
  `INSERT INTO payments (provider_payment_id, order_id, amount_minor, currency)
   VALUES ($1, $2, $3, $4)
   ON CONFLICT (provider_payment_id) DO NOTHING`,
  [paymentId, orderId, amountMinor, currency],
);
Enter fullscreen mode Exit fullscreen mode

Why a database rule and not a quick "does it exist?" check in code? Because two copies of the same webhook can arrive at the same moment. Both check, both see "not recorded", both insert. A unique constraint lets only one of them win (see PostgreSQL INSERT ... ON CONFLICT).

This snippet only prevents duplicate payment records. It is not a complete handler, and it deliberately doesn't return early when the payment already exists. A duplicate delivery may be the retry that finishes work an earlier attempt left undone. The next point shows how to do that safely.

Recording a payment and granting access in separate steps

Say your handler does two things: save the payment, then give the customer access. If the server crashes between the two, you have a payment with no access.

It gets worse on retry. The provider sends the webhook again, your code sees "payment already saved", skips it, and the customer never gets access. They paid, and nothing ever fixes it.

The fix is to do both steps in one database transaction. A transaction is all-or-nothing: either both happen or neither does, so a retry starts clean.

await db.transaction(async (tx) => {
  // 1. Record the payment. A duplicate delivery inserts nothing.
  await tx.query(
    `INSERT INTO payments (provider_payment_id, order_id, amount_minor, currency)
     VALUES ($1, $2, $3, $4)
     ON CONFLICT (provider_payment_id) DO NOTHING`,
    [paymentId, orderId, amountMinor, currency],
  );

  // 2. Grant access. Also idempotent, so a duplicate delivery can run it again
  //    and finish a job an earlier attempt left undone.
  await tx.query(
    `INSERT INTO entitlements (order_id, user_id, plan)
     VALUES ($1, $2, $3)
     ON CONFLICT (order_id) DO NOTHING`,
    [orderId, userId, plan],
  );
});
Enter fullscreen mode Exit fullscreen mode

Both statements run on every delivery. If everything was already done, they change nothing. If an earlier attempt failed halfway, this one finishes it.

This is a simplified example. It assumes one entitlement per order, and that the order, user and plan were already validated against your stored order. A real implementation should take those values from the stored order, and decide how revoked, expired or wrong entitlements get repaired.

A transaction only covers your own database. It can't cover sending an email or calling another service. For those, save a note inside the same transaction ("send receipt for payment 123") and let a background worker pick it up, retrying if needed. This is called a transactional outbox.

Assuming webhook events arrive in order

Webhooks don't always show up in the order things happened. Paddle says so in its webhook delivery docs: it can't guarantee the order of delivery.

Here's what that looks like. A customer cancels, and your server receives these two events:

  1. subscription.canceled (happened at 10:00:05)
  2. subscription.updated (happened at 10:00:01, but arrived second)

If you just apply whatever arrives last, the old "updated" event overwrites the newer "canceled" one, and your database shows an active subscription that isn't.

What I do about it:

  • Keep the event time and compare it. Paddle recommends storing and checking the occurred_at date before making changes. If an event is older than what you already have, ignore it.
  • Set values, don't add to them. "Status is now canceled" is safe to apply twice. "Add 10 credits" is not.
  • When in doubt, ask the provider. If an event is incomplete, looks stale, or refers to something you don't have yet, fetch the current resource from the provider's API instead of trusting the payload.
  • Don't use retries to fix ordering. Returning an error so the provider tries again is fine when something is simply missing for now, like "that record isn't there yet". But a retry can still arrive out of order, so it isn't a real fix.

Handling money wrong

Two small things that cause big headaches.

Validate the amount, currency and order for every provider. A valid signature or a "paid" status proves who sent the message. It doesn't prove the payment belongs to the right order or has the right price. So compare the amount, currency, payment status and order against what you expected in your own database, and never use an amount that came from the browser. If anything doesn't match, flag the order for review and don't give access. This matters most with redirect-based gateways like eSewa and IMEPay, because the browser is part of the flow. Webhook-based providers like Paddle and Dodo Payments depend less on browser data, but the same check still applies.

Don't use floats. 0.1 + 0.2 gives 0.30000000000000004, which in money code turns into cents that never add up. Store money as a whole number of the smallest unit plus a currency code: Rs 10.50 is 1050 paisa in NPR. Providers format amounts differently ("10.50" or 1050), and not every currency has two decimal places, so convert once, where the amount enters your system, and only format it nicely for display.

Doing slow work inside the webhook handler

If your handler sends emails, builds invoices or calls other APIs before responding, it gets slow. The provider gives up waiting and sends the webhook again, and now the slow work happens twice. Paddle, for example, expects a 200 within five seconds and otherwise retries, up to 60 attempts over three days on live accounts.

So do the minimum before responding: verify, save, reply 200. Do the slow stuff afterwards in a background worker.

One catch: don't reply 200 until the event and the list of pending work are safely saved. If you reply first and crash before saving, the provider thinks it was delivered and never sends it again, and the job is lost.

No plan for unknown payment outcomes

You ask the provider to create a payment and your request times out. Was it created or not? You don't know, and if you just retry, you might create a second payment.

There are two ways out:

  • Send an idempotency key with the request, if the provider supports it (not all do). The provider then treats the repeat as the same request.
  • Otherwise, ask for the status first and only retry if nothing was created.

Treat "I don't know" as its own state in your system. It isn't the same as "failed".

What a safe webhook handler looks like

Put the fixes together and the handler is a short, boring pipeline: verify, deduplicate, record and grant in one transaction, acknowledge, and push everything slow onto a queue through an outbox.

Pipeline diagram: provider sends a webhook; verify the signature on the raw body; deduplicate on the provider payment id; record and grant in one transaction; return 200 OK; slow work like emails goes to a queue.

Verify, deduplicate, record and grant atomically, acknowledge fast, queue the rest.

The edge cases that actually happen

  • The customer pays and closes the tab. They never reach your success page. You only find out from a webhook or a status check.
  • Payments that stay pending. Some settle minutes or hours later. A reconciliation job should ask the provider for the current status of orders stuck in pending and update them from that, using your own expiry rules. Don't close them just because time passed.
  • The same order paid twice. A double click or two open tabs can create two real payments for one order, even if you deduplicate every webhook. Fulfil the order once, record both payments, and refund the extra one.
  • Cancelled, but still has access? That's your decision, not the provider's. Pick a rule, such as "access until the period ends", and base access on the subscription's status and end date, not on whether a payment just arrived.
  • Refunds and disputes. If you only handle "payment succeeded", you can keep granting access after a refund or dispute. Decide what each event means for access, credits and subscription status, then implement those rules explicitly. A partial refund, a full refund and a chargeback don't have to mean the same thing.
  • Test and live mixed up. Use separate keys, webhook secrets and endpoints for each environment. One wrong variable and live payments go nowhere.
  • Dropped events. An event for a product or user you don't recognise is real money you have no record of. Alert on it and keep the payload so you can replay it after fixing the mapping.

My personal recommendations

Put one layer between your app and every provider. Wrap each provider in a small adapter that turns its events into one simple shape you control, like payment_succeeded, refunded or subscription_cancelled. The rest of your app only knows that shape, never a provider's SDK. Adding a new provider then means writing one adapter, not rewriting your app.

Architecture diagram: eSewa, IMEPay, Dodo Payments and Paddle connect through thin adapters to one payment event log; access and credits are decided from current payment state; a reconciliation job compares your records with each provider.

Many providers, one event log, one place where access is decided.

Keep a log of payment events, next to your current payment records. It's a table where you only ever add rows: provider ids, event type, amount, currency, and the original webhook payload. Use the current payment state to make decisions in your app, and keep the event history for reconciliation and debugging. A payment can be authorized, captured, refunded and disputed, so a single row per payment can't tell the whole story. When something looks wrong three weeks later, you'll want the original request, not your interpretation of it.

Add a safety net. Things will still slip through sometimes, so build three small habits:

  • Compare your records with the provider's on a schedule. This is called reconciliation. Webhooks can get lost, and this catches any payment you missed. Once a day is a fine start.
  • Alert yourself about anything odd. An unknown product, a wrong amount or a failed signature should reach you as a message, not sit unnoticed in a log file.
  • Test with real examples. Save real webhooks from the provider and replay them in your tests, twice and at the same time. You'll catch most of the bugs in this post before your customers do.

Read what each provider says about failures. Most people only read the part about how to take a payment. Also read what happens when things go wrong: how often the provider retries, whether events can arrive out of order, and how long a webhook stays valid. Every provider does this a little differently. Good places to start are the Paddle webhooks guide, the Dodo Payments docs and the eSewa developer docs.

Top comments (1)

Collapse
 
challan116ux profile image
challan116-ux •

The duplicate-charge failure mode is the one that teaches you reconciliation is not bookkeeping, it is part of the integration. A webhook retry plus a redirect callback can both be telling the truth about the same payment, so the system needs one place that decides what 'paid' means and lets every other signal defer to it. Teams that build that decision point early argue with their gateway a lot less later.