This guide covers the payment system internals — how payment providers are integrated, how the deposit and withdrawal flows work, and how to add new providers.
Architecture Overview
PaymentGatewayService — unified abstraction, provider registry
├── IPaymentProvider — interface all providers implement
├── ManualPaymentProvider — admin-processed payments (built-in)
├── StripeProvider — credit/debit cards
├── CoinpaymentsProvider — cryptocurrency (BTC, ETH, LTC, USDT, etc.)
├── BTCPayServerProvider — self-hosted cryptocurrency checkout
├── BankTransferProvider — bank wire / SEPA
├── PayPalProvider — PayPal
├── SkrillProvider — Skrill
├── NetellerProvider — Neteller
├── MuchBetterProvider — MuchBetter
└── EcoPayzProvider — ecoPayz
PaymentWebhookService — inbound webhook routing + verification
PaymentVerificationService — KYC-based deposit/withdrawal limits
PaymentCircuitBreakerService — per-provider fault protection
CashierSettingsService — currency, fees, limits configuration
Source files:
| File | Purpose |
|---|---|
src/services/payment-gateway.service.ts | Core abstraction, provider registry, deposit/withdrawal flows |
src/services/payment-webhook.service.ts | Inbound webhook routing, signature verification, replay protection |
src/services/payment-verification.service.ts | KYC levels, amount limits, admin approval |
src/services/payment-circuit-breaker.ts | Per-provider circuit breakers |
src/services/cashier-settings.service.ts | Currency config, verification limits, withdrawal limits, fees |
src/services/stripe.provider.ts | Stripe implementation |
src/services/coinpayments.provider.ts | CoinPayments cryptocurrency implementation |
src/services/providers/btcpayserver.provider.ts | BTCPay Server Greenfield API implementation |
src/services/bank-transfer.provider.ts | Bank transfer implementation |
src/services/providers/ | PayPal, Skrill, Neteller, MuchBetter and ecoPayz implementations |
Plugins can add further providers. This repo ships plugins/steem-provider/ and plugins/telegram-stars-provider/ as examples.
The IPaymentProvider Interface
All providers implement this interface:
interface IPaymentProvider {
readonly type: PaymentProviderType;
readonly name: string;
initialize(config: Record<string, unknown>): Promise<void>;
createDeposit(params: CreateDepositParams): Promise<DepositResult>;
processWithdrawal(params: ProcessWithdrawalParams): Promise<WithdrawalResult>;
checkPaymentStatus(externalId: string): Promise<PaymentStatusResult>;
parseWebhook(payload: unknown, signature?: string): Promise<WebhookPayload>;
getSupportedCurrencies(): Promise<string[]>;
validateConfig(): Promise<boolean>;
}
The built-in provider type values are: coinpayments, btcpayserver, stripe, bank_transfer, manual, paypal, skrill, neteller, muchbetter, ecopayz. PaymentProviderType also accepts any other string, which is how plugin providers register their own types.
Provider Registry
Core providers register themselves at module load time, and plugins call registry.registerProvider() from register():
import { registerProvider } from './payment-gateway.service.js';
class MyProvider implements IPaymentProvider { ... }
registerProvider('my_provider_type', MyProvider);
The registry (providerRegistry) maps each PaymentProviderType string to a constructor. When a payment operation needs a provider, the gateway instantiates it with new ProviderClass() and calls initialize(config). A type with no registered class throws PaymentConfigError.
Provider configs are stored encrypted in the database (AES-256-GCM via encrypt()/decrypt() from src/utils/encryption.ts). The gateway transparently encrypts on write and decrypts on read.
Deposit Flow
initiateDeposit({ userId, paymentSystemId, amount, currency, callbackUrl, returnUrl, metadata, limitThresholds })
1. Check the user exists and is not blocked or frozen
2. Load PaymentSystem from DB, decrypt config; it must be active for deposits
3. Check deposit limits (per-system min/max, daily/weekly/monthly)
4. Instantiate and initialize the provider
5. Call provider.createDeposit(params)
→ Provider creates pending Transaction in DB
→ Provider calls external API
→ Returns DepositResult { transactionId, paymentUrl, paymentAddress, qrCode, ... }
6. Bind the transaction to the payment system and external ID
7. Return DepositResult to caller
The caller redirects the player to paymentUrl (card payment), shows the paymentAddress + qrCode (crypto), or displays bank transfer instructions.
Withdrawal Flow
requestWithdrawal({ userId, paymentSystemId, amount, currency, destination, fee, metadata, idempotencyKey, limitThresholds })
1. Load PaymentSystem from DB, decrypt config
2. External providers require an idempotencyKey; a retry with the same key returns the existing withdrawal
3. Check the account is active and the system is active for withdrawals
4. Check user balance covers amount + fee
5. Check withdrawal limits (per-system min/max, daily/weekly/monthly)
6. Call provider.processWithdrawal(params)
→ Provider creates withdrawal Transaction in DB
→ Provider calls external API
→ Returns WithdrawalResult { transactionId, externalId, status }
7. Bind the transaction to the payment system and external ID
8. If the provider reports status = completed, settle the withdrawal
If a provider fails before the payout is accepted, it is responsible for rejecting its withdrawal transaction. If the outcome is unknown, it throws PaymentSubmissionUncertainError and the withdrawal stays pending rather than being refunded.
The cashier route auto-approves a withdrawal only when the amount is at or below autoApproveThreshold (default $100, configurable), no AML review is required, and the provider settles locally (manual, bank_transfer). External providers settle through webhooks or status sync. Other withdrawals stay PENDING for review.
Webhook Processing Flow
Inbound webhooks arrive at:
POST /api/v1/webhooks/payment/:paymentSystemId
The route reads the signature from x-signature, hmac, x-coinpayments-hmac, btcpay-sig, stripe-signature or x-webhook-signature. PayPal’s paypal-transmission-* headers are combined into a JSON-encoded signature string.
The processPaymentWebhook() function in payment-webhook.service.ts handles all providers:
processPaymentWebhook({ paymentSystemId, payload, rawPayload, signature, sourceIP })
1. Load PaymentSystem (validates paymentSystemId)
2. Verify source IP against ipWhitelist (if configured)
3. Verify webhook signature:
- coinpayments: HMAC-SHA512 of raw body
- btcpayserver: `BTCPAY-SIG` header, `sha256=<hmac>` over raw body
- stripe: t=timestamp,v1=signature format (HMAC-SHA256)
- paypal: transmission headers, verified by the provider's parseWebhook()
- skrill/neteller/muchbetter/ecopayz/telegram_stars: required config and fields are
checked first, then the provider's parseWebhook() verifies them
- bank_transfer/manual and unknown types: rejected
4. Atomically claim the event in webhook_events (replay protection)
5. If already processed or in flight: return { status: 'duplicate' }
6. Pass to gatewayProcessWebhook() which updates transaction status
7. If no matching transaction: return { status: 'ignored' }
8. If payment.completed + deposit: credit user balance, send push notification
9. Dispatch developer partner webhooks (fire-and-forget)
10. Return { status: 'processed' }
Signature verification details
// Coinpayments: HMAC-SHA512
HMAC-SHA512(secret=ipnSecret, data=rawBody)
// BTCPay Server: HMAC-SHA256
HMAC-SHA256(secret=webhookSecret, data=rawBody)
// Header format: BTCPAY-SIG: sha256=abcdef...
// Stripe: signed payload = timestamp.rawBody
HMAC-SHA256(secret=webhookSecret, data=timestamp + '.' + rawBody)
// Header format: Stripe-Signature: t=1234,v1=abcdef...
// PayPal: transmission headers are verified by the provider's parseWebhook()
// Skrill/Neteller/MuchBetter/ecoPayz: signatures in the POST body are verified
// by the provider's parseWebhook()
All comparisons use crypto.timingSafeEqual() to prevent timing attacks.
Replay protection
Every webhook is claimed in the webhook_events table under (paymentSystemId, externalId, eventType) inside a serializable transaction. A matching processed row or an in-flight row (received, processing) is returned with status: 'duplicate' without reprocessing. In-flight rows older than 5 minutes are marked failed, so a later delivery can retry. If the claim cannot be stored, the webhook is refused.
Provider Implementations
Manual
Built-in provider. Used for admin-added credits or test deposits that don’t require a real payment gateway. Deposits are created with status = pending and require admin approval to credit the user’s balance.
Stripe
Configuration:
interface StripeConfig {
secretKey: string;
publishableKey: string;
webhookSecret: string;
testMode?: boolean;
defaultCurrency?: string;
}
- Deposits: creates a Stripe Checkout Session. The player is redirected to Stripe’s hosted checkout.
- Withdrawals: creates a Stripe Payout to a connected bank account.
- Webhooks: processes
payment_intent.succeeded,charge.succeeded,payment_intent.payment_failed,charge.failed,payment_intent.canceled,charge.refunded.
CoinPayments (Cryptocurrency)
Configuration:
interface CoinpaymentsConfig {
publicKey: string;
privateKey: string;
ipnSecret: string;
merchantId: string;
ipnUrl?: string;
defaultCurrency?: string; // e.g., 'BTC', 'LTCT' for testnet
testMode?: boolean;
}
All API calls are signed with HMAC-SHA512 using the privateKey. The HMAC header is included in every request.
Deposit flow:
createDeposit()callscreate_transactionAPI withcurrency1=USD(fiat) andcurrency2=BTC(crypto).- Returns
{ address, checkout_url, qrcode_url, timeout }. - Player sends the exact crypto amount to the address within
timeoutseconds. - Coinpayments sends an IPN (Instant Payment Notification) to
ipnUrl.
IPN status codes:
| Code | Meaning |
|---|---|
| -2 | Refund |
| -1 | Cancelled |
| 0 | Waiting for funds |
| 1–99 | Received, confirming |
| 100 or 2 | Complete |
Withdrawal flow:
processWithdrawal()callscreate_withdrawalAPI.- If the Coinpayments API fails, the withdrawal remains
PENDINGfor manual processing.
Additional methods on CoinpaymentsProvider:
getExchangeRates(currencies?)— live BTC rates for listed coins.getDepositAddress(currency, userId)— callback address for a currency.getCoinInfo(currency)— confirmations required, tx fee, can-convert flag.
BTCPay Server
Configuration:
interface BTCPayServerConfig {
serverUrl: string; // e.g. https://btcpay.example.com
apiKey: string; // Greenfield API key
storeId: string; // BTCPay store ID
webhookSecret?: string; // HMAC shared secret; webhooks are rejected without it
speedPolicy: string;
defaultCurrency: string;
}
Deposit flow:
createDeposit()creates a local pending deposit to get the internal transaction ID.- It creates a BTCPay invoice through
POST /api/v1/stores/{storeId}/invoices. - The invoice metadata includes
orderId = transaction.idso webhooks can match either the BTCPay invoice ID or the local transaction ID. - The cashier redirects the player to BTCPay’s
checkoutLink.
When a BTCPay payment system is saved, the gateway registers its webhook with the store at /api/v1/webhooks/payment/{systemId}, updating an existing registration for the same URL. Registration requires webhookSecret.
Webhook flow:
- Incoming requests must include
BTCPAY-SIG: sha256=<hmac>. InvoiceSettledandInvoicePaymentSettledmap tocompleted.InvoiceProcessingmaps topending.InvoiceExpiredmaps tocanceled.InvoiceInvalidmaps tofailed.
The provider hydrates missing amount/currency details from the invoice API before gateway settlement so the existing transaction amount and currency checks still apply.
Withdrawal flow:
processWithdrawal()creates a BTCPay pull payment throughPOST /api/v1/stores/{storeId}/pull-payments.- If a destination was supplied, it attempts a payout through
POST /api/v1/stores/{storeId}/payouts, usingBOLT11for a Lightning destination andBTCotherwise. - The local withdrawal remains pending until operator/provider settlement confirms completion. If the pull payment was created but the payout failed on an idempotent request, the provider throws
PaymentSubmissionUncertainError.
Supported currencies:
getSupportedCurrencies() reads enabled store payment methods from GET /api/v1/stores/{storeId}/payment-methods, extracts unique currency codes, caches them per provider instance, and falls back to BTC if discovery fails.
Bank Transfer
Admin-managed. No external API calls. Deposit instructions (bank account details) are returned and the admin manually confirms receipt and credits the balance.
New deposit and withdrawal references use DEP-<32 uppercase hex characters>-YYYYMMDD and WDR-<32 uppercase hex characters>-YYYYMMDD. The random component contains 128 bits to avoid same-day collisions in the previous six-character format. Existing shorter references remain valid for payment-status lookups.
PayPal, Skrill, Neteller, MuchBetter, ecoPayz
These providers handle their own webhook signature verification inside parseWebhook(). The webhook service passes the raw payload (including any XML) to the provider without service-level parsing. Providers may return a confirmationResponse (e.g., ecoPayz requires a signed XML response to be returned to the caller).
Circuit Breaker
Every external provider call goes through a per-provider circuit breaker via executeWithPaymentCircuitBreaker(providerType, operation).
States:
CLOSED— normal operation.OPEN— provider is failing; calls are rejected immediately withCircuitOpenError.HALF_OPEN— testing recovery; a limited number of probe calls (default 2) are allowed through.
Default configuration (per provider):
| Provider | Failure threshold | Reset timeout | Request timeout |
|---|---|---|---|
| stripe | 5 | 60s | 30s |
| coinpayments | 5 | 60s | 45s |
| bank_transfer | 3 | 120s | 60s |
| manual | 10 | 30s | 10s |
| paypal/skrill/neteller/muchbetter/ecopayz | 5 | 60s | 30s |
| btcpayserver | 5 | 60s | 45s |
When a circuit is open, calls throw CircuitOpenError. createCircuitOpenErrorResult() turns it into a CIRCUIT_OPEN result with a retryAfterSeconds field. Provider types without an entry, such as plugin providers, use the defaults of 5 failures, 60s reset and 30s request timeout. Admins can manually reset a circuit via resetPaymentCircuitBreaker(providerType).
Verification and KYC Limits
payment-verification.service.ts enforces KYC-based limits before processing:
Verification levels (calculated from user profile):
| Level | Requirement |
|---|---|
none | No verification |
basic | Email confirmed |
standard | ID document verified (isVerified = true) |
enhanced | ID + address proof. Address verification is not yet recorded, so users do not currently reach this level. |
Amount thresholds (USD) requiring verification:
| Level | Threshold |
|---|---|
basic | ≥ $100 |
standard | ≥ $1,000 |
enhanced | ≥ $5,000 |
Per-tier transaction limits (configurable via cashier-settings.service.ts):
| Tier | Default max deposit | Default max withdrawal |
|---|---|---|
| unverified | $500 | $100 |
| verified | $10,000 | $5,000 |
| vip | $100,000 | $50,000 |
The verification profile selects verified or unverified from the user’s isVerified flag.
KYC tier daily withdrawal limits:
| KYC Tier | Daily limit |
|---|---|
| UNVERIFIED | $100 |
| TIER_1 (basic) | $1,000 |
| TIER_2 (enhanced) | $10,000 |
| TIER_3 (full) | Unlimited |
Blocked and frozen accounts are denied all deposits and withdrawals at the verification check. Withdrawals also require a confirmed email address.
Cashier Settings
cashier-settings.service.ts manages all operator-configurable cashier parameters. Settings are stored in the Setting table under category = 'cashier'.
Currency configuration
interface CurrencyConfig {
code: string; // e.g., 'USD'
symbol: string; // e.g., '$'
name: string;
decimals: number;
symbolPosition: 'before' | 'after';
thousandsSeparator: string;
decimalSeparator: string;
}
Default currencies: USD, EUR, GBP, MNT. The primary currency is USD. Exchange rates are stored as multipliers relative to the primary currency.
Fee calculation helpers
import {
calculateRake,
calculateWithdrawalFee,
calculateTournamentRake,
calculateAgentCommission,
getAffiliateCommissionPercentage,
} from './cashier-settings.service.js';
const fee = await calculateWithdrawalFee(new Decimal(100)); // 0 by default (fee disabled)
const rake = await calculateRake(new Decimal(50)); // 5% of pot, capped at $5
Default commission settings:
- Rake: 5% of pot, capped at $5/hand.
- Tournament rake: 10% of buy-in.
- Agent commission: 30% of rake.
- Affiliate commission: Level 1 = 5%, Level 2 = 3%, Level 3 = 1%.
Adding a New Provider
To add a provider without changing core code, build it as a plugin. Implement IPaymentProvider and call registry.registerProvider(), registry.registerProviderMeta() and registry.registerProviderType() from register(), as plugins/telegram-stars-provider/ does. A plugin provider’s webhook type must be one the webhook service recognises, otherwise signature verification rejects it, so a new plugin normally verifies its own signature in a { access: 'webhook' } route and settles through registry.cashier.confirmDeposit().
The steps below add a provider to core.
1. Add the type to PaymentProviderType
In payment-gateway.service.ts:
export type PaymentProviderType =
| 'coinpayments'
| 'stripe'
| 'my_new_provider' // add here
// ...
Also add circuit breaker configuration in payment-circuit-breaker.ts:
const PROVIDER_CIRCUIT_OPTIONS: Record<PaymentProviderType, Partial<CircuitBreakerOptions>> = {
my_new_provider: {
failureThreshold: 5,
resetTimeoutMs: 60000,
requestTimeoutMs: 30000,
},
// ...
};
2. Implement IPaymentProvider
Create a new file src/services/my-provider.provider.ts:
import {
type IPaymentProvider,
type PaymentProviderType,
type CreateDepositParams,
type DepositResult,
type ProcessWithdrawalParams,
type WithdrawalResult,
type PaymentStatusResult,
type WebhookPayload,
PaymentError,
PaymentConfigError,
registerProvider,
} from './payment-gateway.service.js';
import {
executeWithPaymentCircuitBreaker,
isCircuitOpenError,
createCircuitOpenErrorResult,
} from './payment-circuit-breaker.js';
import { createPendingDeposit, createWithdrawalRequest } from './transaction.service.js';
export class MyProvider implements IPaymentProvider {
readonly type: PaymentProviderType = 'my_new_provider';
readonly name = 'My Payment Provider';
private config: MyProviderConfig | null = null;
async initialize(config: Record<string, unknown>): Promise<void> {
if (!config.apiKey) {
throw new PaymentConfigError('apiKey is required', 'my_new_provider');
}
this.config = { apiKey: String(config.apiKey) };
}
async createDeposit(params: CreateDepositParams): Promise<DepositResult> {
const config = this.getConfig();
// Create the pending deposit first so the limit checks run atomically
const transaction = await createPendingDeposit({
userId: params.userId,
paymentSystemId: params.paymentSystemId,
amount: params.amount,
paymentMethod: 'my_new_provider',
limitThresholds: params.limitThresholds,
});
const result = await executeWithPaymentCircuitBreaker('my_new_provider', async () => {
// Call external API here, passing transaction.id as your reference
return await myProviderApi.createPayment({ ... });
}).catch(error => {
// Also cancel the pending transaction here, as the built-in providers do
if (isCircuitOpenError(error)) {
throw new PaymentError(
createCircuitOpenErrorResult('my_new_provider', error).error,
'my_new_provider'
);
}
throw error;
});
return {
transactionId: transaction.id,
externalId: result.id,
status: 'pending',
paymentUrl: result.paymentUrl,
amount: params.amount instanceof Decimal ? params.amount : new Decimal(params.amount),
currency: params.currency,
};
}
// ... implement remaining methods
async parseWebhook(payload: unknown, signature?: string): Promise<WebhookPayload> {
// Verify signature, parse payload
return {
externalId: '...',
status: 'completed',
amount: new Decimal('100.00'),
currency: 'USD',
direction: 'deposit',
timestamp: new Date(),
rawPayload: payload,
};
}
async getSupportedCurrencies(): Promise<string[]> {
return ['USD', 'EUR', 'GBP'];
}
async validateConfig(): Promise<boolean> {
try {
await myProviderApi.ping(this.config!.apiKey);
return true;
} catch {
return false;
}
}
}
// Self-register on import
registerProvider('my_new_provider', MyProvider);
export default MyProvider;
3. Add signature verification
In payment-webhook.service.ts, add a case to the second switch in verifyWebhookSignature(), and return the secret from getWebhookSigningSecret() unless you use config.signingSecret. Any type without a case is rejected:
case 'my_new_provider':
return verifyMyProviderSignature(payload, signature, signingSecret);
Or if your provider verifies signatures itself in parseWebhook(), add your type to the first case list in verifyWebhookSignature() and to providerParsedTypes. If the signature is in the body rather than a header, also add it to BODY_SIGNED_PROVIDERS, and add a case to assertProviderParsedWebhookPreconditions() for its required config:
const providerParsedTypes: PaymentProviderType[] = [
'paypal', 'skrill', 'neteller', 'muchbetter', 'ecopayz', 'telegram_stars', 'my_new_provider',
];
4. Import the provider at startup
Add a side-effect import alongside the others in src/app.ts, so the registerProvider() call at the bottom of the file executes:
import './services/my-provider.provider.js';
Security Notes
- Provider configs are encrypted at rest using AES-256-GCM. Never store raw API keys in plain text in the database.
- Webhook verification is always required for external providers. Webhooks without a signature are rejected with 401, except body-signed providers (
skrill,neteller,muchbetter,ecopayz,telegram_stars), whose required fields and config are checked and which then verify inparseWebhook(). - Timing-safe comparison via
crypto.timingSafeEqual()is required for all signature checks. - IP allowlisting is supported per-payment-system via
config.ipWhitelist. - Replay protection via
webhook_eventsdeduplication prevents double-crediting from retried webhooks. - Circuit breakers prevent cascading failures when external payment APIs are down.
Key Files Reference
| File | Purpose |
|---|---|
src/services/payment-gateway.service.ts | Core abstraction, provider registry, deposit/withdrawal orchestration |
src/services/payment-webhook.service.ts | Inbound webhook routing, signature verification, event storage |
src/services/payment-verification.service.ts | KYC levels, amount thresholds, admin approval actions |
src/services/payment-circuit-breaker.ts | Per-provider circuit breakers with open/half-open/closed states |
src/services/cashier-settings.service.ts | Currency, limits, fees, rake configuration |
src/services/stripe.provider.ts | Stripe credit/debit card integration |
src/services/coinpayments.provider.ts | CoinPayments cryptocurrency integration |
src/services/bank-transfer.provider.ts | Bank wire / SEPA integration |
src/services/providers/btcpayserver.provider.ts | BTCPay Server integration |
src/api/routes/webhook.routes.ts | Inbound payment webhook route |
src/utils/encryption.ts | AES-256-GCM encrypt/decrypt used for provider configs |
src/services/transaction.service.ts | createPendingDeposit, confirmDeposit, createWithdrawalRequest |