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://www.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.
- IPN — we POST the result to your
ipn_url. Verify the signature and mark the order paid. This is the ONLY trusted confirmation. - 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 8 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://www.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 | Yes | Your server endpoint that receives the payment notification. |
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://www.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://www.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://www.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://www.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://www.zurvapay.com/initiate/payment/checkout?payment_id=eyJpdiI6..."
}
// 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)
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. |
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 after about 2, 5, 15, 30, 60, 180 and 360 minutes (8 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://www.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://www.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://www.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://www.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://www.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://www.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 (IPN + our retries). Add it if you want zero orders stuck as pending — for example if your server was down while we sent the IPN.
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://www.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://www.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://www.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 or a verified status-API response — 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: