Paynex Merchant API
Create a payment, redirect your customer to the hosted checkout, and get notified by a signed webhook when the money lands. Cards, mobile wallets and bank transfers — one integration.
Overview
POST /v1/payments with amount, invoice number and return URLs.checkout_url. They choose bKash, Nagad, card, bank transfer…payment.paid webhook. You verify it, fetch the payment, and deliver the order.Amounts are decimal strings with up to 2 decimals ("1500.00"). Times are RFC 3339 with timezone. IDs are UUIDs.
Quick start
- In the Paynex admin open API Keys and create a key for your store. You get a
key_id(pk_live_…/pk_sandbox_…) and asecret(sk_…). The secret is shown once. - In Store settings, add the domains your
return_url/cancel_urluse and set a Webhook URL. Copy the webhook secret. - Create a payment (signed, see Authentication):
POST https://paynex.xyz/v1/payments
Idempotency-Key: order-1042
{
"amount": "1500.00",
"currency": "BDT",
"merchant_ref": "INV-1042",
"description": "Hosting renewal",
"customer": { "name": "Md Samiul Alam", "email": "[email protected]", "phone": "01712345678" },
"return_url": "https://yoursite.com/pay/return",
"cancel_url": "https://yoursite.com/pay/cancel",
"expires_in": 1800
}
- Redirect the customer to
checkout_urlfrom the response. - On the
payment.paidwebhook: verify the signature, callGET /v1/payments/{id}, and mark the order paid only if that response says"status": "paid".
Authentication
Every /v1 request except /v1/health carries four headers:
| Header | Value |
|---|---|
Authorization | Paynex-HMAC key_id=<key_id> |
X-PG-Timestamp | Unix seconds. Must be within ±300 s of server time. |
X-PG-Nonce | Random string (16 random bytes, hex). Single use; replays within 10 minutes are rejected. |
X-PG-Signature | Lower-case hex HMAC-SHA256 (recipe below). |
Signature recipe
message = timestamp + "\n" + nonce + "\n" + METHOD + "\n" + path + "\n" + sha256_hex(body)
signature = hex( HMAC-SHA256( secret, message ) )
METHODupper-case (POST,GET).path= URL path only, including/v1, excluding the query string.body= the exact raw bytes you send. For GET it is empty:sha256_hex("")=e3b0c442…b855.- Send the body byte-for-byte as signed; do not re-encode JSON after signing.
POST /v1/payments. Reuse the same key (e.g. your order id) to retry safely: you get the same payment back with HTTP 200 instead of a new one with 201.Code samples
function paynexRequest(string $method, string $path, ?array $json = null): array {
$base = 'https://paynex.xyz';
$keyId = getenv('PAYNEX_KEY_ID');
$secret = getenv('PAYNEX_SECRET');
$body = $json === null ? '' : json_encode($json, JSON_UNESCAPED_SLASHES);
$ts = (string) time();
$nonce = bin2hex(random_bytes(16));
$sign = strtok($path, '?'); // sign the path only, never the query string
$msg = $ts . "\n" . $nonce . "\n" . strtoupper($method) . "\n" . $sign . "\n" . hash('sha256', $body);
$sig = hash_hmac('sha256', $msg, $secret);
$headers = [
"Authorization: Paynex-HMAC key_id=$keyId",
"X-PG-Timestamp: $ts",
"X-PG-Nonce: $nonce",
"X-PG-Signature: $sig",
"Content-Type: application/json",
];
if ($method === 'POST' && $path === '/v1/payments') {
$headers[] = 'Idempotency-Key: ' . ($json['merchant_ref'] ?? $nonce);
}
$ch = curl_init($base . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => strtoupper($method),
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
]);
$res = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [$code, json_decode($res, true)];
}
// usage
[$code, $p] = paynexRequest('POST', '/v1/payments', [
'amount' => '1500.00', 'currency' => 'BDT', 'merchant_ref' => 'INV-1042',
'return_url' => 'https://yoursite.com/pay/return', 'cancel_url' => 'https://yoursite.com/pay/cancel',
]);
header('Location: ' . $p['checkout_url']);import crypto from 'node:crypto';
export async function paynex(method, path, json) {
const base = 'https://paynex.xyz', keyId = process.env.PAYNEX_KEY_ID, secret = process.env.PAYNEX_SECRET;
const body = json ? JSON.stringify(json) : '';
const ts = Math.floor(Date.now() / 1000).toString();
const nonce = crypto.randomBytes(16).toString('hex');
const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
const signPath = path.split('?')[0]; // sign the path only, never the query string
const msg = `${ts}\n${nonce}\n${method.toUpperCase()}\n${signPath}\n${bodyHash}`;
const sig = crypto.createHmac('sha256', secret).update(msg).digest('hex');
const headers = {
Authorization: `Paynex-HMAC key_id=${keyId}`,
'X-PG-Timestamp': ts, 'X-PG-Nonce': nonce, 'X-PG-Signature': sig,
'Content-Type': 'application/json',
};
if (method === 'POST' && path === '/v1/payments') headers['Idempotency-Key'] = json.merchant_ref;
const res = await fetch(base + path, { method, headers, body: body || undefined });
return { status: res.status, data: await res.json() };
}
// usage
const { data } = await paynex('POST', '/v1/payments', {
amount: '1500.00', currency: 'BDT', merchant_ref: 'INV-1042',
return_url: 'https://yoursite.com/pay/return', cancel_url: 'https://yoursite.com/pay/cancel',
});
res.redirect(data.checkout_url);import hashlib, hmac, json, os, secrets, time, requests
BASE = "https://paynex.xyz"
def paynex(method, path, payload=None):
key_id, secret = os.environ["PAYNEX_KEY_ID"], os.environ["PAYNEX_SECRET"]
body = json.dumps(payload, separators=(",", ":")) if payload is not None else ""
ts, nonce = str(int(time.time())), secrets.token_hex(16)
sign_path = path.split("?")[0] # sign the path only, never the query string
msg = f"{ts}\n{nonce}\n{method.upper()}\n{sign_path}\n{hashlib.sha256(body.encode()).hexdigest()}"
sig = hmac.new(secret.encode(), msg.encode(), hashlib.sha256).hexdigest()
headers = {"Authorization": f"Paynex-HMAC key_id={key_id}", "X-PG-Timestamp": ts,
"X-PG-Nonce": nonce, "X-PG-Signature": sig, "Content-Type": "application/json"}
if method.upper() == "POST" and path == "/v1/payments":
headers["Idempotency-Key"] = payload["merchant_ref"]
r = requests.request(method, BASE + path, headers=headers, data=body or None, timeout=20)
return r.status_code, r.json()
code, p = paynex("POST", "/v1/payments", {
"amount": "1500.00", "currency": "BDT", "merchant_ref": "INV-1042",
"return_url": "https://yoursite.com/pay/return", "cancel_url": "https://yoursite.com/pay/cancel"})
print(p["checkout_url"])#!/bin/bash
# usage: pgcall.sh METHOD PATH [JSON] [IDEMPOTENCY_KEY]
BASE="https://paynex.xyz"; KEY_ID="pk_…"; SECRET="sk_…"
METHOD="$1"; PATH_="$2"; BODY="${3:-}"
TS=$(date +%s); NONCE=$(openssl rand -hex 16)
BODY_SHA=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')
SIGN_PATH="${PATH_%%\?*}" # sign the path only, never the query string
SIG=$(printf '%s\n%s\n%s\n%s\n%s' "$TS" "$NONCE" "$METHOD" "$SIGN_PATH" "$BODY_SHA" \
| openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
curl -s -X "$METHOD" "$BASE$PATH_" \
-H "Authorization: Paynex-HMAC key_id=$KEY_ID" -H "X-PG-Timestamp: $TS" \
-H "X-PG-Nonce: $NONCE" -H "X-PG-Signature: $SIG" -H "Content-Type: application/json" \
${4:+-H "Idempotency-Key: $4"} ${BODY:+-d "$BODY"}
# ./pgcall.sh POST /v1/payments '{"amount":"1500.00","merchant_ref":"INV-1042","return_url":"https://yoursite.com/r","cancel_url":"https://yoursite.com/c"}' INV-1042Key scopes
| Scope | Allows |
|---|---|
payments:create | POST /v1/payments |
payments:read | GET /v1/payments, GET /v1/payments/{id}, POST …/verify, GET /v1/refunds/{id} |
payments:cancel | POST /v1/payments/{id}/cancel |
refunds:create | POST /v1/refunds |
GET /v1/store needs a valid key but no specific scope. A missing scope returns 403 forbidden.
Payments
Payment object
{
"id": "92189b87-829f-441d-a2cb-6c21b9aab38d",
"status": "created",
"amount": "1500.00",
"currency": "BDT",
"merchant_ref": "INV-1042",
"description": "Hosting renewal",
"checkout_token": "FLeipf0FGgJ2agDku55a7JhNfoCeWSmD",
"checkout_url": "https://paynex.xyz/c/FLeipf0FGgJ2agDku55a7JhNfoCeWSmD",
"provider_txn_id": null,
"customer": { "name": "Md Samiul Alam", "email": "[email protected]", "phone": "01712345678" },
"metadata": { "order_id": 1042 },
"expires_at": "2026-09-06T00:23:35+06:00",
"paid_at": null,
"created_at": "2026-09-05T23:23:35+06:00",
"attempts": [ { "id": "…", "provider_account_id": "…", "status": "redirected", "provider_ref": null, "started_at": "…" } ]
}
checkout_token/checkout_url are returned by create and get (not in list results). attempts only by get.
/v1/paymentspayments:createIdempotency-Key required| Field | Type | Req. | Notes |
|---|---|---|---|
amount | string | yes | > 0, up to 2 decimals. Send as a string. |
currency | string | no | ISO code. Default: store's default currency (BDT). |
merchant_ref | string | rec. | Your invoice/order number. Shown to the customer, searchable in admin. |
description | string | no | Shown on the checkout page. |
customer | object | no | name, email, phone are shown on checkout (customer may correct them). Other keys stored as-is. |
metadata | object | no | Free-form JSON, returned unchanged. |
return_url | string | yes | Landing page after a successful payment. Host must be in the store's allowed domains. |
cancel_url | string | yes | Landing page after cancel / failure / expiry. Same domain rule. |
expires_in | int (s) | no | Default 1800 (30 min), max 86400. |
Responses: 201 new payment · 200 same Idempotency-Key seen before, existing payment returned · 400 malformed · 422 validation (e.g. domain not allowed).
/v1/payments/{id}payments:readFull object including attempts. This is the source of truth: always confirm status == "paid" here before delivering goods.
/v1/payments?status=&merchant_ref=&limit=&cursor=payments:readNewest first. limit 1–100 (default 50). Pass next_cursor back as cursor for the next page; empty data means the end.
{ "data": [ …payment objects without checkout_url… ], "next_cursor": "2026-09-05T23:23:35.667561+06:00" }
/v1/payments/{id}/cancelpayments:cancelOnly while the payment is open (created, redirected, pending). Returns the updated payment; 409 conflict otherwise.
/v1/payments/{id}/verifypayments:readForces Paynex to re-check with the provider right now and returns the current payment. Handy if a webhook seems late. Never marks a bank transfer paid — those are confirmed by the store's staff.
Refunds
/v1/refundsrefunds:create{ "payment_id": "92189b87-…", "amount": "500.00", "reason": "Customer request" }
- Only
paidorpartially_refundedpayments can be refunded. - Omit
amountfor a full refund of the remaining balance. Partial refunds allowed; the sum cannot exceed the payment. - Response 202:
{ "id", "payment_id", "amount", "currency", "status" }.statusissucceeded,requested(manual provider, staff completes it) orfailed. - You receive
refund.succeeded/refund.failedwebhooks when the result is known.
/v1/refunds/{id}payments:readReturns id, payment_id, amount, currency, status, reason, provider_refund_id, created_at.
Store
/v1/storeany keyYour store's slug, name, default currency and enabled payment methods — useful if you build your own method picker before redirecting.
{ "slug": "demo", "name": "Demo Store", "default_currency": "BDT",
"methods": [ { "id": "4e5b…", "provider": "bkash", "label": "bKash", "mode": "live" } ] }
Providers: bkash, nagad, rocket, upay, sslcommerz, aamarpay, stripe, bank (manual transfer with receipt upload), banglaqr (Bangla QR shown on checkout, receipt upload), crypto (USDT TRC20/BEP20/ERC20, BTC, ETH — unique deposit address per payment, confirmed on-chain automatically), sandbox (test).
Hosted checkout & return redirect
Send the customer to checkout_url. They pick a method and pay; Paynex then redirects:
- Paid →
return_urlwith signed query parameters:?pg_ref=<payment id>&pg_status=paid&pg_ts=<unix>&pg_sig=<hex>pg_sig = hex(HMAC-SHA256(webhook_secret, pg_ref + "." + pg_status + "." + pg_ts)) - Cancelled / failed / expired →
cancel_url(no signature). - Bank transfer / Bangla QR receipt submitted → customer sees a "receipt submitted" page with a link to
return_url; the payment isneeds_reviewuntil staff approve.
GET /v1/payments/{id}. Verify pg_sig and check pg_ts is recent before showing a success message.The link stays valid until expires_at. A failed attempt does not kill the payment: the customer can retry another method on the same link.
Webhooks
Set a Webhook URL in store settings. Paynex POSTs JSON:
POST {webhook_url}
Content-Type: application/json
X-PG-Event: payment.paid
X-PG-Delivery: 5a4c… (unique per delivery; use for de-duplication)
X-PG-Signature: t=1757092800,v1=8f3e…
{ "event": "payment.paid",
"data": { "id": "92189b87-…", "status": "paid", "amount": "1500.00", "currency": "BDT",
"merchant_ref": "INV-1042", "provider_txn_id": "TRX9A2K…", "paid_at": "2026-09-05T23:40:12+06:00" } }
Refund events carry data = { "id", "payment_id", "amount", "currency", "status" }.
| Event | When |
|---|---|
payment.paid | Money confirmed. Deliver the order. |
payment.needs_review | Bank-transfer receipt submitted, awaiting staff approval (or a review rule triggered). |
payment.failed | Rejected in review, or provider failure with no retry possible. |
payment.cancelled | Customer or API cancelled. |
payment.expired | Link expired unpaid. |
refund.succeeded / refund.failed | Refund result. |
Verify the signature
expected = hex( HMAC-SHA256( webhook_secret, t + "." + raw_body ) ) // compare with v1, constant-time; reject if t is older than ~5 min
$t = $v1 = null;
foreach (explode(',', $_SERVER['HTTP_X_PG_SIGNATURE'] ?? '') as $part) {
[$k, $v] = explode('=', $part, 2) + [null, null];
if ($k === 't') $t = $v; if ($k === 'v1') $v1 = $v;
}
$raw = file_get_contents('php://input');
$ok = $t && $v1 && abs(time() - (int)$t) < 300
&& hash_equals(hash_hmac('sha256', $t . '.' . $raw, $webhookSecret), $v1);
if (!$ok) { http_response_code(401); exit; }
$evt = json_decode($raw, true);
[$code, $p] = paynexRequest('GET', '/v1/payments/' . $evt['data']['id']);
if ($code === 200 && $p['status'] === 'paid') {
// mark the order paid — idempotently, keyed by $p['id'] (or provider_txn_id)
}
http_response_code(200);import crypto from 'node:crypto';
app.post('/paynex/webhook', express.raw({ type: '*/*' }), async (req, res) => {
const sig = Object.fromEntries((req.get('X-PG-Signature') || '').split(',').map(p => p.split('=')));
const expected = crypto.createHmac('sha256', process.env.PAYNEX_WEBHOOK_SECRET).update(sig.t + '.').update(req.body).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(sig.t)) < 300;
if (!fresh || !sig.v1 || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig.v1))) return res.sendStatus(401);
const evt = JSON.parse(req.body);
const { status, data } = await paynex('GET', '/v1/payments/' + evt.data.id);
if (status === 200 && data.status === 'paid') await markOrderPaid(data.merchant_ref, data.id); // idempotent
res.sendStatus(200);
});Delivery rules
- Respond
2xxwithin 30 s. Otherwise Paynex retries after 1 m, 5 m, 30 m, 2 h, 12 h, 24 h, then gives up and flags it in admin (staff can redeliver). - Deliveries may arrive more than once or out of order. Make handlers idempotent (store
X-PG-Deliveryor the payment id). - Receiver contract: verify signature →
GET /v1/payments/{id}→ trust only that response.
Payment lifecycle
| Status | Meaning |
|---|---|
created | Payment exists; no method chosen yet (or last attempt failed, retry allowed). |
redirected | Customer chose a method and was sent to the provider / bank page. |
pending | Provider hasn't confirmed yet; Paynex re-checks automatically. |
needs_review | Awaiting staff decision (bank receipts, review rules). |
approved | Staff approved, finalising. Transient. |
paid | Final. Money confirmed. |
partially_refunded / refunded | Paid, then refunded in part / full. |
failed, cancelled, expired | Final, unpaid. |
Errors
{ "error": { "code": "invalid_request", "message": "return_url: host \"x.com\" is not in the store's allowed domains" } }
| HTTP | code | Typical cause |
|---|---|---|
| 400 | invalid_request | Bad JSON, missing Idempotency-Key, bad amount / URLs. |
| 401 | unauthorized | Missing headers, bad signature, timestamp outside ±300 s, nonce replay, revoked key, inactive store. |
| 403 | forbidden | Key lacks the required scope. |
| 404 | not_found | Payment / refund not found or belongs to another store. |
| 409 | conflict | Illegal state change (e.g. cancelling a paid payment). |
| 413 | too_large | Body over 1 MB. |
| 422 | invalid_request | Semantic validation failed (domain, refund exceeds balance…). |
| 502 | provider_error | Upstream provider error during verify. |
| 500 | internal | Retry later. |
/v1 and excludes ?query · LF separators, no trailing newline · sha256 of the exact bytes sent · lower-case hex · clock within 5 minutes · fresh nonce per request.Test (sandbox) mode
Keys created in sandbox mode (pk_sandbox_…) work exactly like live keys. With the Sandbox Pay method enabled on the store, the checkout shows a simulator where you choose "success" or "failure" — no real money moves, but the full flow (webhook, verify, refund) runs. The sandbox provider appears with "mode": "sandbox" in GET /v1/store.
Building a plugin / module (e.g. WHMCS)
A gateway module needs three pieces:
POST /v1/payments with Idempotency-Key = "<invoice id>-<amount>", merchant_ref = invoice id, return/cancel URLs = invoice page. Redirect to checkout_url. Refreshing the page reuses the same payment.X-PG-Signature, GET /v1/payments/{id}, and if status == "paid" and this payment id hasn't been applied yet, add the payment to the invoice. Respond 200.POST /v1/refunds with the stored payment_id and amount. Update the transaction from the refund.* webhook.Store the Paynex payment id on your order/transaction so callbacks and refunds can find it. Never rely on the customer's return redirect to record money.
Mobile apps (Android / iOS / Flutter / React Native)
The API key never goes into the app. The app talks to your backend; your backend talks to Paynex.
app ──POST /api/pay──▶ your backend ──POST /v1/payments──▶ Paynex
app ◀── checkout_url ── your backend ◀── checkout_url ──── Paynex
app opens checkout_url in an in-app browser (Chrome Custom Tabs / SFSafariViewController)
customer pays → Paynex redirects to return_url = myapp://paynex/return?pg_ref=…&pg_status=paid&pg_ts=…&pg_sig=…
app receives the deep link → asks your backend → backend GET /v1/payments/{id}
- Register the scheme — store settings → Domains → Mobile app URL schemes (e.g.
myapp). Thenreturn_url/cancel_urlmay bemyapp://…. Universal Links / App Links (https://yourdomain.com/pay/return) also work with no extra setup — prefer them in production. - Open the checkout in an in-app browser, not an embedded WebView (bKash/Nagad/bank apps and 3-D Secure pages expect a real browser).
- Handle the deep link — show "confirming…", then ask your backend. Optionally verify
pg_sigon the backend for an instant success screen; never mark the order paid from it. - Webhook on the backend marks the order paid. The app can also poll a public, token-scoped JSON status while the browser is open:
GET {checkout_url}/status (no auth — the checkout token is the secret)
{ "id": "…", "status": "paid", "amount": "1500.00", "currency": "BDT", "merchant_ref": "INV-1042",
"paid_at": "2026-09-12T18:02:11+06:00", "expires_at": "…", "redirect": "myapp://paynex/return?pg_ref=…" }
http:// checkout URLs. Bangla QR on a phone: the page offers Save QR image (scan from gallery in bKash/Nagad) and Copy QR code.SDKs
Server-side clients that sign requests, verify webhooks and the return redirect. Dependency-free, single file, vendor them into your project.
| Language | Download | Browse | Notes |
|---|---|---|---|
| Go | paynex-go.zip | paynex.go · README | paynex.New(base, keyID, secret) · WebhookHandler for net/http |
| Node.js 18+ | paynex-node.zip | paynex.js · README | CommonJS + .d.ts · Express webhookHandler |
| Dart 3 / Flutter | paynex-dart.zip | paynex.dart · README (Flutter guide) | dart:io client for the backend; README shows the app side (url_launcher + app_links), which needs no key |
| PHP 7.4+ | paynex-php.zip | Paynex.php · README | single file, cURL · WHMCS / Laravel / WordPress |
Same surface everywhere: createPayment (idempotency key required), getPayment, listPayments, cancelPayment, verifyPayment, createRefund, getRefund, getStore, verifyWebhook, verifyReturn.
Reference
| Endpoint | Scope | Purpose |
|---|---|---|
GET /v1/health | none | Liveness: {"status":"ok"} |
POST /v1/payments | payments:create | Create payment, get checkout_url |
GET /v1/payments | payments:read | List / search |
GET /v1/payments/{id} | payments:read | Full detail (source of truth) |
POST /v1/payments/{id}/cancel | payments:cancel | Cancel open payment |
POST /v1/payments/{id}/verify | payments:read | Force provider re-check |
POST /v1/refunds | refunds:create | Full / partial refund |
GET /v1/refunds/{id} | payments:read | Refund status |
GET /v1/store | any key | Store info + enabled methods |
Base URL https://paynex.xyz · Markdown version: /docs/api.md