PSP #1 Adapter -- PassimPay

IPaymentProvider implementation for PassimPay. Full spec for the backend developer.

Phase 1
Spec
Backend
PassimPay
PassimPay is the first PSP adapter. This document describes the concrete implementation of the IPaymentProvider interface for the PassimPay API. Base URL: https://api.passimpay.io. Account: account.passimpay.io.
1.

Configuration

Environment VariableRequiredDescription
PASSIMPAY_PLATFORM_ID
Yes
Integer platform ID from account.passimpay.io
PASSIMPAY_API_SECRET
Yes
API secret key (used for request signing and webhook verification)
PASSIMPAY_BASE_URL
Yes
https://api.passimpay.io (no trailing slash)
PASSIMPAY_WEBHOOK_URL
Yes
Your public webhook endpoint, configured in PassimPay platform settings (e.g. https://yourapi.com/webhooks/passimpay)
PASSIMPAY_SERVER_IP
Yes
Your server's outbound IP -- must be whitelisted in PassimPay platform settings for withdraw to work
2.

Authentication & Signature

Every request to PassimPay is signed with the x-signature header. Contract: HMAC-SHA256(platformId + ";" + jsonBody + ";" + secret, secret). The JSON body must escape forward slashes as \/.
TypeScript -- signRequest + verifyWebhook
import crypto from 'crypto'

/**
 * Sign a PassimPay API request.
 * Contract: HMAC-SHA256( platformId + ";" + jsonBody + ";" + secret, secret )
 *
 * IMPORTANT: JSON.stringify escapes forward slashes as "\/" in some environments.
 * PassimPay expects escaped slashes. Use the replaceAll below to be safe.
 */
export function signRequest(
  platformId: number,
  body: Record<string, unknown>,
  secret: string,
): string {
  const jsonBody = JSON.stringify(body).replaceAll('/', '\/')
  const contract = `${platformId};${jsonBody};${secret}`
  return crypto
    .createHmac('sha256', secret)
    .update(contract)
    .digest('hex')
}

/**
 * Verify an incoming PassimPay webhook.
 * Same contract -- compare computed signature with x-signature header.
 */
export function verifyWebhook(
  platformId: number,
  rawBody: string,       // raw unparsed request body string
  secret: string,
  receivedSignature: string,
): boolean {
  const contract = `${platformId};${rawBody};${secret}`
  const expected = crypto
    .createHmac('sha256', secret)
    .update(contract)
    .digest('hex')
  return crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(receivedSignature, 'hex'),
  )
}
3.

Interface Method → PassimPay API Mapping

Interface MethodPassimPay EndpointWhenKey Fields
initiateDeposit
POST
/v2/address
H2H method (btc, eth, usdt_trc20, etc.)Send: platformId, paymentId (currency ID), orderId. Receive: address, destinationTag (XRP/TON)
initiateDeposit
POST
/v2/createorder
Invoice method (fixed amount, redirect flow)Send: platformId, orderId, amount, currencies, returnUrl. Receive: url (redirect player here)
initiateWithdrawal
POST
/v2/withdraw
All withdrawal requestsSend: platformId, paymentId, addressTo (address:tag for XRP/TON), amount (in crypto). Receive: transactionId
getTransactionStatus
POST
/v2/withdrawstatus
Withdrawal status check (reconciliation)Send: platformId, transactionId or orderId. Receive: approve (0/1/2), txhash, amountDebited
getTransactionStatus
POST
/v3/orderstatus
Invoice deposit status check (reconciliation)Send: platformId, orderId. Receive: status (paid/wait/error), amountCreditedMerchant, feeService, feeNetwork
handleWebhook
POST
-- (incoming)
PassimPay calls your webhook URL on deposit / withdrawal eventsVerify x-signature first. Parse type field: "deposit" or "withdraw". Map to UnifiedEvent.
getSupportedMethods
POST
/v2/currencies
Fetch available currencies for this platformSend: platformId. Receive: list[] with id, currency, network, rateUsd, minDep, minWithdraw. Cache up to 5 min.
4.

PassimPay Status → UnifiedStatus Mapping

