End-to-end crypto pay-in via the self-hosted SHKeeper gateway at pay.amn.gg. The buyer pays in stablecoins/crypto; SHKeeper monitors the blockchain, sends webhooks back, and the backend marks the escrow funded.
Supported assets
Pulled from env: SHKEEPER_NETWORKS and SHKEEPER_ALLOWED_TOKENS (shkeeperService.ts:97-98).
Networks: bsc, ethereum (default in code).
Tokens: USDT, USDC (default). The endpoint URL is built as https://pay.amn.gg/api/v1/{NETWORK_PREFIX}-{TOKEN}/payment_request (shkeeperService.ts:413-417).
BSC → BNB-USDT, BNB-USDC (i.e. BEP-20).
Ethereum → ETH-USDT, ETH-USDC (ERC-20).
TRC-20 (USDT-TRC20) and native BTC are mentioned in the task brief but not currently wired in shkeeperService.ts — only BSC/ETH variants are produced from the code path. Verify SHKeeper-side configuration if those are required.
Actors
Buyer — pays.
Seller — passive in this flow; gets notified on success.
Frontend — checkout components under frontend/src/sections/request/components/buyer-steps/step-3-components/ and frontend/src/sections/payment/.
Backend — shkeeperService.createPayInIntent (backend/src/services/payment/shkeeper/shkeeperService.ts:48-533) and shkeeperWebhook.handleShkeeperWebhook (backend/src/services/payment/shkeeper/shkeeperWebhook.ts).
Buyer clicks "Pay" on the chosen offer (/dashboard/buyer/requests/{id} → step-3-select-and-pay).
Frontend POSTs POST /api/payment/shkeeper/create with { purchaseRequestId, sellerOfferId, amount, token?, network? }.
Backend createPayInIntent:
Validates ObjectIds (shkeeperService.ts:55-71). Special path for template checkout (string IDs starting with template-checkout-).
Duplicate-guard #1 (:75-116): if there is already an active Payment (status ∈ {pending, processing}) for the same {purchaseRequestId, sellerOfferId, buyerId}, the existing record is reused — same paymentUrl, no new wallet allocation.
Duplicate-guard #2 (template checkout) (:131-198): if a recent completed/confirmed payment exists for the same buyer + template session, reuse it; otherwise dedupe pending records within the last 5 minutes.
Sets Payment.providerPaymentId = externalId. For template checkouts, externalId = template-{ts}-{rand}; otherwise it's the Payment._id.
Wallet cache lookup (:421-450): paymentRedisService.getCachedWallet(cacheKey) — if a wallet was allocated for the same (amount, token, network, requestId) within the last 2 h, reuse it. Avoids hammering SHKeeper for the same checkout.
Call SHKeeper API (:453-475): POST https://pay.amn.gg/api/v1/{cryptoName}/payment_request with header X-Shkeeper-Api-Key. Body: { external_id, fiat: 'USD', amount: '12.34', callback_url: ${API_URL}/api/payment/shkeeper/webhook }. The HTTP call is wrapped by shkeeperFetch (shkeeperHealthCheck.ts) which trips the breaker on repeated failures.
Persist response (:484-503): updates Payment.metadata.{shkeeperInvoiceId, shkeeperData, cryptoName, walletAddress}; caches the wallet for 2 h; calls walletMonitor.addWallet(...) so the on-chain watcher can confirm independently (belt-and-braces against missed webhooks).
Emits payment-created globally via emitGlobalEvent (shkeeperService.ts:277-287) so the admin dashboard sees the new pending payment in real time.
Phase 2 — Buyer pays
Frontend renders a QR code for ${walletAddress}?amount=${amount}&token=... and shows the exchange-rate-locked USDT amount, recalculate-after timer (recalculate_after from SHKeeper, typically 15 min), and a copy-to-clipboard button.
Buyer scans with MetaMask / Trust Wallet / Binance App and sends the on-chain transfer.
SHKeeper polls the chain, detects the deposit. When confirmations reach the threshold it marks the invoice PAID (or OVERPAID if the buyer sent extra).
Phase 3 — Webhook
SHKeeper POSTs to ${API_URL}/api/payment/shkeeper/webhook with the ShkeeperWebhookPayload shape (shkeeperWebhook.ts:14-37):
Signature verification (shkeeperWebhook.ts:84-120): HMAC-SHA256 of the raw body with SHKEEPER_WEBHOOK_SECRET, header x-shkeeper-signature (also accepts x-signature, signature, x-hub-signature, x-hub-signature-256). Mismatch → 401 in production, allowed in dev. Length-mismatched signature → 401 (avoids timingSafeEqual crash).
Fallback auth (:122-141): if no signature header but env requires it, the route accepts X-Shkeeper-Api-Key matching SHKEEPER_API_KEY. Otherwise returns 202 to stop SHKeeper retries even if rejected (idempotency principle: always 2xx unless the request itself is mangled).
DB reconnect (:143-155): if Mongoose is disconnected, attempt reconnection. On failure → 202 OK to avoid retry loop, log for investigation.
Payment lookup: Payment.findOne({ providerPaymentId: payload.external_id }). If not found and the external_id looks like a template checkout, hand off to handleTemplateCheckoutWebhook (templateCheckoutWebhook.ts). Otherwise → 202 OK with a rate-limited log.
Duplicate-webhook detection (:249-296): if metadata.shkeeperStatus, balance_fiat, paid are identical to the previous webhook and less than 10 seconds have passed → return 202 (idempotent). Logged once per minute per payment.
Map SHKeeper status → internal status (:387-411):
SHKeeper
Internal status
escrowState
PAID
completed
funded
OVERPAID
completed
funded
EXPIRED, CANCELLED
failed
cancelled
PARTIAL
pending
partial
PENDING
pending
—
Extract transactionHash (:311-385) — prefers the transaction with trigger === true, then falls back to the latest, then to fetching from SHKeeper's invoice endpoint if the webhook somehow arrived without transactions.
PaymentCoordinator (:482-507) — coordinatePaymentUpdate returns false if another worker already started processing this state change, otherwise executePaymentUpdate writes the new status/escrowState/txHash atomically with metadata.
Cascade on PAID/OVERPAID (:543-714):
Load PurchaseRequest and SellerOffer for this payment.
Mark winning offer accepted: selectedOffer.status = 'accepted'; save().
Reject all other offers: SellerOffer.updateMany({ purchaseRequestId, _id: { $ne: sellerOfferId } }, { status: 'rejected' }).
Promote request: status = 'payment'; selectedOfferId = sellerOfferId.
Create direct chat (chatService.createChat, see Chat Flow).
Socket fan-out: seller-offer-update'payment-completed' to winner, 'offer-rejected' to losers (each carries offerId, reason).
Cleanup: simpleAutoWebhook.removePayment(external_id) stops the simple polling fallback.
Always respond 202 Accepted (SHKeeper retries on non-2xx). 200 would cause infinite retries because SHKeeper expects 202 per its convention.
Phase 4 — Frontend reaction
The buyer's checkout page subscribes to socket events and polls GET /api/payment/shkeeper/status/{paymentId}. When status flips to completed, the UI transitions to "Payment received" and unlocks the next buyer step (await delivery).
The seller's dashboard receives seller-offer-updatepayment-completed and surfaces the green "Order paid — start preparing" banner.
Sequence diagram
sequenceDiagram
autonumber
actor B as Buyer
participant FE as Frontend
participant BE as Backend
participant R as Redis
participant SK as SHKeeper (pay.amn.gg)
participant BC as Blockchain
participant DB as MongoDB
participant IO as Socket.IO
actor S as Seller
B->>FE: Choose offer, click "Pay"
FE->>BE: POST /api/payment/shkeeper/create
BE->>DB: dedupe / upsert Payment(status:"pending")
BE->>R: getCachedWallet(amount, token, network, requestId)
alt cache hit
R-->>BE: cached wallet
else cache miss
BE->>SK: POST /api/v1/{cryptoName}/payment_request\nX-Shkeeper-Api-Key
SK-->>BE: { id, wallet, amount, exchange_rate, ... }
BE->>R: setCachedWallet (TTL 2h)
BE->>BE: walletMonitor.addWallet (chain watcher)
end
BE->>IO: emit 'payment-created' (admin)
BE-->>FE: { paymentId, walletAddress, amount, QR-ready data }
FE-->>B: Render QR + countdown + copy address
B->>BC: Send USDT/USDC to walletAddress
BC-->>SK: deposit confirmed
SK->>BE: POST /api/payment/shkeeper/webhook\nx-shkeeper-signature
BE->>BE: HMAC verify
BE->>DB: Payment.findOne({providerPaymentId})
BE->>BE: duplicate-webhook check
BE->>BE: PaymentCoordinator.coordinate + execute
BE->>DB: Payment.status="completed"\nescrowState="funded"\nblockchain.transactionHash=...
BE->>DB: SellerOffer.status="accepted" (others "rejected")
BE->>DB: PurchaseRequest.status="payment", selectedOfferId
BE->>DB: Chat.create (buyer + winning seller)
BE->>IO: emit seller-{winner} 'payment-completed'
BE->>IO: emit seller-{loser_i} 'offer-rejected'
BE-->>SK: 202 OK
IO-->>FE: status updated
IO-->>S: dashboard updates
FE-->>B: "Payment received ✓"
payments: insert on intent creation (status: 'pending'); update on each webhook (status, escrowState, blockchain.transactionHash, metadata).
payments (unique index uniq_pending_shkeeper_by_buyer_session, see Payment.ts:181-188): partial unique on {buyerId, purchaseRequestId, provider:'shkeeper', direction:'in', status:'pending'} prevents duplicate pending pay-ins.
selleroffers: status flipped (accepted / rejected) by the webhook cascade.
purchaserequests: status → payment, selectedOfferId set.
chats: a new direct chat (or reuse) — find-or-create via ChatService.createChat.
notifications: 2–N entries depending on parties.
Socket events emitted
payment-created (global) — broadcast on intent creation.
seller-offer-update with eventType: 'payment-completed' → winning seller.
seller-offer-update with eventType: 'offer-rejected' → each losing seller.
purchase-request-update with eventType: 'status-changed' (via PurchaseRequestService) → request-{id}.
new-notification → both buyer and seller.
Side effects
Redis: walletCache:{amount}_{token}_{network}_{requestId} set for 2 hours.
Wallet monitor: walletMonitor.addWallet(addr, amount, paymentId, token, network) — the on-chain watcher polls BSC/ETH RPCs directly; if SHKeeper's webhook is lost, the monitor still flips the payment to completed. This is the redundancy mechanism noted in the comments.
simpleAutoWebhook (shkeeperSimpleAuto.ts): a poll-based fallback that asks SHKeeper for invoice status; cleaned up once a real webhook arrives.
Webhook stats: webhookStats.recordWebhook(...) updates an in-memory ring buffer surfaced via webhookStats.ts admin endpoint.
Error / edge cases
Duplicate intent submission → reuse the existing pending payment (no new wallet). UX-safe.
SHKeeper API unreachable → shkeeperFetch (with circuit breaker) throws; controller returns a demo fallback URL (shkeeperService.ts:520-532). In production this is observed as a Sentry error.
Webhook signature mismatch in prod → 401, SHKeeper retries — usually the secret has rotated; fix env and they catch up.
Webhook missing both signature and API key in prod → 202 OK (no-op) to prevent retry storm.
DB disconnected during webhook → reconnect; on failure 202 OK + log (consider DLQ).
PARTIAL payment → state held as pending/partial; further deposits to the same address are aggregated by SHKeeper and a new webhook arrives.
OVERPAID → treated as completed/funded; the overage stays with the platform unless an admin manually refunds (no automatic refund of overpayment today).
EXPIRED → failed/cancelled. Buyer can re-initiate; the duplicate-guard will create a fresh intent because the old one is no longer pending.
External_id not found → 202 with rate-limited log; common for orphaned webhooks from old tests.
Webhook arrives twice within 10 s with same data → idempotency skip → 202.
PaymentCoordinator deferral → 202 with a "coordinator skipped update" log; the in-flight worker will finish the state change.
Wallet address reuse — cached for 2 h means two parallel checkouts for the same (amount, token, network, requestId) share one address; whichever pays first wins (acceptable since the duplicate-guard reuses the same Payment doc anyway).
crypto_name mismatch — only BNB-* and ETH-* are produced; for TRC-20, additional logic is needed in shkeeperService.ts:415.
[!warning] Webhook returns 202 even on errors
The handler always responds 2xx to avoid SHKeeper's retry storm — even for unknown payments, signature failures (in non-production paths), DB errors, and unexpected exceptions. Operationally this means failed-to-process webhooks are silently swallowed unless someone tails the logs. Hook the catch-all into Sentry severity = error and alert on webhookStats.errorCount.
[!tip] Manual reconciliation
Use fix-transaction-hashes.js at repo root to backfill blockchain.transactionHash for payments where the webhook arrived without transactions. See Payout Flow for the parallel payout-side script usage.