18 KiB
title, tags, related_models, related_apis
| title | tags | related_models | related_apis | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Payment Flow - SHKeeper |
|
|
|
Payment Flow — SHKeeper (Crypto Pay-In)
[!warning] Historical migration document This page describes the older SHKeeper pay-in rail. It is retained for migration/reconciliation context only. The current primary pay-in path is PRD - Request Network In-House Checkout, and the current escrow/custody model is Escrow Flow plus PRD - Decentralized Custody and Smart-Contract Escrow Roadmap.
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 ashttps://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).
- BSC →
- TRC-20 (
USDT-TRC20) and nativeBTCare mentioned in the task brief but not currently wired inshkeeperService.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/andfrontend/src/sections/payment/. - Backend —
shkeeperService.createPayInIntent(backend/src/services/payment/shkeeper/shkeeperService.ts:48-533) andshkeeperWebhook.handleShkeeperWebhook(backend/src/services/payment/shkeeper/shkeeperWebhook.ts). - SHKeeper gateway (
https://pay.amn.gg) — issues per-payment deposit addresses, watches the chain, sends webhooks. - Blockchain — BSC / Ethereum.
- PaymentCoordinator (
backend/src/services/payment/paymentCoordinator.ts) — serialises concurrent payment-status updates from multiple sources (webhook, wallet monitor, manual confirm). - MongoDB —
payments,purchaserequests,selleroffers,chats,notifications. - Redis —
paymentRedisService(wallet-address cache, 2 h TTL). - Socket.IO —
payment-created,seller-offer-update,purchase-request-update.
Preconditions
- Buyer has selected an offer (or is using the template-checkout shortcut).
- Backend env:
SHKEEPER_API_URL,SHKEEPER_API_KEY,SHKEEPER_WEBHOOK_SECRET,API_URL(for the callback URL). - Redis is reachable (graceful degradation if not — see
app.ts:361-367). - SHKeeper has wallets provisioned for each
crypto_name.
Payment state machine
stateDiagram-v2
[*] --> pending: createPayInIntent\n(Payment.status="pending")
pending --> pending_partial: webhook PARTIAL\nescrowState="partial"
pending --> completed: webhook PAID/OVERPAID\nescrowState="funded"
pending --> failed: webhook EXPIRED/CANCELLED\nescrowState="cancelled"
pending_partial --> completed: top-up arrives, total ≥ amount
pending_partial --> failed: expires
completed --> released: admin release → seller payout\n[[Payout Flow]]
completed --> refunded: dispute resolution → buyer refund
refunded --> [*]
released --> [*]
failed --> [*]
Step-by-step narrative
Phase 1 — Create intent
- Buyer clicks "Pay" on the chosen offer (
/dashboard/buyer/requests/{id}→ step-3-select-and-pay). - Frontend POSTs
POST /api/payment/shkeeper/createwith{ purchaseRequestId, sellerOfferId, amount, token?, network? }. - Backend
createPayInIntent:- Validates ObjectIds (
shkeeperService.ts:55-71). Special path for template checkout (string IDs starting withtemplate-checkout-). - Duplicate-guard #1 (
:75-116): if there is already an activePayment(status ∈ {pending, processing}) for the same{purchaseRequestId, sellerOfferId, buyerId}, the existing record is reused — samepaymentUrl, no new wallet allocation. - Duplicate-guard #2 (template checkout) (
:131-198): if a recentcompleted/confirmedpayment exists for the same buyer + template session, reuse it; otherwise dedupe pending records within the last 5 minutes. - Upsert (
:218-249): atomicPayment.findOneAndUpdate(filter, {$setOnInsert: {...}}, {upsert: true, new: true})keyed by{buyerId, purchaseRequestId, provider, direction:'in', status:'pending'}— prevents race-condition duplicates. - Sets
Payment.providerPaymentId = externalId. For template checkouts,externalId = template-{ts}-{rand}; otherwise it's thePayment._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_requestwith headerX-Shkeeper-Api-Key. Body:{ external_id, fiat: 'USD', amount: '12.34', callback_url: ${API_URL}/api/payment/shkeeper/webhook }. The HTTP call is wrapped byshkeeperFetch(shkeeperHealthCheck.ts) which trips the breaker on repeated failures. - Persist response (
:484-503): updatesPayment.metadata.{shkeeperInvoiceId, shkeeperData, cryptoName, walletAddress}; caches the wallet for 2 h; callswalletMonitor.addWallet(...)so the on-chain watcher can confirm independently (belt-and-braces against missed webhooks).
- Validates ObjectIds (
- Returns
{ paymentId, paymentUrl, shkeeperInvoiceId, walletAddress, amount, exchangeRate, displayName, cryptoName }. - Emits
payment-createdglobally viaemitGlobalEvent(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_afterfrom 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(orOVERPAIDif the buyer sent extra).
Phase 3 — Webhook
- SHKeeper POSTs to
${API_URL}/api/payment/shkeeper/webhookwith theShkeeperWebhookPayloadshape (shkeeperWebhook.ts:14-37):{ external_id, crypto: "BNB-USDT", addr, fiat: "USD", balance_fiat, balance_crypto, paid: true, status: "PAID" | "PENDING" | "EXPIRED" | "CANCELLED" | "PARTIAL" | "OVERPAID", transactions: [{ txid, date, amount_crypto, amount_fiat, trigger: true, ... }], fee_percent, fee_fixed, fee_policy, overpaid_fiat } - Signature verification (
shkeeperWebhook.ts:84-120): HMAC-SHA256 of the raw body withSHKEEPER_WEBHOOK_SECRET, headerx-shkeeper-signature(also acceptsx-signature,signature,x-hub-signature,x-hub-signature-256). Mismatch →401in production, allowed in dev. Length-mismatched signature →401(avoidstimingSafeEqualcrash). - Fallback auth (
:122-141): if no signature header but env requires it, the route acceptsX-Shkeeper-Api-KeymatchingSHKEEPER_API_KEY. Otherwise returns202to 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 OKto 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 tohandleTemplateCheckoutWebhook(templateCheckoutWebhook.ts). Otherwise →202 OKwith a rate-limited log. - Duplicate-webhook detection (
:249-296): ifmetadata.shkeeperStatus,balance_fiat,paidare identical to the previous webhook and less than 10 seconds have passed → return202(idempotent). Logged once per minute per payment. - Map SHKeeper status → internal status (
:387-411):SHKeeper Internal statusescrowStatePAIDcompletedfundedOVERPAIDcompletedfundedEXPIRED,CANCELLEDfailedcancelledPARTIALpendingpartialPENDINGpending— - Extract
transactionHash(:311-385) — prefers the transaction withtrigger === 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) —coordinatePaymentUpdatereturns false if another worker already started processing this state change, otherwiseexecutePaymentUpdatewrites the new status/escrowState/txHash atomically with metadata. - Cascade on PAID/OVERPAID (
:543-714):- Load
PurchaseRequestandSellerOfferfor 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). - Notifications:
notifyPaymentConfirmed(to both parties),notifyOfferAccepted(winner),notifyRequestStatusChanged(received_offers → payment). - Socket fan-out:
seller-offer-update'payment-completed'to winner,'offer-rejected'to losers (each carriesofferId,reason).
- Load
- Cleanup:
simpleAutoWebhook.removePayment(external_id)stops the simple polling fallback. - Always respond
202 Accepted(SHKeeper retries on non-2xx).200would cause infinite retries because SHKeeper expects202per 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 tocompleted, the UI transitions to "Payment received" and unlocks the next buyer step (await delivery). - The seller's dashboard receives
seller-offer-updatepayment-completedand 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 ✓"
API calls
| Method | Endpoint | Purpose | Source |
|---|---|---|---|
POST |
/api/payment/shkeeper/create |
Create pay-in intent | shkeeperRoutes.ts → shkeeperService.createPayInIntent |
POST |
/api/payment/shkeeper/webhook |
Receive SHKeeper webhook | shkeeperWebhook.handleShkeeperWebhook |
GET |
/api/payment/shkeeper/status/:paymentId |
Frontend polling | shkeeperRoutes.ts |
GET |
/api/payment/fetch-tx/:paymentId |
Manual transaction lookup | paymentRoutes.ts |
Database writes
payments: insert on intent creation (status: 'pending'); update on each webhook (status, escrowState, blockchain.transactionHash, metadata).payments(unique indexuniq_pending_shkeeper_by_buyer_session, seePayment.ts:181-188): partial unique on{buyerId, purchaseRequestId, provider:'shkeeper', direction:'in', status:'pending'}prevents duplicate pending pay-ins.selleroffers:statusflipped (accepted/rejected) by the webhook cascade.purchaserequests:status→payment,selectedOfferIdset.chats: a newdirectchat (or reuse) — find-or-create viaChatService.createChat.notifications: 2–N entries depending on parties.
Socket events emitted
payment-created(global) — broadcast on intent creation.seller-offer-updatewitheventType: 'payment-completed'→ winning seller.seller-offer-updatewitheventType: 'offer-rejected'→ each losing seller.purchase-request-updatewitheventType: 'status-changed'(viaPurchaseRequestService) →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 tocompleted. 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 viawebhookStats.tsadmin 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). PARTIALpayment → state held aspending/partial; further deposits to the same address are aggregated by SHKeeper and a new webhook arrives.OVERPAID→ treated ascompleted/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 longerpending.- External_id not found →
202with rate-limited log; common for orphaned webhooks from old tests. - Webhook arrives twice within 10 s with same data → idempotency skip →
202. PaymentCoordinatordeferral →202with 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 samePaymentdoc anyway). crypto_namemismatch — onlyBNB-*andETH-*are produced; for TRC-20, additional logic is needed inshkeeperService.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 =
errorand alert onwebhookStats.errorCount.
[!tip] Manual reconciliation Use
fix-transaction-hashes.jsat repo root to backfillblockchain.transactionHashfor payments where the webhook arrived without transactions. See Payout Flow for the parallel payout-side script usage.
Linked flows
- Purchase Request Flow — supplies the request being paid for.
- Seller Offer Flow — supplies the offer being accepted.
- Payment Flow - DePay & Web3 — alternative direct-wallet route.
- Escrow Flow — what
escrowState=fundedmeans downstream. - Chat Flow — auto-created on success.
- Notification Flow — both parties pinged.
- Payout Flow — pays the seller from the funded escrow.
- Dispute Flow — escape if the order goes wrong.
Source files
- Backend:
backend/src/services/payment/shkeeper/shkeeperService.ts(intent creation, ~650 lines) - Backend:
backend/src/services/payment/shkeeper/shkeeperWebhook.ts(webhook handler, ~750 lines) - Backend:
backend/src/services/payment/shkeeper/shkeeperHealthCheck.ts(circuit breaker) - Backend:
backend/src/services/payment/shkeeper/shkeeperRoutes.ts - Backend:
backend/src/services/payment/paymentCoordinator.ts - Backend:
backend/src/services/payment/cleanupPendingPayments.ts(periodic GC) - Backend:
backend/src/services/blockchain/walletMonitor.ts(chain watcher) - Backend:
backend/src/services/redis/paymentRedisService.ts(wallet cache) - Backend:
backend/src/models/Payment.ts - Frontend:
frontend/src/sections/request/components/buyer-steps/step-3-components/step-3-payment.tsx - Frontend:
frontend/src/sections/payment/