Back to Blog
Engineering
2026-07-137 min readBy StableOps

Stablecoin Payment Webhooks: How to Prevent Duplicate Fulfillment

Build reliable crypto payment webhook handling with raw-body signature verification, event deduplication, idempotent fulfillment, and replay-safe recovery.

Stablecoin payments
Webhooks
Idempotency

A reliable crypto payment webhook integration assumes that the same event can arrive more than once. At-least-once delivery is a normal reliability contract: a timeout can cause a retry, and an operator may manually replay a delivery while recovering from an incident. Your endpoint must therefore make duplicate delivery harmless instead of treating it as an exceptional case.

The durable pattern is straightforward: authenticate the exact request bytes, atomically record every accepted event and its state transition, acknowledge quickly, and let a recoverable worker perform irreversible fulfillment only for a finalized payment. This preserves a clear boundary between StableOps event delivery and your application's business side effects.

Crypto payment webhook: two separate protections against duplicate fulfillment

It is tempting to use one database uniqueness rule for everything. That leaves a gap. Event deduplication protects the delivery stream, while fulfillment idempotency protects the real-world side effect.

ProtectionKey and enforcementPreventsWhy it is still insufficient alone
Event deduplicationA unique constraint on X-Event-IdRetrying or replaying the same StableOps event from recording its state transition twiceTwo distinct valid events can still refer to the same internal order
External fulfillment idempotencyYour internal business order ID, enforced by the fulfillment system or a durable merchant-side recordShipping, granting an entitlement, or posting a ledger entry twice for one orderIt does not preserve a complete, auditable record of each received event

You need both. The first says, “have we already accepted this event?” The second says, “has this order already received this irreversible side effect?” For broader request-level patterns, see idempotency.

Keep the webhook handler small and durable

The endpoint should complete its database work before returning success, but it should not wait for shipping, provisioning, or another remote side effect.

Receive StableOps delivery
          |
          v
Read raw body
          |
          v
Verify X-Product-Signature
          |
          v
In one transaction: persist unique event ID + business state; only first verified payment.finalized writes irreversible-fulfillment outbox record
          |
          v
Return 2xx
          |
          v
After commit, dispatcher/worker claims finalized-fulfillment record and fulfills idempotently by internal order ID

Here is a short Next.js Route Handler example. recordWebhookEventAndWriteOutbox is deliberately a merchant-owned persistence function, not a StableOps API call. Within one transaction, it uses a unique event-ID constraint and INSERT ... ON CONFLICT DO NOTHING (or equivalent) to determine whether this is the first event. Every first, verified event persists its event record and applicable business-state transition. Only a first, verified, deduplicated payment.finalized also writes a durable outbox record for irreversible fulfillment; payment.detected, payment.confirmed, and other events update state or, where appropriate, record reversible work. A duplicate X-Event-Id is a no-op, after which the handler still returns 2xx. After commit, a retryable dispatcher or worker claims only finalized-fulfillment records and performs the external side effect.

import { EVENT_ID_HEADER, SIGNATURE_HEADER, verifySignature } from '@stableops/api-sdk/webhooks'

export async function POST(req: Request) {
  const rawBody = await req.text()
  const secrets = [
    process.env.STABLEOPS_WEBHOOK_SECRET,
    process.env.STABLEOPS_WEBHOOK_SECRET_PREVIOUS,
  ].filter((secret): secret is string => Boolean(secret))
  const verification = verifySignature({
    secrets,
    header: req.headers.get(SIGNATURE_HEADER) ?? undefined,
    rawBody,
  })

  if (!verification.ok) {
    return new Response('invalid signature', { status: 400 })
  }

  const eventId = req.headers.get(EVENT_ID_HEADER)
  if (!eventId) {
    return new Response('missing event id', { status: 400 })
  }

  const event = JSON.parse(rawBody)
  // Merchant persistence: first events write state; only payment.finalized
  // writes an irreversible-fulfillment outbox record. Duplicates are no-ops.
  await recordWebhookEventAndWriteOutbox({ eventId, event })

  return new Response(null, { status: 204 })
}

Read the body exactly once with req.text() and verify it before parsing it. Parsing and re-serializing JSON can change the signed representation. During the 24-hour secret-rotation overlap window, pass both the current and previous secrets as shown so either valid signature verifies. The webhook verifier guide documents the verifier and signature behavior, while the webhooks guide covers endpoint operations.

Design for crashes, retries, and replays

An acknowledged request is not the same thing as completed fulfillment. Model each boundary so that retrying produces a safe result.

