PSP #1 Adapter -- PassimPay
IPaymentProvider implementation for PassimPay. Full spec for the backend developer.
IPaymentProvider interface for the PassimPay API. Base URL: https://api.passimpay.io. Account: account.passimpay.io.Configuration
| Environment Variable | Required | Description |
|---|---|---|
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 |
Authentication & Signature
x-signature header. Contract: HMAC-SHA256(platformId + ";" + jsonBody + ";" + secret, secret). The JSON body must escape forward slashes as \/.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'),
)
}Interface Method → PassimPay API Mapping
| Interface Method | PassimPay Endpoint | When | Key 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 requests | Send: 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 events | Verify x-signature first. Parse type field: "deposit" or "withdraw". Map to UnifiedEvent. |
getSupportedMethods | POST /v2/currencies | Fetch available currencies for this platform | Send: platformId. Receive: list[] with id, currency, network, rateUsd, minDep, minWithdraw. Cache up to 5 min. |
PassimPay Status → UnifiedStatus Mapping
| Event / Trigger | Field | Value | → UnifiedStatus | Note |
|---|---|---|---|---|
| Deposit: /v2/address called | -- | -- | INITIATED | -- |
| Deposit webhook: confirmations = 1 (UTXO networks) | confirmations | 1 | PROCESSING | BTC / LTC / DASH / DOGE / BCH only. Do NOT credit balance yet. |
| Deposit webhook: confirmations >= 2 (UTXO) OR confirmations = 0 (EVM / TRX / TON) | confirmations | ≥2 / 0 | COMPLETED | Credit amountReceive (after fees) to player balance. |
| Invoice webhook: status = "waiting" (partial payment) | status | waiting | PENDING_PARTIAL | Invoice method only. Business decision: credit actual or wait. |
| Invoice webhook: status = "error" (expired or partial fail) | status | error | FAILED | -- |
| Withdrawal webhook: approve = 0 (processing) | approve | 0 | PROCESSING | -- |
| Withdrawal webhook: approve = 1 (success) | approve | 1 | COMPLETED | Use amountDebited for final deduction from PSP balance. |
| Withdrawal webhook: approve = 2 (error) | approve | 2 | FAILED | Mandatory balance rollback. Notify player. |
| Internal TTL expired -- no webhook received | -- | -- | TIMED_OUT | PassimPay only sends callbacks on success. No callback = timeout. Run reconciliation job. |
Webhook Handling
express.json() middleware will transform the body and the signature will not match. Use express.raw() for this route.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 })
},
)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.
UTXO networks -- two webhooks (BTC / LTC / DASH / DOGE / BCH)
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).
XRP / TON / similar -- mandatory destination tag
/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.
Amount fields -- which one to use for crediting
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).
Amount in crypto -- USD conversion required
/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.
Duplicate webhook delivery
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.
orderId constraints
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.
IP whitelist required for /v2/withdraw
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.
Rate Limits
| Endpoint | Limit | Risk |
|---|---|---|
/v2/address | 10 req/sec | Low |
/v2/createorder | 10 req/sec | Low |
/v2/withdraw | 1 req/sec -- account blocked if exceeded | Critical |
/v2/withdrawstatus | 10 req/sec | Low |
/v2/orderstatus | 10 req/sec | Low |
/v3/orderstatus | 10 req/sec | Low |
/v2/currencies | 1 req/sec -- cache the result | Medium |
/v2/balance | 10 req/sec | Low |
/v2/estimated | 1 req/sec | Medium |
/v2/fees | 1 req/sec | Medium |
/v2/validateaddress | 10 req/sec | Low |
/v2/inrout | 1 req/sec -- account blocked if exceeded | Critical |
Implementation Checklist
PASSIMPAY_PLATFORM_ID and PASSIMPAY_API_SECRET are set in environment
Server outbound IP is whitelisted in PassimPay platform settings
Webhook URL is set in PassimPay platform settings and publicly accessible
signRequest() correctly escapes forward slashes in JSON body
verifyWebhook() uses raw (unparsed) body string, not re-serialized JSON
Webhook route uses express.raw() or equivalent -- NOT express.json()
Idempotency check on orderId / transactionId before any state change
UTXO networks: balance credited only on confirmations >= 2, not on first webhook
XRP / TON: destinationTag shown in UI with warning; formatted as address:tag in API calls
Deposits: amountReceive (not amount) converted to USD and credited to player
Withdrawals: amount converted from USD to crypto using /v2/estimated before calling /v2/withdraw
orderId is max 64 chars and only contains A-Za-z0-9+/=-:.,_ characters
/v2/withdraw is never called more than once per second
All PSP errors are caught and wrapped in UnifiedPaymentError before propagating
All outbound PassimPay HTTP calls have explicit timeouts (10s for initiate, 5s for status)
Withdrawal webhook approve=2: player balance is rolled back and player is notified
/v2/currencies response is cached per platformId for up to 5 minutes
psp_payment_id (PassimPay transactionId) is stored in transactions table for reconciliation
Exchange rate used for USD -- crypto conversion is stored alongside the transaction for audit
Reconciliation job calls /v2/withdrawstatus or /v3/orderstatus hourly for PROCESSING transactions