Developer Documentation

PayVault API

Integrate secure EPS payments into your application. Accept payments, manage refunds, and receive real-time notifications.

Overview

PayVault is a self-hosted payment gateway that processes payments through EPS (Electronic Payment System). Your application creates a bill via the API, receives a payment URL, and redirects customers to a hosted payment page where they complete payment via bKash, Nagad, card, or mobile banking.

Base URL

All API endpoints use the base path: /api/v1

Example: https://payvault.trialvo.com/api/v1/bills

Sandbox Mode

Services can be created in sandbox mode. Sandbox transactions use the EPS test environment and don't process real payments. Use sandbox for development and testing.

Quick Start — Your First Payment in 5 Minutes

Follow these steps to create your first bill and accept a payment.

1
Get Your Credentials

Ask your PayVault administrator to create a service for your project. You will receive:

  • Service ID — a UUID identifying your service
  • API Key — a secret key for signing requests (64-character hex string)
2
Create a Bill

From your backend, send a signed POST /api/v1/bills request with customer and order details. You get back a pay_url.

3
Redirect Customer to pay_url

Redirect your customer to the pay_url. PayVault shows a hosted payment page. The customer selects a payment method (bKash, Nagad, card, etc.) and completes payment.

4
Receive IPN Webhook

After payment completes (or fails), PayVault sends a POST webhook to your registered IPN endpoint with the payment result. Verify the signature and update your order.

5
Customer Returns to Your Site

After payment, the customer is redirected to your success_url, fail_url, or cancel_url. Do not rely on this redirect for order fulfillment — always use the IPN webhook.

Important Security Rule

Never make API calls from the frontend. Your API Key must be kept secret and used only from your backend server. The HMAC signature must be computed server-side.

Getting Your Credentials

Your PayVault admin sets up your service. Here's what you'll receive and where to find it:

1. Service ID

A UUID like f47ac10b-58cc-4372-a567-0e02b2c3d479. Found in the admin panel under Services. This is public — it identifies which service is making a request.

2. API Key

A 64-character hex string like a1b2c3d4e5f6.... Generated in admin panel under Services → Generate Key. The full key can be revealed by clicking Reveal Key on the service detail page.

Key Security

Treat your API Key like a password. Store it in environment variables or a secrets manager. Never commit it to version control. If compromised, ask your admin to revoke it and generate a new one.

3. IPN Secret

When your admin registers an IPN endpoint for your service, a separate IPN secret is generated. This is used to verify that incoming webhooks are genuinely from PayVault.

Payment Flow

Here's the complete lifecycle of a payment:

1

Your Backend → PayVault API

Call POST /api/v1/bills with order + customer details. You receive a pay_url and bill_token.

2

Customer → Payment Page

Redirect customer to pay_url. PayVault displays a hosted payment page with available payment methods.

3

Payment Processing

Customer pays via EPS (bKash, Nagad, card, mobile banking). EPS processes the payment and sends a callback to PayVault.

4

PayVault → Your IPN Endpoint

PayVault updates the bill status and sends a signed POST webhook to your registered IPN endpoint with the result.

5

Customer → Your Website

Customer is redirected to your success_url, fail_url, or cancel_url. This is for UX only — fulfillment must be based on the IPN.

Authentication — HMAC-SHA256 Signing

Every API request must include authentication headers with an HMAC-SHA256 signature. This ensures request integrity, prevents tampering, and authenticates your service.

Required Headers

HeaderDescriptionExample
Content-TypeMust be application/jsonapplication/json
X-Service-IdYour service UUIDf47ac10b-58cc-...
X-Api-KeyYour full API key (64-char hex)a1b2c3d4e5f6...
X-TimestampCurrent Unix timestamp (seconds)1750572622
X-NonceUnique random string (UUID recommended)550e8400-e29b-...
X-Body-HashSHA-256 of request body (hex, 64 chars)5d41402abc4b2a...
X-SignatureHMAC-SHA256 signature (hex, 64 chars)f7bc83f430538...

