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.
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.
| Protection | Key and enforcement | Prevents | Why it is still insufficient alone |
|---|---|---|---|
| Event deduplication | A unique constraint on X-Event-Id | Retrying or replaying the same StableOps event from recording its state transition twice | Two distinct valid events can still refer to the same internal order |
| External fulfillment idempotency | Your internal business order ID, enforced by the fulfillment system or a durable merchant-side record | Shipping, granting an entitlement, or posting a ledger entry twice for one order | It 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 IDHere 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 case | What happens | Safe recovery behavior |
|---|---|---|
| Process crashes before the transaction commits | No event record, state transition, or finalized-fulfillment outbox record is durable | The delivery retry runs the transaction again and commits it once |
| Transaction commits, then the response times out before reaching StableOps | StableOps can retry even though the event is already durable | The unique X-Event-Id conflict is treated as already accepted; return a 2xx without another state change or outbox record |
| Worker crashes during fulfillment | A finalized-fulfillment outbox record may remain incomplete after its lease or attempt | Retry or reconcile that record; the order-ID idempotency guard makes the external action safe |
| The same event is replayed | A replay can produce a brand-new StableOps delivery audit row for the original event | Verify it again and let the event-ID uniqueness rule produce the no-op path |
| Different events arrive concurrently for the same order | Each event has a different ID, so event dedupe permits both | Serialize 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-Signatureagainst 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-Idwith a database unique constraint in the same transaction as business-state changes; only a first, verifiedpayment.finalizedmay write an irreversible-fulfillment outbox record. - Return any
2xxonly 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.canceledas 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.