Event / TriggerFieldValue→ UnifiedStatusNote
Deposit: /v2/address called----INITIATED--
Deposit webhook: confirmations = 1 (UTXO networks)confirmations1PROCESSINGBTC / LTC / DASH / DOGE / BCH only. Do NOT credit balance yet.
Deposit webhook: confirmations >= 2 (UTXO) OR confirmations = 0 (EVM / TRX / TON)confirmations≥2 / 0COMPLETEDCredit amountReceive (after fees) to player balance.
Invoice webhook: status = "waiting" (partial payment)statuswaitingPENDING_PARTIALInvoice method only. Business decision: credit actual or wait.
Invoice webhook: status = "error" (expired or partial fail)statuserrorFAILED--
Withdrawal webhook: approve = 0 (processing)approve0PROCESSING--
Withdrawal webhook: approve = 1 (success)approve1COMPLETEDUse amountDebited for final deduction from PSP balance.
Withdrawal webhook: approve = 2 (error)approve2FAILEDMandatory balance rollback. Notify player.
Internal TTL expired -- no webhook received----TIMED_OUTPassimPay only sends callbacks on success. No callback = timeout. Run reconciliation job.
5.

Webhook Handling

The webhook route must receive the raw (unparsed) request body for signature verification. Using express.json() middleware will transform the body and the signature will not match. Use express.raw() for this route.
TypeScript -- webhook handler (Express)
import express from 'express'
import { verifyWebhook } from './passimpay-signature'

const router = express.Router()

// IMPORTANT: use express.raw() -- NOT express.json() -- for this route.
// You need the raw body bytes for signature verification.
router.post(
  '/webhooks/passimpay',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const rawBody = req.body.toString('utf-8')
    const signature = req.headers['x-signature'] as string

    // Step 1 -- verify signature
    const valid = verifyWebhook(
      Number(process.env.PASSIMPAY_PLATFORM_ID),
      rawBody,
      process.env.PASSIMPAY_API_SECRET!,
      signature,
    )
    if (!valid) {
      return res.status(400).json({ error: 'INVALID_SIGNATURE' })
    }

    // Step 2 -- parse body
    const payload = JSON.parse(rawBody)

    // Step 3 -- idempotency check (orderId or transactionId)
    const idempotencyKey = payload.orderId ?? payload.transactionId
    const alreadyProcessed = await db.webhookEvents.exists(idempotencyKey)
    if (alreadyProcessed) {
      return res.status(200).json({ ok: true }) // return 200 to stop PSP retries
    }

    // Step 4 -- enqueue for orchestrator (do NOT process inline)
    await queue.push({ type: 'passimpay_webhook', payload, rawBody })

    // Step 5 -- respond 200 immediately
    // PassimPay retries up to 2 times if 200 is not returned promptly.
    return res.status(200).json({ ok: true })
  },
)
6.

PassimPay Specifics

H2H vs Invoice -- which to use

H2H (/v2/address): player pays any amount to a generated address. No redirect. Use for standard crypto deposits where you show the address in your own UI. Invoice (/v2/createorder): fixed amount, player is redirected to PassimPay-hosted form. Use when you need a fixed amount, on-ramp (fiat), or payment splitting.

Action:Use H2H by default. Switch to Invoice only for fixed-amount or fiat flows.

UTXO networks -- two webhooks (BTC / LTC / DASH / DOGE / BCH)

High

For Bitcoin, Litecoin, Dash, Dogecoin, and Bitcoin Cash, PassimPay sends two webhook callbacks: first at confirmations=1 (transaction in mempool), second at confirmations=2 (transaction finalized). EVM, TRX, TON send only one webhook (confirmations=0).

Action:Map confirmations=1 → PROCESSING (update UI only). Map confirmations=2 → COMPLETED (credit balance). Never credit on the first webhook.

XRP / TON / similar -- mandatory destination tag

High

/v2/address returns a destinationTag field for networks that require it (XRP, TON). If the player sends funds without the tag, PassimPay cannot identify the payment and the funds are effectively lost.

Action:Always display destinationTag prominently next to the address with a warning. For /v2/withdraw and /v2/fees with XRP/TON, format addressTo as "address:tag" (colon-separated).

Amount fields -- which one to use for crediting

High

Deposit webhook contains three amount fields: amount (raw crypto received), amountReceive (after PassimPay fees -- this is what lands in your PSP balance), feeService + feeNetwork (deducted fees). Withdrawal webhook contains amountDebited (definitive deduction from your PSP balance).