Failure or recovery caseWhat happensSafe recovery behavior
Process crashes before the transaction commitsNo event record, state transition, or finalized-fulfillment outbox record is durableThe delivery retry runs the transaction again and commits it once
Transaction commits, then the response times out before reaching StableOpsStableOps can retry even though the event is already durableThe unique X-Event-Id conflict is treated as already accepted; return a 2xx without another state change or outbox record
Worker crashes during fulfillmentA finalized-fulfillment outbox record may remain incomplete after its lease or attemptRetry or reconcile that record; the order-ID idempotency guard makes the external action safe
The same event is replayedA replay can produce a brand-new StableOps delivery audit row for the original eventVerify it again and let the event-ID uniqueness rule produce the no-op path
Different events arrive concurrently for the same orderEach event has a different ID, so event dedupe permits bothSerialize or conditionally update the order and make fulfillment idempotent on the internal order ID

StableOps treats any 2xx as a successful delivery. A non-2xx response, a network error, or no response within 10 seconds is a failure that is retried; failed deliveries ultimately move to the DLQ. Do not return success before durable acceptance, and do not intentionally hold the request open while performing slow work. Replaying from the dashboard or replay APIs creates a new delivery audit row, so your event-level guard remains essential.

Treat payment events as a state machine

Only a verified and deduplicated payment.finalized event should trigger irreversible fulfillment and write its durable fulfillment outbox record. payment.detected and payment.confirmed are useful for payment progress, notifications, or deliberately reversible actions; they are not final proof and must not cause a worker to perform irreversible fulfillment. Handle payment.reverted to reverse or stop optimistic work, and handle payment.expired to close the unpaid attempt and guide the customer to start again. payment_order.canceled is a terminal state for an order canceled before a transfer is detected; if your webhook stream is the source of truth, mark the order canceled and release its reserved resources.

A browser return URL, a transaction hash, and an unverified webhook are all useful signals for UX or investigation, but none is a final credential for fulfillment. The confirmation and finality model is chain- and risk-dependent; do not substitute a fixed confirmation count for a verified final event. See the confirmation model and Stablecoin Payment Confirmations for the lifecycle behind that decision.

Production checklist

  • Verify X-Product-Signature against the raw request body before JSON parsing, and keep webhook secrets server-side.
  • During the 24-hour secret-rotation overlap, verify with both the current and previous secret, then remove the previous secret after the window closes.
  • Require and persist X-Event-Id with a database unique constraint in the same transaction as business-state changes; only a first, verified payment.finalized may write an irreversible-fulfillment outbox record.
  • Return any 2xx only after durable acceptance; use a worker for slow or external operations.
  • Make every irreversible fulfillment action idempotent by your internal business order ID, not only by the event ID.
  • Persist each payment event according to the state machine; when webhook state is authoritative, mark payment_order.canceled as canceled and release reserved resources.
  • Monitor failed deliveries and the DLQ, then use replay after the endpoint or downstream dependency is repaired.
  • Retain event IDs, delivery IDs, payment-order references, internal order IDs, and transaction hashes for reconciliation.
  • Exercise duplicate delivery, response-timeout, transaction-crash, worker-crash, replay, and concurrent-order-event scenarios before launch.

FAQ

Does a 204 response count as webhook success?

Yes. StableOps treats any 2xx response as successful. A 204 is useful when your handler has durably accepted the event and has no response body to return.

Can I fulfill directly inside the webhook request?

You can, but it expands the timeout and crash window around an external side effect. A transactionally recorded outbox plus a worker is usually safer: acknowledge after durable acceptance, then retry only finalized-payment fulfillment independently with an order-ID idempotency guard.

If a replay gets a new delivery row, will it fulfill twice?

It should not. The new delivery is an audit record, while the original event ID remains the deduplication key. Even if two valid events target one order, fulfillment keyed by your internal order ID prevents the external side effect from being applied twice.

Build the recovery path before you need it

Start with the webhooks guide, add the SDK verifier, and test a duplicate delivery plus a worker restart in Sandbox. Once your handler can accept, recover, and reconcile those cases, your stablecoin payment flow is ready to fulfill safely at production scale.

Related articles

A practical model for confirmations, reorgs, finality, and merchant fulfillment.

Compare custodial gateways, direct wallet transfers, and non-custodial payment infrastructure for reliable stablecoin collection.

Accept USDC payments into merchant-controlled wallets with payment orders, chain monitoring, finality, and reliable webhook fulfillment.