DEV Community

poetic
poetic

Posted on

Webhook idempotency for stablecoin checkout

A stablecoin checkout is usually built around one important event: a transaction was detected and the merchant order can move forward.

That sounds simple until retries, duplicate callbacks, delayed confirmations, and partial payments show up.

If your webhook handler is not idempotent, the same blockchain event can accidentally create two business-side effects:

  • two paid-order transitions
  • two emails
  • two license activations
  • two shipment requests
  • two accounting records

The blockchain transaction is final. Your application state still needs protection.

A practical webhook rule

Treat every payment event as an input to a state machine, not as a command to blindly mark an order as paid.

For each incoming webhook, I like to check four things before changing merchant state:

  1. Is the event signature valid?
  2. Have we already processed this event id or transaction hash?
  3. Is the target payment intent still in a state that can change?
  4. Does the observed payment match the expected amount, token, chain, and expiry window?

Only after those checks should the order move to a new state.

Example shape

A minimal handler can look like this:

async function handlePaymentWebhook(event) {
  assertValidSignature(event)

  const alreadyProcessed = await db.webhookEvents.find(event.id)
  if (alreadyProcessed) return { ok: true }

  await db.transaction(async (tx) => {
    await tx.webhookEvents.insert({ id: event.id, txHash: event.txHash })

    const payment = await tx.paymentIntents.findForUpdate(event.paymentIntentId)
    if (!payment) return

    if (!["pending", "underpaid"].includes(payment.status)) return

    const nextStatus = classifyPayment(payment, event)

    if (nextStatus !== payment.status) {
      await tx.paymentIntents.update(payment.id, { status: nextStatus })
      await tx.orderEvents.insert({ orderId: payment.orderId, type: nextStatus })
    }
  })

  return { ok: true }
}
Enter fullscreen mode Exit fullscreen mode

The important part is not the exact code. It is the constraint: the event record and the order transition should be committed together.

If the process crashes halfway through, retrying the webhook should either complete the transition once or safely return without repeating side effects.

States worth modeling early

For USDT or USDC checkout, I would avoid only having pending and paid.

At minimum, model:

  • pending
  • paid
  • expired
  • underpaid
  • overpaid
  • wrong_network
  • manual_review

Those states make support and merchant dashboards much clearer. They also prevent your code from pretending every payment problem is a binary success/failure case.

Why I am thinking about this

I am building ChainPay, a stablecoin checkout for merchants, WooCommerce stores, SaaS apps, Telegram sellers, and API workflows.

The goal is not just to display a QR code. The harder part is making payment state boring and predictable for the merchant.

Docs:
https://chainpay.to/docs?utm_source=devto&utm_medium=article&utm_campaign=webhook-idempotency-2026-07

If you have implemented payment webhooks before, what is the failure case you always design for first?

Top comments (0)