Payment Providers

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:

FilePurpose
src/services/payment-gateway.service.tsCore abstraction, provider registry, deposit/withdrawal flows
src/services/payment-webhook.service.tsInbound webhook routing, signature verification, replay protection
src/services/payment-verification.service.tsKYC levels, amount limits, admin approval
src/services/payment-circuit-breaker.tsPer-provider circuit breakers
src/services/cashier-settings.service.tsCurrency config, verification limits, withdrawal limits, fees
src/services/stripe.provider.tsStripe implementation
src/services/coinpayments.provider.tsCoinPayments cryptocurrency implementation
src/services/providers/btcpayserver.provider.tsBTCPay Server Greenfield API implementation
src/services/bank-transfer.provider.tsBank 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:

  1. createDeposit() calls create_transaction API with currency1=USD (fiat) and currency2=BTC (crypto).
  2. Returns { address, checkout_url, qrcode_url, timeout }.
  3. Player sends the exact crypto amount to the address within timeout seconds.
  4. Coinpayments sends an IPN (Instant Payment Notification) to ipnUrl.

IPN status codes:

CodeMeaning
-2Refund
-1Cancelled
0Waiting for funds
1–99Received, confirming
100 or 2Complete

Withdrawal flow:

  1. processWithdrawal() calls create_withdrawal API.
  2. If the Coinpayments API fails, the withdrawal remains PENDING for 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:

  1. createDeposit() creates a local pending deposit to get the internal transaction ID.
  2. It creates a BTCPay invoice through POST /api/v1/stores/{storeId}/invoices.
  3. The invoice metadata includes orderId = transaction.id so webhooks can match either the BTCPay invoice ID or the local transaction ID.
  4. 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>.
  • InvoiceSettled and InvoicePaymentSettled map to completed.
  • InvoiceProcessing maps to pending.
  • InvoiceExpired maps to canceled.
  • InvoiceInvalid maps to failed.

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:

  1. processWithdrawal() creates a BTCPay pull payment through POST /api/v1/stores/{storeId}/pull-payments.
  2. If a destination was supplied, it attempts a payout through POST /api/v1/stores/{storeId}/payouts, using BOLT11 for a Lightning destination and BTC otherwise.
  3. 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 with CircuitOpenError.
  • HALF_OPEN — testing recovery; a limited number of probe calls (default 2) are allowed through.

Default configuration (per provider):

ProviderFailure thresholdReset timeoutRequest timeout
stripe560s30s
coinpayments560s45s
bank_transfer3120s60s
manual1030s10s
paypal/skrill/neteller/muchbetter/ecopayz560s30s
btcpayserver560s45s

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):

LevelRequirement
noneNo verification
basicEmail confirmed
standardID document verified (isVerified = true)
enhancedID + address proof. Address verification is not yet recorded, so users do not currently reach this level.

Amount thresholds (USD) requiring verification:

LevelThreshold
basic≥ $100
standard≥ $1,000
enhanced≥ $5,000

Per-tier transaction limits (configurable via cashier-settings.service.ts):

TierDefault max depositDefault 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 TierDaily 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 in parseWebhook().
  • 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_events deduplication prevents double-crediting from retried webhooks.
  • Circuit breakers prevent cascading failures when external payment APIs are down.

Key Files Reference

FilePurpose
src/services/payment-gateway.service.tsCore abstraction, provider registry, deposit/withdrawal orchestration
src/services/payment-webhook.service.tsInbound webhook routing, signature verification, event storage
src/services/payment-verification.service.tsKYC levels, amount thresholds, admin approval actions
src/services/payment-circuit-breaker.tsPer-provider circuit breakers with open/half-open/closed states
src/services/cashier-settings.service.tsCurrency, limits, fees, rake configuration
src/services/stripe.provider.tsStripe credit/debit card integration
src/services/coinpayments.provider.tsCoinPayments cryptocurrency integration
src/services/bank-transfer.provider.tsBank wire / SEPA integration
src/services/providers/btcpayserver.provider.tsBTCPay Server integration
src/api/routes/webhook.routes.tsInbound payment webhook route
src/utils/encryption.tsAES-256-GCM encrypt/decrypt used for provider configs
src/services/transaction.service.tscreatePendingDeposit, confirmDeposit, createWithdrawalRequest
Try the live demo Pricing

Have questions about Poker Engine? We reply fast.