Action:For deposit: credit amountReceive (converted to USD) to player balance. For withdrawal: record amountDebited as the final PSP deduction. Always store both amount and amountReceive for audit.

Amount in crypto -- USD conversion required

Medium

/v2/withdraw requires amount in cryptocurrency, not USD. You must convert the player's USD withdrawal amount to crypto before calling the endpoint. Use /v2/estimated to get the current rate, or /v2/currencies for the rateUsd field.

Action:Call /v2/estimated before /v2/withdraw. Store the rate used for audit. Note: rate may shift between estimate and actual execution.

Duplicate webhook delivery

High

PassimPay retries webhook delivery up to 2 additional times if your server does not return HTTP 200. This means the same event may arrive 3 times. Without idempotency protection, you could credit a player's balance 3 times for one deposit.

Action:Check orderId (deposits) or transactionId (withdrawals) against a processed-events table before any state change. Return 200 immediately for duplicates.

orderId constraints

Medium

PassimPay orderId is limited to 64 characters and only allows: A-Za-z0-9+/=-:.,_ -- no spaces, no Unicode. Your internal payment_id (UUID) must be transformed before use.

Action:Strip hyphens from UUID or use Base64URL encoding. Store the mapping payment_id <-> orderId in the transactions table.

IP whitelist required for /v2/withdraw

High

PassimPay blocks withdrawal requests from non-whitelisted IPs. The IP of your server making outbound calls to PassimPay must be added in your platform settings at account.passimpay.io. Exceeding the rate limit (1 req/sec) causes an account block.

Action:Add server IP before testing withdrawals. In CI/CD: use a static IP or NAT gateway. Never call /v2/withdraw more than once per second.
7.

Rate Limits

EndpointLimitRisk
/v2/address10 req/secLow
/v2/createorder10 req/secLow
/v2/withdraw1 req/sec -- account blocked if exceededCritical
/v2/withdrawstatus10 req/secLow
/v2/orderstatus10 req/secLow
/v3/orderstatus10 req/secLow
/v2/currencies1 req/sec -- cache the resultMedium
/v2/balance10 req/secLow
/v2/estimated1 req/secMedium
/v2/fees1 req/secMedium
/v2/validateaddress10 req/secLow
/v2/inrout1 req/sec -- account blocked if exceededCritical
8.

Implementation Checklist

All Required items must be complete before Phase 1 sign-off. Verify against the latest PassimPay documentation before final deployment.

PASSIMPAY_PLATFORM_ID and PASSIMPAY_API_SECRET are set in environment

Required

Server outbound IP is whitelisted in PassimPay platform settings

Required

Webhook URL is set in PassimPay platform settings and publicly accessible

Required

signRequest() correctly escapes forward slashes in JSON body

Required

verifyWebhook() uses raw (unparsed) body string, not re-serialized JSON

Required

Webhook route uses express.raw() or equivalent -- NOT express.json()

Required

Idempotency check on orderId / transactionId before any state change

Required

UTXO networks: balance credited only on confirmations >= 2, not on first webhook

Required

XRP / TON: destinationTag shown in UI with warning; formatted as address:tag in API calls

Required

Deposits: amountReceive (not amount) converted to USD and credited to player

Required

Withdrawals: amount converted from USD to crypto using /v2/estimated before calling /v2/withdraw

Required

orderId is max 64 chars and only contains A-Za-z0-9+/=-:.,_ characters

Required

/v2/withdraw is never called more than once per second

Required

All PSP errors are caught and wrapped in UnifiedPaymentError before propagating

Required

All outbound PassimPay HTTP calls have explicit timeouts (10s for initiate, 5s for status)

Required

Withdrawal webhook approve=2: player balance is rolled back and player is notified

Required

/v2/currencies response is cached per platformId for up to 5 minutes

Recommended

psp_payment_id (PassimPay transactionId) is stored in transactions table for reconciliation

Recommended

Exchange rate used for USD -- crypto conversion is stored alongside the transaction for audit

Recommended

Reconciliation job calls /v2/withdrawstatus or /v3/orderstatus hourly for PROCESSING transactions

Recommended
DEPO44 | PASSIMPAY ADAPTER SPEC v1 | PHASE 1 | VERIFY AGAINST PASSIMPAY DOCS BEFORE IMPLEMENTATION