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 \/.using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
public static class PassimPaySignature
{
/// Sign a PassimPay API request.
/// Contract: HMAC-SHA256( platformId + ";" + jsonBody + ";" + secret, secret )
/// IMPORTANT: PassimPay expects escaped forward slashes (\/).
public static string SignRequest(int platformId, object body, string secret)
{
var jsonBody = JsonSerializer.Serialize(body).Replace("/", "\/");
var contract = $"{platformId};{jsonBody};{secret}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(contract));
return Convert.ToHexString(hash).ToLower();
}
/// Verify an incoming PassimPay webhook.
/// Same contract -- compare computed signature with x-signature header.
public static bool VerifyWebhook(
int platformId,
string rawBody, // raw unparsed request body string
string secret,
string receivedSignature)
{
var contract = $"{platformId};{rawBody};{secret}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(contract))).ToLower();
return CryptographicOperations.FixedTimeEquals(
Convert.FromHexString(expected),
Convert.FromHexString(receivedSignature)
);
}
}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
[FromBody] — ASP.NET Core will deserialize the body and the signature will not match. Read Request.Body manually via StreamReader.[ApiController]
[Route("webhooks")]
public class PassimPayWebhookController : ControllerBase
{
private readonly IWebhookEventRepository _events;
private readonly IPaymentQueue _queue;
public PassimPayWebhookController(
IWebhookEventRepository events,
IPaymentQueue queue)
{
_events = events;
_queue = queue;
}
// IMPORTANT: read raw body BEFORE any middleware deserializes it.
// Do NOT use [FromBody] -- that would parse the body and break signature verification.
[HttpPost("passimpay")]
public async Task<IActionResult> HandleWebhook()
{
// Step 1 -- read raw body (required for signature verification)
using var reader = new StreamReader(Request.Body, Encoding.UTF8);
var rawBody = await reader.ReadToEndAsync();
var signature = Request.Headers["x-signature"].ToString();
var platformId = int.Parse(Environment.GetEnvironmentVariable("PASSIMPAY_PLATFORM_ID")!);
var secret = Environment.GetEnvironmentVariable("PASSIMPAY_API_SECRET")!;
// Step 2 -- verify signature
if (!PassimPaySignature.VerifyWebhook(platformId, rawBody, secret, signature))
return BadRequest(new { error = "INVALID_SIGNATURE" });
// Step 3 -- parse body
var payload = JsonSerializer.Deserialize<JsonElement>(rawBody);
// Step 4 -- idempotency check (orderId or transactionId)
var idempotencyKey =
payload.TryGetProperty("orderId", out var orderId) ? orderId.GetString() :
payload.GetProperty("transactionId").GetString();
if (await _events.ExistsAsync(idempotencyKey!))
return Ok(new { ok = true }); // return 200 to stop PSP retries
// Step 5 -- enqueue for orchestrator (do NOT process inline)
await _queue.PushAsync(new PassimPayWebhookMessage { RawBody = rawBody });
// Step 6 -- respond 200 immediately
// PassimPay retries up to 2 times if 200 is not returned promptly.
return Ok(new { 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)
HighFor 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
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.
Amount fields -- which one to use for crediting
HighDeposit 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
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.
Duplicate webhook delivery
HighPassimPay 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
MediumPassimPay 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
HighPassimPay 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
RequiredServer outbound IP is whitelisted in PassimPay platform settings
RequiredWebhook URL is set in PassimPay platform settings and publicly accessible
RequiredsignRequest() correctly escapes forward slashes in JSON body
RequiredverifyWebhook() uses raw (unparsed) body string, not re-serialized JSON
RequiredWebhook route uses express.raw() or equivalent -- NOT express.json()
RequiredIdempotency check on orderId / transactionId before any state change
RequiredUTXO networks: balance credited only on confirmations >= 2, not on first webhook
RequiredXRP / TON: destinationTag shown in UI with warning; formatted as address:tag in API calls
RequiredDeposits: amountReceive (not amount) converted to USD and credited to player
RequiredWithdrawals: amount converted from USD to crypto using /v2/estimated before calling /v2/withdraw
RequiredorderId is max 64 chars and only contains A-Za-z0-9+/=-:.,_ characters
Required/v2/withdraw is never called more than once per second
RequiredAll PSP errors are caught and wrapped in UnifiedPaymentError before propagating
RequiredAll outbound PassimPay HTTP calls have explicit timeouts (10s for initiate, 5s for status)
RequiredWithdrawal 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
Recommendedpsp_payment_id (PassimPay transactionId) is stored in transactions table for reconciliation
RecommendedExchange rate used for USD -- crypto conversion is stored alongside the transaction for audit
RecommendedReconciliation job calls /v2/withdrawstatus or /v3/orderstatus hourly for PROCESSING transactions
Recommended