Backend solution (c) shipped in nick/backend@bdbcc32 — endpoint now reuses existing pending Payments instead of colliding on the unique index. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
7.1 KiB
Handover — Request Network Intent: Duplicate Key Bug
Date: 2026-05-27
Endpoint: POST https://dev.amn.gg/api/payment/request-network/intents
Severity: Blocks checkout retry flow on Request Network payments
Status: Reproducible in production (dev.amn.gg), root cause identified, three remediation paths proposed
1. Symptom
When a buyer attempts to create a Request Network payment intent from the checkout step 2 page, the backend returns:
{
"success": false,
"error": "E11000 duplicate key error collection: marketplace.payments index: uniq_pending_request_network_by_buyer_session dup key: { buyerId: ObjectId('68e3a21fbc79e4364c20a07e'), purchaseRequestId: \"template-checkout-1779856632092\", provider: \"request.network\", direction: \"in\" }"
}
The raw MongoDB error is being surfaced to the client, which is a secondary issue (information leak + ugly UX).
2. Environment (verified correct)
The infrastructure side is not the cause. All env vars and dashboard config are aligned:
REQUEST_NETWORK_API_KEY— matches the dashboard "amn" Client ID, status Active.REQUEST_NETWORK_MERCHANT_REFERENCE— matches dashboard destination wallet (0x05E2…573e) on BNB Chain, USDC token slug.REQUEST_NETWORK_ORIGIN=https://dev.amn.gg— present in the Client ID's Allowed Domains (along withhttps://amn.gg).- Webhook endpoint
https://dev.amn.gg/api/payment/request-network/webhookis registered and Active in the dashboard. REQUEST_NETWORK_ENABLED=true,PAYMENT_PROVIDER_MODE=live.
The Request Network API was never reached on this failure — the error happens inside our backend before any outbound call.
3. Root cause
The payments collection has a unique (partial) index:
uniq_pending_request_network_by_buyer_session
keys: { buyerId: 1, purchaseRequestId: 1, provider: 1, direction: 1 }
The frontend submits a purchaseRequestId of the form template-checkout-<timestamp>. In the failing request:
purchaseRequestId: "template-checkout-1779856632092"
buyerId: 68e3a21fbc79e4364c20a07e
provider: request.network
direction: in
A previous attempt — almost certainly a retry from the same page session — already inserted a Payment document with this exact key tuple and left it in a pending state. The unique index correctly rejects the second insert.
This is a logic bug in how the intent endpoint and the frontend handle retries, not a database bug. The index is doing exactly what it should: preventing duplicate pending intents for the same checkout session.
Why it triggers in practice
purchaseRequestIdis generated client-side from a timestamp and persisted in component/page state, so it does not rotate on retry.- If the first POST creates the Payment doc but the client then errors (network blip, validation issue elsewhere, double-click), the second POST collides.
- The backend treats the endpoint as create-only rather than idempotent, so it tries
insertOneevery time.
4. Reproduction
- Sign in as
buyer@marketplace.com. - Open
https://dev.amn.gg/dashboard/shops/checkout/?step=2for a template checkout. - Submit the intent successfully (or simulate a half-complete request that creates the Payment doc).
- Submit again from the same page state without regenerating
purchaseRequestId. - Observe the E11000 response.
Exact payload that reproduces is captured in the original ticket (amount: 12, token: "USDT", network: "bsc", sellerId: "6918535be9301e0e4358d83e").
5. Solutions
Three layered fixes. (c) is implemented as of 2026-05-27 in backend/src/services/payment/requestNetwork/{requestNetworkPayInService,requestNetworkRoutes}.ts. Apply (a) once to clear the existing stuck doc. (b) is a frontend hygiene improvement worth keeping on the backlog but is no longer required to unblock checkouts.
a) Hot unblock — clear the stale pending document
Run in the Mongo shell against the marketplace database:
db.payments.deleteOne({
buyerId: ObjectId("68e3a21fbc79e4364c20a07e"),
purchaseRequestId: "template-checkout-1779856632092",
provider: "request.network",
direction: "in",
status: { $in: ["pending", "initiated", "awaiting_payment"] }
});
Then retry the checkout. Use this only for the specific buyer/session being unblocked — do not broad-delete pending Payments.
b) Frontend — rotate purchaseRequestId on every retry
Locate the checkout step 2 component that builds the template-checkout-<timestamp> id. Today this value is computed once and reused across retries. Change it so:
- A fresh id is generated every time the user lands on (or re-enters) step 2.
- A fresh id is generated when the user clicks "Pay" after a previous failure — i.e. tie generation to the click handler, not to mount, OR clear the cached value on any error response.
- Prefer a UUID/ULID over a timestamp to make the intent collision-proof even across rapid clicks.
This eliminates the collision from the client side and is the minimum fix.
c) Backend — make /intents idempotent
The endpoint is semantically an intent: the same (buyer, purchaseRequest, provider, direction) tuple should always resolve to the same Payment document. Change the controller for POST /api/payment/request-network/intents to:
- Look up an existing Payment matching
{ buyerId, purchaseRequestId, provider: "request.network", direction: "in" }in any non-terminal status (pending,initiated,awaiting_payment). - If found, return that Payment (and its Request Network handoff data) with HTTP 200 — do not insert.
- If not found, create the new Payment as today.
- Wrap the insert in a try/catch on
E11000; on collision, re-read and return the existing doc (handles the race between two concurrent requests).
This is the correct long-term shape and also defends against double-clicks, browser back/forward, and React strict-mode double-invocations.
Additionally:
- Stop returning raw Mongo error strings to the client. Map
E11000on this collection to an HTTP 409 with a sanitized body like{ success: false, code: "INTENT_ALREADY_EXISTS" }. - Log the raw error server-side only.
6. Out of scope (but worth noting)
- The webhook Signing Secret in the dashboard shows
Unavailablefor the active webhook.REQUEST_NETWORK_WEBHOOK_SECRETis set in env, but verify the value matches what the dashboard issued at webhook creation — if not, regenerate and update env. This will bite the next time a payment actually clears. - The
amount: 12is sent asUSDTin the payload, butREQUEST_NETWORK_PAYMENT_CURRENCY=USDCand the merchant reference's token slug is the BSC USDC contract. Confirm whether the frontend should be sendingUSDCor whether the backend is supposed to normalize.
7. Suggested ownership
- (a) — Ops / whoever has Mongo access. One-shot.
- (b) — Frontend dev owning
dashboard/shops/checkout. - (c) — Backend dev owning
payment/request-networkcontrollers and thePaymentmodel. This should land as a single PR with a regression test that fires two identical intent POSTs and asserts the second returns 200 with the same payment id.