Step-by-Step Signature Computation

  1. Serialize your JSON request body as a string.
  2. Hash the body: body_hash = SHA-256(body_string) → hex string (64 chars). For GET/DELETE requests with no body, use the SHA-256 of an empty string: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
  3. Build the message: concatenate with colons: {service_id}:{timestamp}:{nonce}:{body_hash}
  4. Sign: signature = HMAC-SHA256(api_key, message) → hex string (64 chars). The API key is used as raw UTF-8 bytes (the string itself, not hex-decoded).
// Pseudocode
body_string  = '{"customer_name":"John",...}'
body_hash    = SHA256(body_string)                                    // hex, 64 chars
message      = "{service_id}:{timestamp}:{nonce}:{body_hash}"
signature    = HMAC_SHA256(api_key_as_utf8_bytes, message)            // hex, 64 chars

Replay Protection

PayVault enforces two layers of replay protection:

CheckRuleError if violated
TimestampRequest must be within 5 minutes in the past or 60 seconds in the future of server timeRequest timestamp expired
NonceEach nonce can only be used once within a 10-minute window (per service)Nonce already used (replay attack)
Clock Synchronization

Ensure your server's clock is synchronized with NTP. If your server clock drifts more than 5 minutes from UTC, all requests will be rejected.

Create Bill

POST /api/v1/bills

Creates a new payment bill and returns a payment URL to redirect the customer to.

Request Body

FieldTypeRequiredDescription
customer_namestringCustomer full name
customer_emailstringCustomer email address
customer_phonestringCustomer phone number
customer_addressstringBilling address line 1
customer_citystringBilling city
customer_statestringBilling state / division
customer_postcodestringBilling postal code
subtotaldecimalSubtotal before discounts/tax (e.g. "1500.00")
final_amountdecimalFinal amount to charge the customer
itemsarrayArray of bill items (see below)
external_order_idstringYour system's order ID (returned in IPN)
external_invoice_idstringYour invoice reference
currencystringCurrency code (default: BDT)
payment_typestringone_time (default) or subscription
total_discountdecimalTotal discount applied
tax_amountdecimalTax amount
shipping_amountdecimalShipping cost
success_urlstringRedirect URL on payment success
fail_urlstringRedirect URL on payment failure
cancel_urlstringRedirect URL if customer cancels
metaobjectCustom metadata — stored and returned in IPN

Bill Item Fields

FieldTypeRequiredDescription
product_namestringName of the product
quantityintegerQuantity purchased
unit_selling_pricedecimalPrice per unit before discount
unit_final_pricedecimalFinal price per unit after discounts
external_product_idstringYour product SKU / ID
product_categorystringProduct category
unit_buying_pricedecimalCost price per unit (for margin tracking)
unit_discountdecimalDiscount per unit
metaobjectPer-item custom metadata

Response 201 Created

{
  "success": true,
  "bill_token": "pv_b_a1b2c3d4e5f6...",
  "pay_url": "https://payvault.trialvo.com/pay/pv_b_a1b2c3d4e5f6...",
  "expires_at": "2026-06-22T08:30:00+00:00"
}

Bills expire after a configurable period (default: 30 minutes). Once expired, the customer can no longer pay.

Get Bill Status

GET /api/v1/bills/{bill_token}

Retrieve the current status of a bill. Use this to verify payment status after receiving an IPN notification.

For GET requests with no body, set X-Body-Hash to e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 (SHA-256 of empty string).

Bill Statuses

StatusDescription
pendingBill created, awaiting payment
processingPayment initiated, waiting for gateway response
paidPayment completed successfully
failedPayment attempt failed
cancelledBill was cancelled before payment
expiredBill expired before payment (configurable timeout)
refundedPayment was fully refunded

Cancel Bill

DELETE /api/v1/bills/{bill_token}

Cancel a pending bill. Only bills in pending status can be cancelled. Cancelling a bill that is already paid, failed, or expired will return a 400 error.

Response 200 OK

{
  "success": true,
  "message": "Bill cancelled"
}

Request Refund

POST /api/v1/refunds

Submit a refund request for a paid bill. All refunds require manual admin approval before being processed — they are not automatic.

Request Body

FieldTypeRequiredDescription
bill_tokenstringThe bill token of the paid bill
refund_amountdecimalAmount to refund (must be > 0 and ≤ bill amount)
refund_reasonstringReason for the refund
refund_typestring"full" or "partial" — auto-detected from amount if omitted
external_refstringYour internal reference for this refund

Business Rules

  • Only bills with status paid or partially_paid can be refunded
  • Refunds are rejected if the bill is older than the configured refund window (default: 30 days)
  • Refund amount cannot exceed the bill's final_amount

Response 201 Created

{
  "success": true,
  "refund_id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "requested",
  "message": "Refund request submitted. Pending admin approval."
}

Get Refund Status

GET /api/v1/refunds/{refund_id}

Check the status of a refund request.

Refund Statuses

StatusDescription
requestedRefund submitted, pending admin review
approvedAdmin approved — refund is being processed
rejectedAdmin rejected the refund request
completedRefund processed and money returned

Get Transaction

GET /api/v1/transactions/{transaction_id}

Retrieve details of a payment transaction, including its event history (initiated, callback, success/failure).

Response 200 OK

{
  "transaction": {
    "id": "uuid",
    "bill_id": "uuid",
    "eps_merchant_tx_id": "PV_20260622_abc123",
    "status": "success",
    "amount": "1500.00",
    "currency": "BDT",
    "created_at": "2026-06-22T07:20:00Z"
  },
  "events": [
    { "event_type": "initiated", "created_at": "2026-06-22T07:18:00Z" },
    { "event_type": "callback_received", "created_at": "2026-06-22T07:20:00Z" },
    { "event_type": "success", "created_at": "2026-06-22T07:20:01Z" }
  ]
}

IPN Webhooks

When a payment status changes, PayVault sends a POST request to your registered IPN (Instant Payment Notification) endpoint. IPN endpoints are configured by your admin via the admin panel.

IPN Event Types

EventTriggered When
payment.successCustomer completed payment successfully
payment.failedPayment attempt was declined or errored
payment.cancelledCustomer cancelled on the payment page
refund.approvedAdmin approved a refund request
refund.completedRefund has been processed

IPN Headers

HeaderDescription
Content-Typeapplication/json
X-PayVault-SignatureHMAC-SHA256 signature of the raw body, using your IPN secret
X-PayVault-EventAlways ipn

IPN Payload Format

The exact fields in the IPN payload for payment events (based on the actual server code):

{
  "event": "payment.success",
  "bill_token": "pv_b_a1b2c3d4e5f6...",
  "transaction_id": "PV_20260622_abc123",
  "eps_transaction_id": "EPS_TXN_789",
  "amount": "1500.00",
  "currency": "BDT",
  "status": "paid",
  "financial_entity": "bKash",
  "customer_id": "CUST_123",
  "payment_reference": "REF_456",
  "transaction_date": "2026-06-22 07:20:00",
  "external_order_id": "ORD-001",
  "external_subscription_id": null,
  "value_a": "...",
  "value_b": "...",
  "value_c": "...",
  "timestamp": "2026-06-22T07:20:01+00:00"
}
Key Fields for Your Backend

bill_token — use to look up the original order in your system. external_order_id — your order ID as submitted. status — the bill's new status. financial_entity — which payment method was used (bKash, Nagad, etc.).

IPN Delivery & Retries

Verify IPN Signatures

Every IPN request includes an X-PayVault-Signature header. You must verify this signature to ensure the webhook is genuine and hasn't been tampered with.

expected_signature = HMAC-SHA256(your_ipn_secret, raw_request_body) → hex
// Compare expected_signature with X-PayVault-Signature header (constant-time comparison)
Never Skip Verification

Without signature verification, an attacker could send fake payment.success notifications to your endpoint, tricking your system into fulfilling orders that were never paid for.

Integration Examples

Complete working examples for creating a bill. Copy and adapt to your project.

Python
import hmac, hashlib, time, uuid, json, requests # ─── Your credentials (store in env vars!) ─────────────────────── SERVICE_ID = "your-service-uuid" # From admin panel → Services API_KEY = "your-64-char-hex-api-key" # From admin panel → Reveal Key BASE_URL = "https://payvault.trialvo.com/api/v1" def payvault_request(method, path, body_dict=None): """Make a signed request to the PayVault API.""" body = json.dumps(body_dict) if body_dict else "" timestamp = str(int(time.time())) nonce = str(uuid.uuid4()) body_hash = hashlib.sha256(body.encode()).hexdigest() message = f"{SERVICE_ID}:{timestamp}:{nonce}:{body_hash}" signature = hmac.new( API_KEY.encode(), message.encode(), hashlib.sha256 ).hexdigest() headers = { "Content-Type": "application/json", "X-Service-Id": SERVICE_ID, "X-Api-Key": API_KEY, "X-Timestamp": timestamp, "X-Nonce": nonce, "X-Body-Hash": body_hash, "X-Signature": signature, } resp = requests.request(method, f"{BASE_URL}{path}", headers=headers, data=body or None) resp.raise_for_status() return resp.json() # ─── Create a bill ─────────────────────────────────────────────── bill = payvault_request("POST", "/bills", { "customer_name": "Rahim Ahmed", "customer_email": "rahim@example.com", "customer_phone": "+8801712345678", "customer_address": "123 Gulshan Ave", "customer_city": "Dhaka", "customer_state": "Dhaka", "customer_postcode": "1212", "subtotal": "1500.00", "final_amount": "1500.00", "external_order_id": "ORD-001", "success_url": "https://yoursite.com/payment/success", "fail_url": "https://yoursite.com/payment/failed", "cancel_url": "https://yoursite.com/payment/cancelled", "items": [{ "product_name": "Premium Plan", "quantity": 1, "unit_selling_price": "1500.00", "unit_final_price": "1500.00", }] }) print(f"Redirect customer to: {bill['pay_url']}") # ─── Check bill status (GET — no body) ────────────────────────── status = payvault_request("GET", f"/bills/{bill['bill_token']}") print(f"Bill status: {status['status']}")
Node.js
const crypto = require('crypto'); // ─── Your credentials (store in env vars!) ─────────────────────── const SERVICE_ID = 'your-service-uuid'; const API_KEY = 'your-64-char-hex-api-key'; const BASE_URL = 'https://payvault.trialvo.com/api/v1'; async function payvaultRequest(method, path, bodyObj = null) { const body = bodyObj ? JSON.stringify(bodyObj) : ''; const timestamp = Math.floor(Date.now() / 1000).toString(); const nonce = crypto.randomUUID(); const bodyHash = crypto.createHash('sha256').update(body).digest('hex'); const message = `${SERVICE_ID}:${timestamp}:${nonce}:${bodyHash}`; const signature = crypto.createHmac('sha256', API_KEY) .update(message).digest('hex'); const res = await fetch(`${BASE_URL}${path}`, { method, headers: { 'Content-Type': 'application/json', 'X-Service-Id': SERVICE_ID, 'X-Api-Key': API_KEY, 'X-Timestamp': timestamp, 'X-Nonce': nonce, 'X-Body-Hash': bodyHash, 'X-Signature': signature, }, body: body || undefined, }); if (!res.ok) { const text = await res.text(); throw new Error(`PayVault ${res.status}: ${text}`); } return res.json(); } // ─── Create a bill ─────────────────────────────────────────────── const bill = await payvaultRequest('POST', '/bills', { customer_name: 'Rahim Ahmed', customer_email: 'rahim@example.com', customer_phone: '+8801712345678', customer_address: '123 Gulshan Ave', customer_city: 'Dhaka', customer_state: 'Dhaka', customer_postcode: '1212', subtotal: '1500.00', final_amount: '1500.00', external_order_id: 'ORD-001', success_url: 'https://yoursite.com/payment/success', fail_url: 'https://yoursite.com/payment/failed', cancel_url: 'https://yoursite.com/payment/cancelled', items: [{ product_name: 'Premium Plan', quantity: 1, unit_selling_price: '1500.00', unit_final_price: '1500.00', }] }); console.log('Redirect customer to:', bill.pay_url); // ─── Check bill status ────────────────────────────────────────── const status = await payvaultRequest('GET', `/bills/${bill.bill_token}`); console.log('Bill status:', status.status);
PHP
<?php // ─── Your credentials (store in env vars!) ─────────────────────── $SERVICE_ID = 'your-service-uuid'; $API_KEY = 'your-64-char-hex-api-key'; $BASE_URL = 'https://payvault.trialvo.com/api/v1'; function payvaultRequest($method, $path, $bodyObj = null) { global $SERVICE_ID, $API_KEY, $BASE_URL; $body = $bodyObj ? json_encode($bodyObj) : ''; $timestamp = (string) time(); $nonce = bin2hex(random_bytes(16)); $bodyHash = hash('sha256', $body); $message = "$SERVICE_ID:$timestamp:$nonce:$bodyHash"; $signature = hash_hmac('sha256', $message, $API_KEY); $ch = curl_init("$BASE_URL$path"); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_POSTFIELDS => $body ?: null, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', "X-Service-Id: $SERVICE_ID", "X-Api-Key: $API_KEY", "X-Timestamp: $timestamp", "X-Nonce: $nonce", "X-Body-Hash: $bodyHash", "X-Signature: $signature", ], ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode >= 400) { throw new Exception("PayVault error ($httpCode): $response"); } return json_decode($response, true); } // ─── Create a bill ─────────────────────────────────────────────── $bill = payvaultRequest('POST', '/bills', [ 'customer_name' => 'Rahim Ahmed', 'customer_email' => 'rahim@example.com', 'customer_phone' => '+8801712345678', 'customer_address' => '123 Gulshan Ave', 'customer_city' => 'Dhaka', 'customer_state' => 'Dhaka', 'customer_postcode' => '1212', 'subtotal' => '1500.00', 'final_amount' => '1500.00', 'external_order_id' => 'ORD-001', 'success_url' => 'https://yoursite.com/payment/success', 'fail_url' => 'https://yoursite.com/payment/failed', 'cancel_url' => 'https://yoursite.com/payment/cancelled', 'items' => [[ 'product_name' => 'Premium Plan', 'quantity' => 1, 'unit_selling_price' => '1500.00', 'unit_final_price' => '1500.00', ]] ]); echo "Redirect customer to: " . $bill['pay_url'] . "\n"; ?>
Bash / cURL
# ─── Step 1: Set your credentials ──────────────────────────────── SERVICE_ID="your-service-uuid" API_KEY="your-64-char-hex-api-key" BASE_URL="https://payvault.trialvo.com/api/v1" # ─── Step 2: Prepare the request body ──────────────────────────── BODY='{"customer_name":"Rahim Ahmed","customer_email":"rahim@example.com","customer_phone":"+8801712345678","customer_address":"123 Gulshan Ave","customer_city":"Dhaka","customer_state":"Dhaka","customer_postcode":"1212","subtotal":"1500.00","final_amount":"1500.00","external_order_id":"ORD-001","success_url":"https://yoursite.com/payment/success","fail_url":"https://yoursite.com/payment/failed","cancel_url":"https://yoursite.com/payment/cancelled","items":[{"product_name":"Premium Plan","quantity":1,"unit_selling_price":"1500.00","unit_final_price":"1500.00"}]}' # ─── Step 3: Compute authentication values ─────────────────────── TIMESTAMP=$(date +%s) NONCE=$(uuidgen || python3 -c "import uuid; print(uuid.uuid4())") BODY_HASH=$(echo -n "$BODY" | sha256sum | awk '{print $1}') MESSAGE="${SERVICE_ID}:${TIMESTAMP}:${NONCE}:${BODY_HASH}" SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "$API_KEY" | awk '{print $2}') # ─── Step 4: Make the request ──────────────────────────────────── curl -X POST "${BASE_URL}/bills" \ -H "Content-Type: application/json" \ -H "X-Service-Id: ${SERVICE_ID}" \ -H "X-Api-Key: ${API_KEY}" \ -H "X-Timestamp: ${TIMESTAMP}" \ -H "X-Nonce: ${NONCE}" \ -H "X-Body-Hash: ${BODY_HASH}" \ -H "X-Signature: ${SIGNATURE}" \ -d "$BODY"

IPN Verification Examples

Verify incoming webhooks in your IPN handler. Always verify before processing.

Python (Flask)
import hmac, hashlib from flask import Flask, request, jsonify IPN_SECRET = "your-ipn-secret" # From admin panel → IPN Endpoints app = Flask(__name__) @app.route("/webhook/payvault", methods=["POST"]) def payvault_ipn(): # 1. Get the raw body and signature header raw_body = request.get_data(as_text=True) signature = request.headers.get("X-PayVault-Signature", "") # 2. Compute expected signature expected = hmac.new( IPN_SECRET.encode(), raw_body.encode(), hashlib.sha256 ).hexdigest() # 3. Verify (constant-time comparison) if not hmac.compare_digest(expected, signature): return jsonify({"error": "Invalid signature"}), 403 # 4. Process the event data = request.get_json() event = data["event"] bill_token = data["bill_token"] if event == "payment.success": # Fulfill the order in your system print(f"Payment success for bill {bill_token}") # TODO: update order status, send confirmation email, etc. elif event == "payment.failed": print(f"Payment failed for bill {bill_token}") return jsonify({"received": True}), 200
Node.js (Express)
const crypto = require('crypto'); const express = require('express'); const app = express(); const IPN_SECRET = 'your-ipn-secret'; // IMPORTANT: Use raw body for signature verification app.post('/webhook/payvault', express.raw({ type: 'application/json' }), (req, res) => { const rawBody = req.body.toString('utf8'); const signature = req.headers['x-payvault-signature'] || ''; // Compute expected signature const expected = crypto.createHmac('sha256', IPN_SECRET) .update(rawBody).digest('hex'); // Constant-time comparison if (!crypto.timingSafeEqual( Buffer.from(expected, 'hex'), Buffer.from(signature, 'hex') )) { return res.status(403).json({ error: 'Invalid signature' }); } // Process the event const data = JSON.parse(rawBody); if (data.event === 'payment.success') { console.log(`Payment success for ${data.bill_token}`); // TODO: fulfill order } res.json({ received: true }); } );
PHP
<?php $IPN_SECRET = 'your-ipn-secret'; // 1. Read raw body $rawBody = file_get_contents('php://input'); $signature = $_SERVER['HTTP_X_PAYVAULT_SIGNATURE'] ?? ''; // 2. Compute expected signature $expected = hash_hmac('sha256', $rawBody, $IPN_SECRET); // 3. Verify (constant-time comparison) if (!hash_equals($expected, $signature)) { http_response_code(403); echo json_encode(['error' => 'Invalid signature']); exit; } // 4. Process the event $data = json_decode($rawBody, true); if ($data['event'] === 'payment.success') { // Fulfill the order error_log("Payment success for bill " . $data['bill_token']); // TODO: update order status } http_response_code(200); echo json_encode(['received' => true]); ?>

Error Handling

All error responses follow a consistent JSON format:

{ "error": "Human-readable error message" }

HTTP Status Codes

CodeMeaningCommon Causes
201CreatedBill or refund created successfully
200OKRequest successful
400Bad RequestInvalid parameters, business logic error (e.g. refunding an unpaid bill)
401UnauthorizedMissing headers, invalid API key, expired timestamp, bad signature, reused nonce
403ForbiddenTrying to access another service's bill/refund/transaction
404Not FoundBill token, refund ID, or transaction ID doesn't exist
500Server ErrorInternal error — retry with exponential backoff

Troubleshooting 401 Errors

Error MessageCauseFix
Missing X-Service-Id headerHeader not sentInclude all 7 required headers
Invalid API keyKey not found in databaseVerify you're using the full key, not just the prefix
Request timestamp expiredClock skew > 5 minutesSync your server clock with NTP
Nonce already usedSame nonce sent twiceGenerate a new UUID for every request
Invalid HMAC signatureSignature mismatchCheck message format: svc_id:ts:nonce:body_hash. Verify API key is UTF-8 encoded.
Service is deactivatedAdmin disabled your serviceContact your PayVault admin
Service ID mismatchX-Service-Id doesn't match the key's serviceEnsure service ID and key belong to the same service

Retry Strategy

For 500 errors, implement exponential backoff:

For 401 and 400 errors, do not retry — fix the request first.

Need Help?

Contact your PayVault administrator for API credentials, IPN endpoint setup, and technical support. For self-hosted deployments, refer to the project README.