Payment gateway API documentation
Start a payment with one request, verify the signed IPN and confirm orders. Everything you need to integrate JazzCash, Easypaisa and Raast QR payments with ZurvaPay.
Introduction
ZurvaPay is a hosted payment gateway for Pakistan (JazzCash, EasyPaisa and Raast QR).
You send the order to our API, redirect the customer to our secure checkout, and we notify your server when the payment is paid. All endpoints use standard HTTPS form posts and return JSON, so they work from any language: PHP, Laravel, WordPress, Python, Node.js, .NET, Java, Go.
Base URL: https://zurvapay.com Mode: Live only — every request is a real payment.
How It Works
- Create order — save it in YOUR database as pending with a unique
identifier. - Initiate — POST the order to /payment/initiate and receive a checkout
url. - Redirect — send the customer to that URL. They pay with JazzCash / EasyPaisa / QR.
- Result — your choice — Webhook (IPN): send
ipn_urland we POST the result to it; verify the signature and mark the order paid. Polling: leaveipn_urlout and ask /payment/status yourself. Both are equally trusted once the signatures are verified; you may use either one, or both together. - Return — the customer is sent to your
success_url(orcancel_url). Only show a message there — never mark the order paid from these pages. - Safety net (optional) — a cron job calls /payment/status for orders still pending and completes any that were paid.
Why the safety net matters
Sometimes the customer pays on their phone but the checkout page shows an error, times out, or they close the browser. Our system re-checks such payments directly with JazzCash / EasyPaisa / Raast and confirms them automatically a few minutes later, then sends your IPN (retried up to 10 times if your server does not answer HTTP 200). If you also run the optional cron job, your order is completed even if every IPN attempt failed.
API Keys
Log in to your merchant account (login) and open Api Key in the sidebar. You get two keys:
| Key | Used for | Where |
|---|---|---|
public_key | Identifies your business when starting a payment. | Server (may appear in forms) |
secret_key | Verifies IPN signatures and authenticates the Payment Status API. | Server ONLY — never in browser/app code |
Generating new keys immediately invalidates the old ones. Your ipn_url, success_url and cancel_url must be on the website domain approved for your merchant account (domain lock).
Supported Currencies
| Currency Name | Symbol | Code |
|---|---|---|
| Pakistani Rupee | PKR | PKR |
Initiate Payment
POST https://zurvapay.com/payment/initiate
Send as form data (multipart or x-www-form-urlencoded). Call it from your server, not from the browser.
| Parameter | Type | Required | Description |
|---|---|---|---|
public_key | string (50) | Yes | Your public key. |
identifier | string (20) | Yes | Your unique order reference. Must never repeat (a used identifier is rejected). Letters and digits are safest, e.g. ORD1790000000123. |
currency | string (4) | Yes | Upper-case code, e.g. PKR. |
amount | decimal | Yes | Amount to charge, greater than 0. |
details | string (100) | Yes | What the customer is paying for. |
ipn_url | url | No (optional) | Webhook: your server endpoint that receives the payment notification. Leave it out if you prefer to check the status yourself with the Status API (polling). |
success_url | url | Yes | Where the customer is sent after paying. |
cancel_url | url | Yes | Where the customer is sent if they cancel. |
customer_name | string (30) | Yes | Customer name. |
customer_email | email (30) | Yes | Customer email. |
site_logo | url | No | Logo shown on checkout. |
checkout_theme | dark | light | No | Checkout theme, default light. |
On success, redirect the customer to the returned url. The payment record is created when the customer opens checkout — until then the Payment Status API returns not_found.
<?php
// 1) Save the order in YOUR database first (status = pending),
// keyed by a unique identifier (max 20 chars, unique forever).
$identifier = 'ORD' . time() . rand(100, 999); // e.g. ORD1790000000123
$parameters = [
'public_key' => 'YOUR_PUBLIC_KEY',
'identifier' => $identifier,
'currency' => 'PKR',
'amount' => 500, // PKR
'details' => 'Wallet Recharge',
'ipn_url' => 'https://yourdomain.com/zurvapay/ipn.php',
'success_url' => 'https://yourdomain.com/payment/success',
'cancel_url' => 'https://yourdomain.com/payment/cancel',
'site_logo' => 'https://yourdomain.com/logo.png', // optional
'checkout_theme' => 'dark', // optional: dark | light
'customer_name' => 'John Doe',
'customer_email' => 'john@mail.com',
];
$ch = curl_init('https://zurvapay.com/payment/initiate');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $parameters); // form data
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
$result = json_decode(curl_exec($ch), true);
curl_close($ch);
if (($result['success'] ?? '') === 'ok') {
header('Location: ' . $result['url']); // send the customer to checkout
exit;
}
echo 'Payment could not be started: ' . ($result['message'] ?? 'unknown error');
curl -X POST "https://zurvapay.com/payment/initiate" \
-F "public_key=YOUR_PUBLIC_KEY" \
-F "identifier=ORD1790000000123" \
-F "currency=PKR" \
-F "amount=500" \
-F "details=Wallet Recharge" \
-F "ipn_url=https://yourdomain.com/zurvapay/ipn.php" \
-F "success_url=https://yourdomain.com/payment/success" \
-F "cancel_url=https://yourdomain.com/payment/cancel" \
-F "checkout_theme=dark" \
-F "customer_name=John Doe" \
-F "customer_email=john@mail.com"
import time, random, requests
identifier = f"ORD{int(time.time())}{random.randint(100, 999)}"
r = requests.post("https://zurvapay.com/payment/initiate", data={
"public_key": "YOUR_PUBLIC_KEY",
"identifier": identifier,
"currency": "PKR",
"amount": 500,
"details": "Wallet Recharge",
"ipn_url": "https://yourdomain.com/zurvapay/ipn",
"success_url": "https://yourdomain.com/payment/success",
"cancel_url": "https://yourdomain.com/payment/cancel",
"checkout_theme": "dark",
"customer_name": "John Doe",
"customer_email": "john@mail.com",
}, timeout=30)
res = r.json()
if res.get("success") == "ok":
checkout_url = res["url"] # redirect the customer here
else:
print("Error:", res.get("message"))
// Node.js 18+ (built-in fetch)
const identifier = 'ORD' + Date.now().toString().slice(0, 10) + Math.floor(100 + Math.random() * 900);
const body = new URLSearchParams({
public_key: 'YOUR_PUBLIC_KEY',
identifier,
currency: 'PKR',
amount: '500',
details: 'Wallet Recharge',
ipn_url: 'https://yourdomain.com/zurvapay/ipn',
success_url: 'https://yourdomain.com/payment/success',
cancel_url: 'https://yourdomain.com/payment/cancel',
checkout_theme: 'dark',
customer_name: 'John Doe',
customer_email: 'john@mail.com',
});
const res = await (await fetch('https://zurvapay.com/payment/initiate', { method: 'POST', body })).json();
if (res.success === 'ok') {
// res.redirect(res.url) -> send the customer to checkout
} else {
console.error(res.message);
}
// Success
{
"success": "ok",
"message": "Payment Initiated. Redirect to url",
"url": "https://zurvapay.com/initiate/payment/checkout?payment_id=eyJpdiI6...",
"payment_trx": "IC9CI8LI8IIR"
}
// Error (examples)
{ "error": "true", "message": "Invalid api key." }
{ "error": "true", "message": "Currency not supported." }
{ "error": "yes", "errors": ["The identifier has already been taken."] }
Success & Cancel URLs
These are normal browser redirects. Anyone can open them, so they must never mark an order as paid.
success_url— show "Payment received, confirming…" and read the order status from YOUR database (set by the IPN or cron).cancel_url— show "Payment cancelled". If the customer actually paid, the IPN / cron will still complete the order.
IPN (Instant Payment Notification)
ipn_url and poll; if you send ipn_url we deliver once per payment and retry up to 10 times until your server answers HTTP 200.POST to your ipn_url — form-encoded (application/x-www-form-urlencoded).
| Field | Description |
|---|---|
status | Always success (an IPN is only sent for paid payments). |
identifier | Your order identifier. |
signature | strtoupper(hmac_sha256(data[amount] . identifier, secret_key)) |
status_signature | strtoupper(hmac_sha256(status . '|' . identifier . '|' . data[amount], secret_key)) — also covers the status. Verify both. |
data[payment_trx] | Our transaction ID — store it as your payment reference. |
data[amount] | Paid amount as a string, e.g. 500.00000000. Use it exactly as received when computing signatures. |
data[charge] | Our fee for this payment. |
data[currency][code] | e.g. PKR |
data[payment_method] | jazzcash, easypaisa, dqrc … |
data[payment_timestamp] | When the payment was created. |
data[account_holder] | Payer name, if known. |
IC9CI8LI8IIR. It is returned when you create the payment ( payment_trx), in the IPN ( data[payment_trx]) and in the Status API ( payment_trx). Save it with your order and show it as the transaction ID of the ZurvaPay payment in your payment history, so you and ZurvaPay support always talk about the same transaction. Keep your own identifier only to find your order.Your handler must
- Verify
signatureandstatus_signaturewith your secret key (constant-time compare). - Find YOUR order by
identifier, check it is still pending and the amount matches. - Mark it paid only once (database transaction / "UPDATE … WHERE status = pending") — the same payment can be notified more than once.
- Reply HTTP 200 quickly (e.g.
{"status":"ok"}). Do heavy work after replying.
Retries
If your server does not reply HTTP 2xx, we retry at once (within a few seconds) and then after about 2, 5, 15, 60, 180 and 360 minutes (10 attempts in total). Payments confirmed later by our automatic verification are notified the same way.
<?php
// ipn.php — ZurvaPay POSTs here (form data) when a payment is PAID.
// This is the ONLY place you should mark an order as paid.
$secret = 'YOUR_SECRET_KEY';
$status = $_POST['status'] ?? '';
$identifier = $_POST['identifier'] ?? '';
$signature = $_POST['signature'] ?? '';
$statusSig = $_POST['status_signature'] ?? '';
$data = $_POST['data'] ?? [];
$amount = $data['amount'] ?? ''; // e.g. "500.00000000" — use AS-IS
// 1) Verify signatures (use the raw strings exactly as received)
$expected = strtoupper(hash_hmac('sha256', $amount . $identifier, $secret));
$expectedStatus = strtoupper(hash_hmac('sha256', $status . '|' . $identifier . '|' . $amount, $secret));
if (!hash_equals($expected, $signature) || !hash_equals($expectedStatus, $statusSig) || $status !== 'success') {
http_response_code(200);
echo json_encode(['status' => 'failed']);
exit;
}
// 2) Find YOUR pending order and check the amount
$order = find_order_by_identifier($identifier); // your function
if ($order && $order['status'] === 'pending' && (float) $amount >= (float) $order['amount']) {
// 3) Mark paid ONCE (use a DB transaction / row lock), then deliver
mark_order_paid($order['id'], $data['payment_trx']); // your function
}
// 4) Always answer HTTP 200 — anything else makes ZurvaPay retry
http_response_code(200);
echo json_encode(['status' => 'ok']);
# Flask example
import hmac, hashlib
from flask import Flask, request, jsonify
app = Flask(__name__)
SECRET = b"YOUR_SECRET_KEY"
def sign(msg: str) -> str:
return hmac.new(SECRET, msg.encode(), hashlib.sha256).hexdigest().upper()
@app.post("/zurvapay/ipn")
def zurvapay_ipn():
f = request.form
status, identifier = f.get("status", ""), f.get("identifier", "")
amount = f.get("data[amount]", "") # raw string, e.g. "500.00000000"
ok = (hmac.compare_digest(sign(amount + identifier), f.get("signature", ""))
and hmac.compare_digest(sign(f"{status}|{identifier}|{amount}"), f.get("status_signature", ""))
and status == "success")
if ok:
order = find_pending_order(identifier) # your function
if order and float(amount) >= float(order.amount):
mark_order_paid(order.id, f.get("data[payment_trx]")) # once only
return jsonify(status="ok"), 200
return jsonify(status="failed"), 200
// Express example
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.urlencoded({ extended: true })); // IPN is form-encoded
const SECRET = 'YOUR_SECRET_KEY';
const sign = (msg) => crypto.createHmac('sha256', SECRET).update(msg).digest('hex').toUpperCase();
const same = (a, b) => a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
app.post('/zurvapay/ipn', async (req, res) => {
const { status = '', identifier = '', signature = '', status_signature = '' } = req.body;
const data = req.body.data || {};
const amount = String(data.amount || ''); // raw string, e.g. "500.00000000"
const valid = same(sign(amount + identifier), signature)
&& same(sign(`${status}|${identifier}|${amount}`), status_signature)
&& status === 'success';
if (valid) {
const order = await findPendingOrder(identifier); // your function
if (order && Number(amount) >= Number(order.amount)) {
await markOrderPaid(order.id, data.payment_trx); // once only
}
return res.status(200).json({ status: 'ok' });
}
res.status(200).json({ status: 'failed' });
});
status=success
signature=A5791248DC90F2908BE99398D0C753863C6AA8CAFB952A334238465D48F50F31
status_signature=3F1C0B7E...(64 hex chars)
identifier=ORD1790000000123
data[payment_trx]=IC9CI8LI8IIR
data[amount]=500.00000000
data[account_holder]=John Doe
data[payment_type]=hosted
data[payment_method]=jazzcash
data[payment_timestamp]=2026-09-28 14:05:11
data[charge]=10
data[currency][code]=PKR
data[currency][symbol]=Rs
Payment Status
POST GET https://zurvapay.com/payment/status
Ask for the current status of one payment or up to 50 at once. Server-to-server only (it requires your secret key).
| Parameter | Required | Description |
|---|---|---|
public_key | Yes | Your public key (or header X-Public-Key). |
secret_key | Yes | Your secret key (or header X-Secret-Key). |
identifier | One of | A single identifier → response key payment. |
identifiers[] | One of | Array (or comma list) of up to 50 identifiers → response key payments. |
Status values
| status | Meaning | What to do |
|---|---|---|
success | Paid. | Verify status_signature, check amount, complete the order (once). |
pending | Checkout opened, not paid yet (or still being verified). | Check again later. |
failed | Cancelled / expired / rejected (see reason). | Mark your order failed. |
not_found | The customer never opened / completed checkout. | Stop checking after 24 hours. |
Limit: 120 requests per minute per IP.
<?php
function zurvapay_status(array $identifiers): array
{
$ch = curl_init('https://zurvapay.com/payment/status');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'public_key' => 'YOUR_PUBLIC_KEY',
'secret_key' => 'YOUR_SECRET_KEY',
'identifiers' => $identifiers, // max 50
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$res = json_decode(curl_exec($ch), true);
curl_close($ch);
return ($res['success'] ?? '') === 'ok' ? $res['payments'] : [];
}
print_r(zurvapay_status(['ORD1790000000123', 'ORD1790000000456']));
# One payment
curl -X POST "https://zurvapay.com/payment/status" \
-d "public_key=YOUR_PUBLIC_KEY" \
-d "secret_key=YOUR_SECRET_KEY" \
-d "identifier=ORD1790000000123"
# Up to 50 payments in one call
curl -X POST "https://zurvapay.com/payment/status" \
-d "public_key=YOUR_PUBLIC_KEY" \
-d "secret_key=YOUR_SECRET_KEY" \
-d "identifiers[]=ORD1790000000123" \
-d "identifiers[]=ORD1790000000456"
import requests
r = requests.post("https://zurvapay.com/payment/status", data={
"public_key": "YOUR_PUBLIC_KEY",
"secret_key": "YOUR_SECRET_KEY",
"identifiers[]": ["ORD1790000000123", "ORD1790000000456"],
}, timeout=30)
for p in r.json().get("payments", []):
print(p["identifier"], p["status"])
const body = new URLSearchParams();
body.append('public_key', 'YOUR_PUBLIC_KEY');
body.append('secret_key', 'YOUR_SECRET_KEY');
['ORD1790000000123', 'ORD1790000000456'].forEach(id => body.append('identifiers[]', id));
const res = await (await fetch('https://zurvapay.com/payment/status', { method: 'POST', body })).json();
for (const p of res.payments || []) console.log(p.identifier, p.status);
// Single identifier -> "payment"
{
"success": "ok",
"message": "Payment status fetched.",
"payment": {
"identifier": "ORD1790000000123",
"status": "success",
"payment_trx": "IC9CI8LI8IIR",
"amount": "500.00000000",
"currency": { "code": "PKR", "symbol": "Rs" },
"payment_method": "jazzcash",
"customer_name": "John Doe",
"customer_email": "john@mail.com",
"created_at": "2026-09-28 14:05:11",
"updated_at": "2026-09-28 14:09:40",
"charge": "10",
"signature": "A5791248DC90F2908BE9...",
"status_signature": "3F1C0B7E...",
"ipn_delivered": false
}
}
// identifiers[] -> "payments": [ ... ]
// status = success | pending | failed | not_found
// "failed" may include "reason" (e.g. "Expired", "Cancelled")
// "not_found" = the customer never reached / completed checkout
// Error
{ "error": "true", "message": "Invalid api key" }
Optional Cron Job
Optional. Your integration works without it. Use it as your main method if you do not send an ipn_url (polling), or as a safety net next to the IPN webhook. Add it if you want zero orders stuck as pending — for example if your server was down while we sent the IPN.
On ZurvaPay (nothing to do)
- Raast QR: the payment is credited the moment the bank confirms it, and every unfinished QR payment is re-checked continuously; expired ones are closed.
- JazzCash / EasyPaisa: a payment the customer approved but whose answer never reached us is re-checked 10–30 minutes later and credited automatically; your IPN is then sent (retried up to 10 times).
On your server (optional, recommended)
Every 5 minutes it:
- loads YOUR ZurvaPay orders that are still pending and less than 24 hours old (max 50);
- asks /payment/status in one call;
- for every
success: verifiesstatus_signature, checks the amount, and completes the order withUPDATE … WHERE status = 'pending'so it can never double-credit even if the IPN arrives at the same moment.
Adapt the table/column names (orders, identifier, amount, status, gateway, created_at) and the deliver function to your system.
<?php
/**
* zurvapay_cron.php — OPTIONAL. Completes orders whose customer paid but
* the IPN never reached you (checkout error, closed browser, server down).
* Run it every 5 minutes with a cron job (see "Schedule it" below).
*/
const PUBLIC_KEY = 'YOUR_PUBLIC_KEY';
const SECRET_KEY = 'YOUR_SECRET_KEY';
const STATUS_URL = 'https://zurvapay.com/payment/status';
$pdo = new PDO('mysql:host=localhost;dbname=YOUR_DB;charset=utf8mb4', 'DB_USER', 'DB_PASS', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
// 1) Your ZurvaPay orders still pending, created in the last 24 hours
$rows = $pdo->query("SELECT id, identifier, amount FROM orders
WHERE gateway = 'zurvapay' AND status = 'pending'
AND created_at >= NOW() - INTERVAL 24 HOUR
ORDER BY id DESC LIMIT 50")->fetchAll(PDO::FETCH_ASSOC);
if (!$rows) exit("Nothing to check\n");
$orders = array_column($rows, null, 'identifier');
// 2) Ask ZurvaPay (max 50 identifiers per call)
$ch = curl_init(STATUS_URL);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'public_key' => PUBLIC_KEY,
'secret_key' => SECRET_KEY,
'identifiers' => array_keys($orders),
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$res = json_decode(curl_exec($ch), true);
curl_close($ch);
if (($res['success'] ?? '') !== 'ok') exit('Status API error: ' . ($res['message'] ?? 'no response') . "\n");
// 3) Complete the paid ones — same checks as your IPN handler
foreach ($res['payments'] as $p) {
if ($p['status'] !== 'success') continue;
$expected = strtoupper(hash_hmac('sha256', $p['status'] . '|' . $p['identifier'] . '|' . $p['amount'], SECRET_KEY));
if (!hash_equals($expected, $p['status_signature'])) continue;
$order = $orders[$p['identifier']] ?? null;
if (!$order || (float) $p['amount'] < (float) $order['amount']) continue;
// Update only if STILL pending -> never double-credits (IPN may arrive at the same time)
$pdo->beginTransaction();
$upd = $pdo->prepare("UPDATE orders SET status = 'paid', payment_trx = ? WHERE id = ? AND status = 'pending'");
$upd->execute([$p['payment_trx'], $order['id']]);
if ($upd->rowCount() === 1) {
deliver_order($pdo, $order['id']); // your function: add balance / ship / activate ...
}
$pdo->commit();
echo "Paid: {$p['identifier']}\n";
}
#!/usr/bin/env python3
# zurvapay_cron.py — OPTIONAL. Run every 5 minutes:
# */5 * * * * /usr/bin/python3 /home/USER/zurvapay_cron.py
import hmac, hashlib, requests, pymysql
PUBLIC_KEY, SECRET_KEY = "YOUR_PUBLIC_KEY", "YOUR_SECRET_KEY"
STATUS_URL = "https://zurvapay.com/payment/status"
def sign(msg):
return hmac.new(SECRET_KEY.encode(), msg.encode(), hashlib.sha256).hexdigest().upper()
db = pymysql.connect(host="localhost", user="DB_USER", password="DB_PASS", database="YOUR_DB", autocommit=False)
cur = db.cursor(pymysql.cursors.DictCursor)
cur.execute("""SELECT id, identifier, amount FROM orders
WHERE gateway='zurvapay' AND status='pending'
AND created_at >= NOW() - INTERVAL 24 HOUR
ORDER BY id DESC LIMIT 50""")
orders = {o["identifier"]: o for o in cur.fetchall()}
if not orders:
raise SystemExit("Nothing to check")
res = requests.post(STATUS_URL, data={
"public_key": PUBLIC_KEY, "secret_key": SECRET_KEY,
"identifiers[]": list(orders.keys()),
}, timeout=30).json()
if res.get("success") != "ok":
raise SystemExit(f"Status API error: {res.get('message')}")
for p in res["payments"]:
if p["status"] != "success":
continue
if not hmac.compare_digest(sign(f"{p['status']}|{p['identifier']}|{p['amount']}"), p["status_signature"]):
continue
order = orders.get(p["identifier"])
if not order or float(p["amount"]) < float(order["amount"]):
continue
# only if still pending -> no double credit
changed = cur.execute("UPDATE orders SET status='paid', payment_trx=%s WHERE id=%s AND status='pending'",
(p["payment_trx"], order["id"]))
if changed == 1:
deliver_order(cur, order["id"]) # your function
db.commit()
print("Paid:", p["identifier"])
// zurvapay_cron.js — OPTIONAL. Run every 5 minutes:
// */5 * * * * /usr/bin/node /home/USER/zurvapay_cron.js
const crypto = require('crypto');
const mysql = require('mysql2/promise');
const PUBLIC_KEY = 'YOUR_PUBLIC_KEY', SECRET_KEY = 'YOUR_SECRET_KEY';
const STATUS_URL = 'https://zurvapay.com/payment/status';
const sign = (m) => crypto.createHmac('sha256', SECRET_KEY).update(m).digest('hex').toUpperCase();
(async () => {
const db = await mysql.createConnection({ host: 'localhost', user: 'DB_USER', password: 'DB_PASS', database: 'YOUR_DB' });
const [rows] = await db.query(`SELECT id, identifier, amount FROM orders
WHERE gateway='zurvapay' AND status='pending'
AND created_at >= NOW() - INTERVAL 24 HOUR
ORDER BY id DESC LIMIT 50`);
if (!rows.length) return db.end();
const orders = Object.fromEntries(rows.map(o => [o.identifier, o]));
const body = new URLSearchParams({ public_key: PUBLIC_KEY, secret_key: SECRET_KEY });
Object.keys(orders).forEach(id => body.append('identifiers[]', id));
const res = await (await fetch(STATUS_URL, { method: 'POST', body })).json();
if (res.success !== 'ok') { console.error('Status API error:', res.message); return db.end(); }
for (const p of res.payments) {
if (p.status !== 'success') continue;
if (sign(`${p.status}|${p.identifier}|${p.amount}`) !== p.status_signature) continue;
const order = orders[p.identifier];
if (!order || Number(p.amount) < Number(order.amount)) continue;
// only if still pending -> no double credit
const [r] = await db.execute("UPDATE orders SET status='paid', payment_trx=? WHERE id=? AND status='pending'",
[p.payment_trx, order.id]);
if (r.affectedRows === 1) await deliverOrder(db, order.id); // your function
console.log('Paid:', p.identifier);
}
await db.end();
})();
Schedule It
In cPanel / Hostinger hPanel open Cron Jobs, choose "Every 5 minutes" and paste the command. Running it more often than every minute is not needed.
If you use a URL-based cron, protect the script with a long secret in the URL and reject requests without it.
# cPanel / hPanel -> Cron Jobs -> "Every 5 minutes"
*/5 * * * * /usr/bin/php /home/USERNAME/public_html/zurvapay_cron.php >/dev/null 2>&1
# If your host only allows URL crons, protect the URL with a secret key:
*/5 * * * * curl -s "https://yourdomain.com/zurvapay_cron.php?key=LONG_RANDOM_SECRET" >/dev/null 2>&1
Errors
Errors are returned as {"error":"true","message":"…"} (validation errors as {"error":"yes","errors":[…]}). Always check success == "ok" before using the response.
| Message | Fix |
|---|---|
Invalid api key. | Wrong / regenerated public key, or wrong secret key (status API). |
Currency not supported. | Use an enabled currency code, e.g. PKR. |
The identifier has already been taken. | Every payment needs a new identifier. |
The domain for ipn_url (…) is not an allowed domain for this merchant. | Use URLs on your approved website domain. |
identifier is required. | Send identifier or identifiers[] to /payment/status. |
Maximum 50 identifiers per request. | Split the list into batches of 50. |
HTTP 429 | Too many requests — slow down. |
Security Checklist
- Secret key only on your server; never in JavaScript, mobile apps or public repositories.
- Mark orders paid ONLY from a verified IPN (webhook) or a verified status-API response (polling) — never from success_url.
- Verify both
signatureandstatus_signatureusing the rawdata[amount]string. - Compare the paid amount with YOUR stored order amount.
- Complete an order once: UPDATE … WHERE status = pending, or a row lock inside a transaction.
- Always answer the IPN with HTTP 200 — otherwise it is retried.
- Use a new identifier for every payment attempt.
Developer / AI Brief
Copy this brief together with the code on this page to a developer or an AI assistant:
Direct API — your own checkout
Use the Direct API when you want the payment to happen inside YOUR website, app or bot — your own screens, your own design — instead of redirecting the customer to the hosted ZurvaPay checkout. You call ZurvaPay from your server, choose ONE channel for the payment, and ZurvaPay does the rest (charge / QR, confirmation, credit to your wallet, IPN, background checks).
| Hosted checkout | Direct API (this tab) |
|---|---|
| Customer is redirected to the ZurvaPay checkout page and picks a method there. | Customer stays on YOUR page / app. YOU choose the channel (jazzcash, easypaisa or dqrc) and show the screens. |
Starts with /payment/initiate (public key + success / cancel URLs). | Starts with POST /api/v1/payments (public + secret key, server only). No success / cancel URLs. |
| Works for every merchant. | Must be enabled on your account by ZurvaPay, per channel. Not allowed? Use the hosted checkout. |
How It Works
- Create order — save it in YOUR database as pending with a unique
identifier. - Create payment —
POST /api/v1/paymentswith thechannel. - dqrc — you get
payment.qr.text. Draw it as a QR code. The customer scans it with any banking app (Raast QR). - jazzcash / easypaisa — send the customer's mobile number. The customer gets an approval request in the wallet app; the call returns when they approve or decline.
- Result — your choice — Webhook: send
ipn_urland we POST the IPN to it. Polling: leave it out and ask the status API yourself (every few seconds while the customer pays, then the cron below). Either way, verify the signature, then mark the order paid. - Safety net — ZurvaPay re-checks unfinished payments with the gateway in the background; an optional cron on your side calls the status API.
Access & Keys
direct_api_not_enabled or channel_not_allowed. You can build and test everything right now without approval in the Sandbox (next section).- Use the same
public_key/secret_keyas the hosted checkout (merchant dashboard → Api Key). Send them as headersX-Public-Key/X-Secret-Key(or as parameters). - Direct API must be switched on for your account by ZurvaPay, and the channel you use must be enabled for you. Otherwise you get
direct_api_not_enabledorchannel_not_allowed. - Own business only. The Direct API may be used only for the website / app / bot registered on your account. Every call must prove it: send an
ipn_urlon your registered domain, or have your server IP registered with ZurvaPay. Otherwise you getown_business_only. Processing payments for other businesses (aggregator / payment-facilitator) is allowed only with written approval from ZurvaPay, and then every client business must be registered as a sub-merchant with its own KYC. - Prohibited businesses. Gambling, betting, adult content, ponzi / investment schemes, hawala / hundi, drugs, counterfeit goods and any other illegal activity are not allowed. A payment whose description names such a purpose is refused (
prohibited_purpose) and the account can be suspended. - Server-to-server only. A request that carries a browser
Originheader is refused, so the secret key can never be exposed in a web page or app. - The website / app / server IP registered on your account (domain lock) applies exactly like the hosted checkout: your
ipn_urlmust be on your approved domain; an app account must sendapp_id. - Limits: 60 create requests per minute and 240 status requests per minute per IP.
Sandbox (Testing)
Test your whole integration with fake money before you go live. The sandbox is a separate copy of this API: same endpoints, same requests and responses, same IPN and signatures. To go live you change only two things: the base URL and the keys. Everything else in your code stays the same.
| Sandbox | Live | |
|---|---|---|
| Base URL | https://zurvapay.com/sandbox | https://zurvapay.com |
| Create a payment | https://zurvapay.com/sandbox/api/v1/payments | https://zurvapay.com/api/v1/payments |
X-Public-Key | zpsbpubxtldqpqa9idmmugmj7hn3ahyjx5ddjj4egp77wgl21f | your own public key |
X-Secret-Key | zpsbsec24ciz8ndeygu7magezp65e9dycza5cqbku878ycu4ah | your own secret key |
- The sandbox keys are the same for everybody, need no account and work only on the sandbox URL. Your live keys do not work on the sandbox URL and the sandbox keys do not work on the live API.
- No money moves and no real gateway is called. The QR is a test QR: it cannot be paid from a bank app, the sandbox "pays" it for you.
- Responses carry
"sandbox": true, and payment_trx starts withSBX. The IPN is signed with the sandbox secret key shown above, so verify it with that key while testing. - Your
ipn_urlmust be a public http(s) URL (port 80, 443, 8080 or 8443). localhost and private addresses are refused: use a tunnel such as ngrok, or just poll the status API. - Webhook (IPN): a paid sandbox payment is POSTed to your ipn_url exactly like live (status, signature, status_signature, identifier, data[...]); answer HTTP 200 within 10 seconds. If you answer anything else we retry up to 5 times (about every 5 minutes) and the status API shows
ipn_delivered. Live retries more often and for longer. - The same identifier may be used again in the sandbox by another developer, so use your own prefix. Sandbox data is deleted after 14 days. Same limits as live (60 create / 240 status requests per minute).
The amount decides the result
| Amount | Raast QR (dqrc) | JazzCash / EasyPaisa |
|---|---|---|
any normal amount, e.g. 100 | Pending, then paid about 10 seconds later (status API and IPN) | Paid immediately (HTTP 200) |
ends in .01, e.g. 100.01 | Pending, then failed (declined) about 5 seconds later | Declined immediately (HTTP 402 payment_failed) |
ends in .02, e.g. 100.02 | Stays pending until the QR expires, then failed (QR expired) | Pending (HTTP 202) until it expires |
You can also force the result of a pending payment at any time (sandbox only):
POST https://zurvapay.com/sandbox/api/v1/payments/{identifier}/simulate outcome = success | failed. A success sends the IPN to your ipn_url immediately.
# 1) create a Raast QR payment in the SANDBOX
curl -X POST "https://zurvapay.com/sandbox/api/v1/payments" \
-H "X-Public-Key: zpsbpubxtldqpqa9idmmugmj7hn3ahyjx5ddjj4egp77wgl21f" \
-H "X-Secret-Key: zpsbsec24ciz8ndeygu7magezp65e9dycza5cqbku878ycu4ah" \
-H "Content-Type: application/json" \
-d '{"identifier":"MYTEST-1001","amount":100,"currency":"PKR","channel":"dqrc","details":"Test order","customer_name":"Test User","customer_email":"test@example.com","ipn_url":"https://your-public-server.com/zurvapay_ipn.php"}'
# 2) read the status (it turns to "success" about 10 seconds later)
curl "https://zurvapay.com/sandbox/api/v1/payments/MYTEST-1001" \
-H "X-Public-Key: zpsbpubxtldqpqa9idmmugmj7hn3ahyjx5ddjj4egp77wgl21f" \
-H "X-Secret-Key: zpsbsec24ciz8ndeygu7magezp65e9dycza5cqbku878ycu4ah"
# 3) or force the result right now
curl -X POST "https://zurvapay.com/sandbox/api/v1/payments/MYTEST-1001/simulate" \
-H "X-Public-Key: zpsbpubxtldqpqa9idmmugmj7hn3ahyjx5ddjj4egp77wgl21f" \
-H "X-Secret-Key: zpsbsec24ciz8ndeygu7magezp65e9dycza5cqbku878ycu4ah" \
-H "Content-Type: application/json" -d '{"outcome":"success"}'# Keep your code identical. Change only these two settings:
ZURVAPAY_BASE_URL = https://zurvapay.com/sandbox -> https://zurvapay.com
ZURVAPAY_PUBLIC_KEY = (sandbox key) -> your live public key
ZURVAPAY_SECRET_KEY = (sandbox key) -> your live secret key
# then verify signatures with your LIVE secret key.Create a Payment
POST https://zurvapay.com/api/v1/payments
Send JSON (or form data) from your server with the two key headers. The answer depends on the channel — see the next two sections.
| Parameter | Type | Required | Description |
|---|---|---|---|
identifier | string (64) | Yes | Your unique order reference. Letters, digits, dot, dash, underscore. Never reuse it: every payment attempt needs a new one. |
channel | string | Yes | jazzcash | easypaisa | dqrc |
amount | decimal | Yes | Amount in PKR (minimum 1, up to 2 decimals). |
currency | string | No | PKR (default and only supported value) |
details | string (100) | Yes | What the customer is paying for. |
customer_name | string (40) | Yes | Customer name. |
customer_email | email (40) | Yes | Customer email. |
customer_phone | string | jazzcash / easypaisa | Customer mobile number: 03XXXXXXXXX (92… and +92… are accepted). For dqrc it is needed only if your account asks for it. |
ipn_url | url (255) | No | Your server endpoint that receives the payment notification. Without it you rely on the status API / cron. |
app_id | string | App accounts | Your app package name, if your merchant account is approved for a mobile app. |
HTTP status: 201 QR created · 200 paid · 202 pending · 402 declined · 4xx/5xx error (see Errors).
Always check success == "ok" before using the response. Set your HTTP timeout to at least 170 seconds for jazzcash / easypaisa.
<?php
// Server-side only. Never put the secret key in browser / app code.
$publicKey = 'YOUR_PUBLIC_KEY';
$secretKey = 'YOUR_SECRET_KEY';
// 1) Save the order in YOUR database first (status = pending) under a unique identifier.
$identifier = 'ORD' . time() . rand(100, 999);
$payload = [
'identifier' => $identifier,
'amount' => 500, // PKR
'currency' => 'PKR',
'channel' => 'dqrc', // jazzcash | easypaisa | dqrc
'details' => 'Order #1001',
'customer_name' => 'Ali Khan',
'customer_email' => 'ali@example.com',
'customer_phone' => '03001234567', // required for jazzcash / easypaisa
'ipn_url' => 'https://yourshop.com/zurvapay_ipn.php', // optional (see below)
];
$ch = curl_init('https://zurvapay.com/api/v1/payments');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 170, // JazzCash / EasyPaisa wait for the customer (up to ~2 min)
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Accept: application/json',
'X-Public-Key: ' . $publicKey,
'X-Secret-Key: ' . $secretKey,
],
]);
$body = curl_exec($ch);
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$res = json_decode($body, true);
if (($res['success'] ?? '') === 'ok') {
$p = $res['payment']; // status: success | pending
if ($p['channel'] === 'dqrc') {
$qrText = $p['qr']['text']; // draw this as a QR code on YOUR page
}
// success -> mark the order paid (after checking the signature, see "Verify the result")
// pending -> keep the order pending; the IPN / status API will tell you the result
} else {
// $res['code'] and $res['message'] say what is wrong; $http is 4xx / 5xx
// payment_failed (402): the customer declined / wrong number / no balance - create a NEW identifier to retry
}
# DQRC (Raast QR)
curl -X POST "https://zurvapay.com/api/v1/payments" \
-H "Content-Type: application/json" \
-H "X-Public-Key: YOUR_PUBLIC_KEY" \
-H "X-Secret-Key: YOUR_SECRET_KEY" \
--max-time 170 \
-d '{
"identifier": "ORD1790000000123",
"amount": 500,
"currency": "PKR",
"channel": "dqrc",
"details": "Order 1001",
"customer_name": "Ali Khan",
"customer_email": "ali@example.com",
"ipn_url": "https://yourshop.com/zurvapay_ipn.php"
}'
# JazzCash / EasyPaisa: same call with "channel": "jazzcash" (or "easypaisa") and "customer_phone": "03001234567"
import time, random, requests
PUBLIC_KEY = "YOUR_PUBLIC_KEY"
SECRET_KEY = "YOUR_SECRET_KEY" # server only
payload = {
"identifier": f"ORD{int(time.time())}{random.randint(100, 999)}",
"amount": 500,
"currency": "PKR",
"channel": "dqrc", # jazzcash | easypaisa | dqrc
"details": "Order 1001",
"customer_name": "Ali Khan",
"customer_email": "ali@example.com",
"customer_phone": "03001234567", # required for jazzcash / easypaisa
"ipn_url": "https://yourshop.com/zurvapay_ipn",
}
r = requests.post(
"https://zurvapay.com/api/v1/payments",
json=payload,
headers={"X-Public-Key": PUBLIC_KEY, "X-Secret-Key": SECRET_KEY},
timeout=170, # wallet channels wait for the customer
)
res = r.json()
if res.get("success") == "ok":
p = res["payment"]
if p["channel"] == "dqrc":
qr_text = p["qr"]["text"] # draw it as a QR code on your page
else:
print(r.status_code, res.get("code"), res.get("message"))
// Node.js 18+ (built-in fetch). Server only.
const PUBLIC_KEY = 'YOUR_PUBLIC_KEY';
const SECRET_KEY = 'YOUR_SECRET_KEY';
const payload = {
identifier: 'ORD' + Date.now() + Math.floor(Math.random() * 900 + 100),
amount: 500,
currency: 'PKR',
channel: 'dqrc', // jazzcash | easypaisa | dqrc
details: 'Order 1001',
customer_name: 'Ali Khan',
customer_email: 'ali@example.com',
customer_phone: '03001234567', // required for jazzcash / easypaisa
ipn_url: 'https://yourshop.com/zurvapay-ipn',
};
const r = await fetch('https://zurvapay.com/api/v1/payments', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Public-Key': PUBLIC_KEY, 'X-Secret-Key': SECRET_KEY },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(170000), // wallet channels wait for the customer
});
const res = await r.json();
if (res.success === 'ok') {
const p = res.payment;
if (p.channel === 'dqrc') { /* draw p.qr.text as a QR code */ }
} else {
console.log(r.status, res.code, res.message);
}
// DQRC - HTTP 201
{
"success": "ok",
"message": "QR generated. Show it to the customer; the result arrives on your ipn_url and in the status API.",
"payment": {
"identifier": "ORD1790000000123",
"payment_trx": "K5272J51BBIN",
"channel": "dqrc",
"status": "pending",
"amount": "500.00000000",
"currency": "PKR",
"customer_name": "Ali Khan",
"customer_email": "ali@example.com",
"created_at": "2026-01-01 12:00:00",
"updated_at": "2026-01-01 12:00:00",
"qr": { "text": "00020101021226...", "expires_at": "2026-01-01 12:30:00", "expires_in": 1800 }
}
}
// JazzCash / EasyPaisa, customer approved - HTTP 200
{
"success": "ok",
"message": "Payment received.",
"payment": {
"identifier": "ORD1790000000123", "payment_trx": "K5272J51BBIN", "channel": "jazzcash",
"status": "success", "amount": "500.00000000", "currency": "PKR",
"charge": "17.5", "gateway_reference": "T20260101120005",
"signature": "3F2A...", "status_signature": "9B1C...", "ipn_delivered": false
}
}
// Gateway did not answer in time (customer may still approve) - HTTP 202
{ "success": "ok", "message": "The gateway did not answer in time ...", "payment": { "status": "pending", ... } }
// Customer declined / wrong number / insufficient balance - HTTP 402
{ "error": "true", "code": "payment_failed", "message": "Insufficient balance", "payment": { "status": "failed", ... } }
// Other errors
{ "error": "true", "code": "invalid_api_key", "message": "Invalid api key." }
{ "error": "true", "code": "direct_api_not_enabled", "message": "Direct API is not enabled for your account. ..." }
{ "error": "true", "code": "channel_not_allowed", "message": "Bank QR (DQRC) is not enabled for your account. ..." }
{ "error": "true", "code": "duplicate_identifier", "message": "...", "payment": { ... existing payment ... } }Channel: Raast QR (dqrc)
The response contains payment.qr.text — the Raast / EMV QR string. Draw it as a QR code on your page (any QR library). The customer scans it with any bank or wallet app that supports Raast QR and pays the exact amount.
- The QR is valid for about 30 minutes (
qr.expires_inseconds). After that the payment becomesfailed(reason: QR expired); create a new payment with a new identifier. - Poll YOUR own server every 3–5 seconds; your server calls
GET /api/v1/payments/{identifier}. Never call it from the browser: it needs the secret key. - The IPN is sent as soon as the payment is confirmed.
- Render the QR at least 250 px wide with error correction M or higher.
<!-- YOUR checkout page. qrText comes from YOUR server (the "payment.qr.text" you got from ZurvaPay). -->
<div id="qr"></div>
<div id="state">Scan with any banking app (Raast QR)</div>
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
<script>
// any QR library works: qrcode.js, node-qrcode, endroid/qr-code (PHP), python-qrcode ...
new QRCode(document.getElementById('qr'), { text: qrText, width: 280, height: 280, correctLevel: QRCode.CorrectLevel.M });
// poll YOUR server (never ZurvaPay directly - the secret key stays on your server) every 4 seconds
const timer = setInterval(async () => {
const r = await fetch('/check_order.php?identifier=' + encodeURIComponent(identifier));
const s = await r.json(); // your own endpoint, see the PHP example below
if (s.status === 'success') { clearInterval(timer); document.getElementById('state').textContent = 'Paid. Thank you!'; }
if (s.status === 'failed') { clearInterval(timer); document.getElementById('state').textContent = 'QR expired. Please try again.'; }
}, 4000);
</script>
<?php
// check_order.php - YOUR endpoint called by your page's JavaScript. It asks ZurvaPay (server to server).
$identifier = $_GET['identifier'] ?? '';
$ch = curl_init('https://zurvapay.com/api/v1/payments/' . rawurlencode($identifier));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
CURLOPT_HTTPHEADER => ['X-Public-Key: YOUR_PUBLIC_KEY', 'X-Secret-Key: YOUR_SECRET_KEY'],
]);
$res = json_decode(curl_exec($ch), true);
curl_close($ch);
$p = $res['payment'] ?? null;
if ($p && $p['status'] === 'success') {
// verify the signature (see "Verify the result"), compare the amount with YOUR order, then mark it paid ONCE
}
header('Content-Type: application/json');
echo json_encode(['status' => $p['status'] ?? 'unknown']);
Channels: JazzCash & EasyPaisa
Send the customer's wallet number in customer_phone. The customer receives an approval request in the JazzCash / EasyPaisa app (or a PIN prompt) and the create call stays open until they answer — up to about two minutes.
| Answer | Meaning | What to do |
|---|---|---|
200 status: success | Customer approved; the money is credited to your wallet. | Verify the signature, mark the order paid once. |
402 payment_failed | Declined: wrong number, insufficient balance, customer refused. | Show the message. To retry, create a NEW payment with a new identifier. |
202 status: pending | The gateway did not answer in time. The customer may still have approved. | Keep the order pending. The IPN / status API / background check will settle it. Do not charge again. |
Payment Status
GET https://zurvapay.com/api/v1/payments/{identifier} POST https://zurvapay.com/api/v1/payments/status
Ask for one payment by your identifier, or up to 50 at once ( identifiers[]). Pending payments are re-checked with the gateway when you ask, so polling shows the result within seconds. Headers: the same two keys.
| status | Meaning | What to do |
|---|---|---|
success | Paid and credited. Carries signature / status_signature. | Verify, compare the amount, complete the order once. |
pending | Waiting for the customer (QR not paid yet / approval pending). | Keep waiting; poll again. |
failed | QR expired, declined or cancelled. reason says why. | Create a new payment with a new identifier if the customer wants to retry. |
not_found | No payment with this identifier on your account. | Check the identifier. |
# One payment
curl "https://zurvapay.com/api/v1/payments/ORD1790000000123" \
-H "X-Public-Key: YOUR_PUBLIC_KEY" -H "X-Secret-Key: YOUR_SECRET_KEY"
# Up to 50 payments at once
curl -X POST "https://zurvapay.com/api/v1/payments/status" \
-H "Content-Type: application/json" \
-H "X-Public-Key: YOUR_PUBLIC_KEY" -H "X-Secret-Key: YOUR_SECRET_KEY" \
-d '{"identifiers": ["ORD1790000000123", "ORD1790000000124"]}'
<?php
$ch = curl_init('https://zurvapay.com/api/v1/payments/status');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['identifiers' => ['ORD1790000000123', 'ORD1790000000124']]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Public-Key: YOUR_PUBLIC_KEY', 'X-Secret-Key: YOUR_SECRET_KEY'],
]);
$res = json_decode(curl_exec($ch), true);
curl_close($ch);
foreach (($res['payments'] ?? []) as $p) {
echo $p['identifier'], ' => ', $p['status'], "\n"; // success | pending | failed | not_found
}
// Pending QR payment
{ "success": "ok", "message": "Payment status fetched.",
"payment": { "identifier": "ORD1790000000123", "channel": "dqrc", "status": "pending", "amount": "500.00000000", "currency": "PKR",
"qr": { "text": "00020101...", "expires_at": "2026-01-01 12:30:00", "expires_in": 1260 } } }
// Paid
{ "success": "ok", "message": "Payment status fetched.",
"payment": { "identifier": "ORD1790000000123", "channel": "dqrc", "status": "success", "amount": "500.00000000", "currency": "PKR",
"charge": "17.5", "gateway_reference": "...", "signature": "3F2A...", "status_signature": "9B1C...", "ipn_delivered": true } }
// Expired / declined
{ "success": "ok", "message": "Payment status fetched.",
"payment": { "identifier": "ORD1790000000123", "channel": "dqrc", "status": "failed", "reason": "QR expired" } }
// Batch: { "success": "ok", "payments": [ { ...payment... }, { "identifier": "X", "status": "not_found" } ] }Verify the Result (signatures)
The create response, the status API and the IPN carry the same two signatures, computed with YOUR secret key over the raw amount string. Mark an order paid only after both match, the amount is not less than your order amount, and only once:
signature= UPPER( HMAC-SHA256(amount+identifier, secret_key ) )status_signature= UPPER( HMAC-SHA256("success|" + identifier + "|" + amount, secret_key ) )
The IPN is a form POST with status, signature, status_signature, identifier and data[amount], data[payment_trx], data[payment_method], data[charge], data[payment_type] (direct). Answer it with HTTP 200 — otherwise it is retried (at once, then after about 2, 5, 15, 60, 180 and 360 minutes; 10 attempts in total). Full IPN handlers in PHP / Python / Node.js are in the Hosted Checkout tab (IPN section): they work unchanged.
K5272J51BBIN. It is returned in payment.payment_trx when you create the payment, in data[payment_trx] of the IPN and in payment_trx of the status API. Save it with your order and show it as the transaction ID of the ZurvaPay payment in your payment history, so you and ZurvaPay support always talk about the same transaction. Keep your own identifier only to find your order.<?php
// Works for the create response, the status API AND the IPN: all carry the same two signatures.
function zurvapay_verify(array $p, string $secretKey): bool
{
$amount = $p['amount']; // use the RAW string exactly as received (e.g. "500.00000000")
$sig = strtoupper(hash_hmac('sha256', $amount . $p['identifier'], $secretKey));
$stat = strtoupper(hash_hmac('sha256', 'success|' . $p['identifier'] . '|' . $amount, $secretKey));
return hash_equals($sig, strtoupper($p['signature'])) && hash_equals($stat, strtoupper($p['status_signature']));
}
// In the IPN (form POST) the same values arrive as: signature, status_signature, identifier, data[amount] ...
// $p = ['amount' => $_POST['data']['amount'], 'identifier' => $_POST['identifier'],
// 'signature' => $_POST['signature'], 'status_signature' => $_POST['status_signature']];
// if ($_POST['status'] === 'success' && zurvapay_verify($p, SECRET)) { ...complete the order ONCE... }
// Always answer the IPN with HTTP 200.
Optional Cron Job
Optional. Your integration works without it. Use it as your main method if you do not send an ipn_url (polling), or as a safety net next to the IPN webhook. Add it if you want zero orders stuck as pending — for example if your server was down while we sent the IPN.
On ZurvaPay (nothing to do)
- Raast QR: the payment is credited the moment the bank confirms it, and every unfinished QR payment is re-checked continuously; expired ones are closed.
- JazzCash / EasyPaisa: a payment the customer approved but whose answer never reached us is re-checked 10–30 minutes later and credited automatically; your IPN is then sent (retried up to 10 times).
On your server (optional, recommended)
Every 5 minutes it:
- loads YOUR ZurvaPay orders that are still pending and less than 24 hours old (max 50);
- asks /api/v1/payments/status in one call;
- for every
success: verifiesstatus_signature, checks the amount, and completes the order withUPDATE … WHERE status = 'pending'so it can never double-credit even if the IPN arrives at the same moment.
Adapt the table/column names (orders, identifier, amount, status, gateway, created_at) and the deliver function to your system.
<?php
/**
* zurvapay_cron.php — OPTIONAL. Completes orders whose customer paid but
* the IPN never reached you (checkout error, closed browser, server down).
* Run it every 5 minutes with a cron job (see "Schedule it" below).
*/
const PUBLIC_KEY = 'YOUR_PUBLIC_KEY';
const SECRET_KEY = 'YOUR_SECRET_KEY';
const STATUS_URL = 'https://zurvapay.com/api/v1/payments/status';
$pdo = new PDO('mysql:host=localhost;dbname=YOUR_DB;charset=utf8mb4', 'DB_USER', 'DB_PASS', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
// 1) Your ZurvaPay orders still pending, created in the last 24 hours
$rows = $pdo->query("SELECT id, identifier, amount FROM orders
WHERE gateway = 'zurvapay' AND status = 'pending'
AND created_at >= NOW() - INTERVAL 24 HOUR
ORDER BY id DESC LIMIT 50")->fetchAll(PDO::FETCH_ASSOC);
if (!$rows) exit("Nothing to check\n");
$orders = array_column($rows, null, 'identifier');
// 2) Ask ZurvaPay (max 50 identifiers per call)
$ch = curl_init(STATUS_URL);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['identifiers' => array_keys($orders)]),
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Public-Key: ' . PUBLIC_KEY, 'X-Secret-Key: ' . SECRET_KEY],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$res = json_decode(curl_exec($ch), true);
curl_close($ch);
if (($res['success'] ?? '') !== 'ok') exit('Status API error: ' . ($res['message'] ?? 'no response') . "\n");
// 3) Complete the paid ones — same checks as your IPN handler
foreach ($res['payments'] as $p) {
if ($p['status'] !== 'success') continue;
$expected = strtoupper(hash_hmac('sha256', $p['status'] . '|' . $p['identifier'] . '|' . $p['amount'], SECRET_KEY));
if (!hash_equals($expected, strtoupper($p['status_signature']))) continue;
$order = $orders[$p['identifier']] ?? null;
if (!$order || (float) $p['amount'] < (float) $order['amount']) continue;
// Update only if STILL pending -> never double-credits (IPN may arrive at the same time)
$pdo->beginTransaction();
$upd = $pdo->prepare("UPDATE orders SET status = 'paid', payment_trx = ? WHERE id = ? AND status = 'pending'");
$upd->execute([$p['payment_trx'] ?? '', $order['id']]);
if ($upd->rowCount() === 1) {
deliver_order($pdo, $order['id']); // your function: add balance / ship / activate ...
}
$pdo->commit();
echo "Paid: {$p['identifier']}\n";
}
#!/usr/bin/env python3
# zurvapay_cron.py — OPTIONAL. Run every 5 minutes:
# */5 * * * * /usr/bin/python3 /home/USER/zurvapay_cron.py
import hmac, hashlib, requests, pymysql
PUBLIC_KEY, SECRET_KEY = "YOUR_PUBLIC_KEY", "YOUR_SECRET_KEY"
STATUS_URL = "https://zurvapay.com/api/v1/payments/status"
def sign(msg):
return hmac.new(SECRET_KEY.encode(), msg.encode(), hashlib.sha256).hexdigest().upper()
db = pymysql.connect(host="localhost", user="DB_USER", password="DB_PASS", database="YOUR_DB", autocommit=False)
cur = db.cursor(pymysql.cursors.DictCursor)
cur.execute("""SELECT id, identifier, amount FROM orders
WHERE gateway='zurvapay' AND status='pending'
AND created_at >= NOW() - INTERVAL 24 HOUR
ORDER BY id DESC LIMIT 50""")
orders = {o["identifier"]: o for o in cur.fetchall()}
if not orders:
raise SystemExit("Nothing to check")
res = requests.post(STATUS_URL, json={"identifiers": list(orders.keys())},
headers={"X-Public-Key": PUBLIC_KEY, "X-Secret-Key": SECRET_KEY}, timeout=30).json()
if res.get("success") != "ok":
raise SystemExit(f"Status API error: {res.get('message')}")
for p in res["payments"]:
if p["status"] != "success":
continue
if not hmac.compare_digest(sign(f"{p['status']}|{p['identifier']}|{p['amount']}"), p["status_signature"].upper()):
continue
order = orders.get(p["identifier"])
if not order or float(p["amount"]) < float(order["amount"]):
continue
# only if still pending -> no double credit
changed = cur.execute("UPDATE orders SET status='paid', payment_trx=%s WHERE id=%s AND status='pending'",
(p.get("payment_trx", ""), order["id"]))
if changed == 1:
deliver_order(cur, order["id"]) # your function
db.commit()
print("Paid:", p["identifier"])
// zurvapay_cron.js — OPTIONAL. Run every 5 minutes:
// */5 * * * * /usr/bin/node /home/USER/zurvapay_cron.js
const crypto = require('crypto');
const mysql = require('mysql2/promise');
const PUBLIC_KEY = 'YOUR_PUBLIC_KEY', SECRET_KEY = 'YOUR_SECRET_KEY';
const STATUS_URL = 'https://zurvapay.com/api/v1/payments/status';
const sign = (m) => crypto.createHmac('sha256', SECRET_KEY).update(m).digest('hex').toUpperCase();
(async () => {
const db = await mysql.createConnection({ host: 'localhost', user: 'DB_USER', password: 'DB_PASS', database: 'YOUR_DB' });
const [rows] = await db.query(`SELECT id, identifier, amount FROM orders
WHERE gateway='zurvapay' AND status='pending'
AND created_at >= NOW() - INTERVAL 24 HOUR
ORDER BY id DESC LIMIT 50`);
if (!rows.length) return db.end();
const orders = Object.fromEntries(rows.map(o => [o.identifier, o]));
const res = await (await fetch(STATUS_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Public-Key': PUBLIC_KEY, 'X-Secret-Key': SECRET_KEY },
body: JSON.stringify({ identifiers: Object.keys(orders) }),
})).json();
if (res.success !== 'ok') { console.error('Status API error:', res.message); return db.end(); }
for (const p of res.payments) {
if (p.status !== 'success') continue;
if (sign(`${p.status}|${p.identifier}|${p.amount}`) !== String(p.status_signature).toUpperCase()) continue;
const order = orders[p.identifier];
if (!order || Number(p.amount) < Number(order.amount)) continue;
// only if still pending -> no double credit
const [r] = await db.execute("UPDATE orders SET status='paid', payment_trx=? WHERE id=? AND status='pending'",
[p.payment_trx || '', order.id]);
if (r.affectedRows === 1) await deliverOrder(db, order.id); // your function
console.log('Paid:', p.identifier);
}
await db.end();
})();
Schedule It
In cPanel / Hostinger hPanel open Cron Jobs, choose "Every 5 minutes" and paste the command. Running it more often than every minute is not needed.
If you use a URL-based cron, protect the script with a long secret in the URL and reject requests without it.
# cPanel / hPanel -> Cron Jobs -> "Every 5 minutes"
*/5 * * * * /usr/bin/php /home/USERNAME/public_html/zurvapay_cron.php >/dev/null 2>&1
# If your host only allows URL crons, protect the URL with a secret key:
*/5 * * * * curl -s "https://yourdomain.com/zurvapay_cron.php?key=LONG_RANDOM_SECRET" >/dev/null 2>&1Errors
Errors come as {"error":"true","code":"…","message":"…"} with an HTTP status 4xx / 5xx.
| code | HTTP | Meaning / fix |
|---|---|---|
invalid_api_key | 401 | Missing / wrong public or secret key, or API access disabled. |
direct_api_not_enabled | 403 | Direct API is not enabled on your account. Contact support. |
channel_not_allowed | 403 | This channel is not enabled for your account (or is switched off). Contact support. |
browser_not_allowed | 403 | Call from your server, not from browser code (Origin header present). |
domain_not_allowed | 403 | ipn_url / app / server IP is not the one registered on your account. |
validation_error | 422 | A parameter is missing or invalid; the message says which. |
invalid_channel / currency_not_supported | 422 | Use jazzcash, easypaisa or dqrc, and PKR. |
own_business_only | 403 | Send an ipn_url on your registered domain, or ask support to register your server IP. Serving other businesses needs aggregator approval. |
prohibited_purpose | 422 | The description names a prohibited / illegal purpose. Not allowed. |
duplicate_identifier | 409 | This identifier already exists. The existing payment is returned in payment. |
request_in_progress | 409 | The same identifier is being processed right now. |
payment_failed | 402 | Declined by the wallet / bank. Use a new identifier to retry. |
gateway_error / dqrc_unavailable | 502 / 503 | The gateway could not create the payment. Retry with a new identifier. |
not_found | 404 | Unknown identifier. |
| HTTP 429 | 429 | Too many requests — slow down. |
Security Checklist
- Secret key only on your server. Never in JavaScript, a mobile app or a public repository.
- Your own page / app talks to YOUR server; your server talks to ZurvaPay.
- Mark an order paid ONLY after verifying both signatures and the amount — never from what the browser says.
- Complete an order once: UPDATE … WHERE status = pending, or a row lock inside a transaction.
- Always answer the IPN with HTTP 200.
- Use a new identifier for every payment attempt; store it with your order.
- Testing from your own computer (VS Code, localhost)? Send ZurvaPay support the public IP of your machine. Once it is whitelisted you can use
http://localhost:PORT/...as ipn_url while testing. Localhost URLs from any other IP are refused. Note: ZurvaPay cannot reach localhost, so for IPN testing use a tunnel (ngrok / cloudflared) or poll the status API. - Do not call create twice for the same order while a wallet request is running (the second call is refused as
request_in_progress).
Developer / AI Brief
Copy this brief together with the code on this tab to a developer or an AI assistant: