API v1

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.

Base URL: https://paynex.xyzREST · JSONHMAC-SHA256 authWebhooks with retries

Overview

Your site ──POST /v1/payments──► Paynex ──checkout_url──► Customer pays on the Paynex checkout page ▲ │ └──── webhook payment.paid + GET /v1/payments/{id} ◄───────┘
1. CreateYour server calls POST /v1/payments with amount, invoice number and return URLs.
2. RedirectSend the customer to the returned checkout_url. They choose bKash, Nagad, card, bank transfer…
3. ConfirmPaynex POSTs a signed 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

  1. In the Paynex admin open API Keys and create a key for your store. You get a key_id (pk_live_… / pk_sandbox_…) and a secret (sk_…). The secret is shown once.
  2. In Store settings, add the domains your return_url/cancel_url use and set a Webhook URL. Copy the webhook secret.
  3. 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
}
  1. Redirect the customer to checkout_url from the response.
  2. On the payment.paid webhook: verify the signature, call GET /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:

HeaderValue
AuthorizationPaynex-HMAC key_id=<key_id>
X-PG-TimestampUnix seconds. Must be within ±300 s of server time.
X-PG-NonceRandom string (16 random bytes, hex). Single use; replays within 10 minutes are rejected.
X-PG-SignatureLower-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 ) )
  • METHOD upper-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.
Idempotency-Key is required on 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-1042

Key scopes

ScopeAllows
payments:createPOST /v1/payments
payments:readGET /v1/payments, GET /v1/payments/{id}, POST …/verify, GET /v1/refunds/{id}
payments:cancelPOST /v1/payments/{id}/cancel
refunds:createPOST /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.

POST/v1/paymentspayments:createIdempotency-Key required
FieldTypeReq.Notes
amountstringyes> 0, up to 2 decimals. Send as a string.
currencystringnoISO code. Default: store's default currency (BDT).
merchant_refstringrec.Your invoice/order number. Shown to the customer, searchable in admin.
descriptionstringnoShown on the checkout page.
customerobjectnoname, email, phone are shown on checkout (customer may correct them). Other keys stored as-is.
metadataobjectnoFree-form JSON, returned unchanged.
return_urlstringyesLanding page after a successful payment. Host must be in the store's allowed domains.
cancel_urlstringyesLanding page after cancel / failure / expiry. Same domain rule.
expires_inint (s)noDefault 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).

GET/v1/payments/{id}payments:read

Full object including attempts. This is the source of truth: always confirm status == "paid" here before delivering goods.

GET/v1/payments?status=&merchant_ref=&limit=&cursor=payments:read

Newest 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" }
POST/v1/payments/{id}/cancelpayments:cancel

Only while the payment is open (created, redirected, pending). Returns the updated payment; 409 conflict otherwise.

POST/v1/payments/{id}/verifypayments:read

Forces 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

POST/v1/refundsrefunds:create
{ "payment_id": "92189b87-…", "amount": "500.00", "reason": "Customer request" }
  • Only paid or partially_refunded payments can be refunded.
  • Omit amount for a full refund of the remaining balance. Partial refunds allowed; the sum cannot exceed the payment.
  • Response 202: { "id", "payment_id", "amount", "currency", "status" }. status is succeeded, requested (manual provider, staff completes it) or failed.
  • You receive refund.succeeded / refund.failed webhooks when the result is known.
GET/v1/refunds/{id}payments:read

Returns id, payment_id, amount, currency, status, reason, provider_refund_id, created_at.

Store

GET/v1/storeany key

Your 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:

  • Paidreturn_url with 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 / expiredcancel_url (no signature).
  • Bank transfer / Bangla QR receipt submitted → customer sees a "receipt submitted" page with a link to return_url; the payment is needs_review until staff approve.
Use the return page for UX only ("Thanks, we're confirming your payment…"). Never mark an order paid from the redirect — use the webhook + 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" }.

EventWhen
payment.paidMoney confirmed. Deliver the order.
payment.needs_reviewBank-transfer receipt submitted, awaiting staff approval (or a review rule triggered).
payment.failedRejected in review, or provider failure with no retry possible.
payment.cancelledCustomer or API cancelled.
payment.expiredLink expired unpaid.
refund.succeeded / refund.failedRefund 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 2xx within 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-Delivery or the payment id).
  • Receiver contract: verify signature → GET /v1/payments/{id} → trust only that response.

Payment lifecycle

created ──► redirected ──► pending ──► paid ──► partially_refunded / refunded │ │ │ │ │ └──► needs_review ──► paid (staff approve) │ │ └──────► failed (staff reject) │ └──► (attempt failed) ──► created ← customer can retry ├──► cancelled └──► expired
StatusMeaning
createdPayment exists; no method chosen yet (or last attempt failed, retry allowed).
redirectedCustomer chose a method and was sent to the provider / bank page.
pendingProvider hasn't confirmed yet; Paynex re-checks automatically.
needs_reviewAwaiting staff decision (bank receipts, review rules).
approvedStaff approved, finalising. Transient.
paidFinal. Money confirmed.
partially_refunded / refundedPaid, then refunded in part / full.
failed, cancelled, expiredFinal, unpaid.

Errors

{ "error": { "code": "invalid_request", "message": "return_url: host \"x.com\" is not in the store's allowed domains" } }
HTTPcodeTypical cause
400invalid_requestBad JSON, missing Idempotency-Key, bad amount / URLs.
401unauthorizedMissing headers, bad signature, timestamp outside ±300 s, nonce replay, revoked key, inactive store.
403forbiddenKey lacks the required scope.
404not_foundPayment / refund not found or belongs to another store.
409conflictIllegal state change (e.g. cancelling a paid payment).
413too_largeBody over 1 MB.
422invalid_requestSemantic validation failed (domain, refund exceeds balance…).
502provider_errorUpstream provider error during verify.
500internalRetry later.
Signature debugging checklist: upper-case method · path includes /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:

1. Payment linkOn invoice view call 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.
2. CallbackA public URL receiving the webhook: verify 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.
3. RefundPOST /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}
  1. Register the scheme — store settings → Domains → Mobile app URL schemes (e.g. myapp). Then return_url / cancel_url may be myapp://…. Universal Links / App Links (https://yourdomain.com/pay/return) also work with no extra setup — prefer them in production.
  2. 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).
  3. Handle the deep link — show "confirming…", then ask your backend. Optionally verify pg_sig on the backend for an instant success screen; never mark the order paid from it.
  4. 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=…" }
HTTPS is mandatory for apps — iOS App Transport Security and Android 9+ block plain 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.

LanguageDownloadBrowseNotes
Gopaynex-go.zippaynex.go · READMEpaynex.New(base, keyID, secret) · WebhookHandler for net/http
Node.js 18+paynex-node.zippaynex.js · READMECommonJS + .d.ts · Express webhookHandler
Dart 3 / Flutterpaynex-dart.zippaynex.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.zipPaynex.php · READMEsingle file, cURL · WHMCS / Laravel / WordPress

Same surface everywhere: createPayment (idempotency key required), getPayment, listPayments, cancelPayment, verifyPayment, createRefund, getRefund, getStore, verifyWebhook, verifyReturn.

Reference

EndpointScopePurpose
GET /v1/healthnoneLiveness: {"status":"ok"}
POST /v1/paymentspayments:createCreate payment, get checkout_url
GET /v1/paymentspayments:readList / search
GET /v1/payments/{id}payments:readFull detail (source of truth)
POST /v1/payments/{id}/cancelpayments:cancelCancel open payment
POST /v1/payments/{id}/verifypayments:readForce provider re-check
POST /v1/refundsrefunds:createFull / partial refund
GET /v1/refunds/{id}payments:readRefund status
GET /v1/storeany keyStore info + enabled methods

Base URL https://paynex.xyz · Markdown version: /docs/api.md