How the seller receives the escrowed crypto once the order is complete. Two variants are implemented:
SHKeeper Payouts API (shkeeperPayoutService.ts) — the gateway signs and broadcasts on behalf of the platform.
Manual admin wallet payout (admin-wallet-payout.tsx) — an admin connects their own wallet and signs the transfer; the tx hash is reported back to the backend.
Both result in Payment.escrowState = 'released' and an outgoing Payment record with direction: 'out'.
Actors
Admin (or scheduled system trigger) — initiates the payout.
Seller — recipient, has saved their wallet address under User.profile.walletAddress.
Backend — shkeeperPayoutService.createPayoutTask and the manual confirmation routes.
SHKeeper Payouts API — POST https://pay.amn.gg/api/v1/payout (per SHKeeper docs).
Blockchain (BSC) — final on-chain settlement.
MongoDB — separate Payment document with direction: 'out'.
Preconditions
The original pay-in Payment has escrowState = 'funded' (or releasable).
The seller has set profile.walletAddress (validated ^0x... format).
The corresponding PurchaseRequest is in a status that allows payout (delivered, confirming, seller_paid, or completed).
Step-by-step narrative
SHKeeper-mediated payout
Admin (or the auto-release scheduler — not yet implemented) hits POST /api/payment/shkeeper/payout with { purchaseRequestId, sellerOfferId, buyerId, sellerId, amount, recipientAddress, token?, network? }.
Polling or webhook: when SHKeeper completes the payout, it pushes a webhook (or the backend polls GET /api/v1/payout/{task_id}) and the system flips Payment.status = 'completed', escrowState = 'released', populates blockchain.transactionHash.
The original pay-in Payment is updated in tandem: escrowState = 'released', PurchaseRequest.status = 'seller_paid' → completed.
Notifications: notifyPayoutSent to the seller, internal admin log.
Manual admin payout
Admin opens the request detail in the admin view; the admin-step component admin-wallet-payout.tsx shows the recipient and amount.
Admin connects their wallet (useWeb3 / web3Service.connect()).
Admin clicks "Send payout"; wagmi triggers transfer(recipient, amount) on the USDT contract.
After confirmation, the admin clicks "Confirm in system", which POSTs POST /api/payment/admin/confirm-tx/:paymentId with { txHash, kind: 'release' }.
sequenceDiagram
autonumber
actor A as Admin/System
participant BE as Backend
participant DB as MongoDB
participant SK as SHKeeper Payout API
participant BC as BSC
actor S as Seller
A->>BE: POST /api/payment/shkeeper/payout
BE->>DB: Payment.create({direction:"out", escrowState:"releasing"})
BE->>SK: POST /api/v1/payout {to, amount, crypto}
SK-->>BE: { task_id, status:"pending" }
BE->>DB: Payment.providerPaymentId=task_id
SK->>BC: signed payout tx (managed wallet)
BC-->>SK: confirmed
SK->>BE: webhook payout-completed (or BE polls)
BE->>DB: Payment.status="completed"\nescrowState="released"\ntxHash
BE->>DB: pay-in Payment.escrowState="released"\nPurchaseRequest.status="seller_paid"
BE->>S: notifyPayoutSent
API calls
Method
Endpoint
Source
POST
/api/payment/shkeeper/payout
shkeeperPayoutRoutes.ts → createPayoutTask
GET
/api/payment/shkeeper/payout/:taskId
Polls SHKeeper task status
POST
/api/payment/admin/confirm-tx/:paymentId
Manual admin confirmation
GET
/api/payment/admin/payouts
List payouts (admin dashboard)
Database writes
payments — new outgoing document; updates to status, escrowState, blockchain.transactionHash as the task progresses.
purchaserequests — status advances to seller_paid → completed.
notifications — seller payout receipt.
Socket events emitted
payment-status (admin) on each transition.
purchase-request-updatestatus-changed.
Side effects
fix-transaction-hashes.js at repo root (backend/fix-transaction-hashes.js) — script used to backfill missing blockchain.transactionHash on payouts where the SHKeeper webhook arrived without the txid (e.g. signature length mismatch in dev). Run locally with the same Mongo URI to repair stale documents. Use it as the reference for the data-fix pattern — pull recent payouts, query SHKeeper for invoice/task details, write back the hash.
Hash repair — periodic reconciliation against SHKeeper invoice GET endpoints ensures bookkeeping accuracy.
Error / edge cases
Invalid recipient address → throws synchronously, no DB record created.
Duplicate payout request → idempotency: existing payment returned with no extra SHKeeper call.
Payout reverted on chain → SHKeeper marks the task failed; backend sets Payment.status = 'failed', escrowState = 'failed'. Admin retries.
Missing transactionHash after success → use fix-transaction-hashes.js to backfill.
Manual payout signed but never confirmed in system → on-chain transfer happened, but Payment.escrowState stays releasing. Admin can run a reconciliation script that scans the escrow wallet's outgoing txs and matches by amount/timestamp.
Seller changes wallet address mid-flight → the saved recipientAddress is the snapshot taken at payout creation; subsequent profile changes do not affect in-flight payouts.
[!warning] Auto-release is not yet implemented
Today, payouts are admin-initiated. The flow is ready for an automatic trigger when Delivery Confirmation Flow completes — implement a cron job or queue worker that scans for PurchaseRequest.status='delivered' and auto-creates payouts after a configurable grace period.
Linked flows
Escrow Flow — sets up the conditions under which payout is allowed.