Developer - Api Documentation

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


  1. Create order — save it in YOUR database as pending with a unique identifier.
  2. Initiate — POST the order to /payment/initiate and receive a checkout url.
  3. Redirect — send the customer to that URL. They pay with JazzCash / EasyPaisa / QR.
  4. IPN — we POST the result to your ipn_url. Verify the signature and mark the order paid. This is the ONLY trusted confirmation.
  5. Return — the customer is sent to your success_url (or cancel_url). Only show a message there — never mark the order paid from these pages.
  6. 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:

KeyUsed forWhere
public_keyIdentifies your business when starting a payment.Server (may appear in forms)
secret_keyVerifies 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 NameSymbolCode
Pakistani RupeePKRPKR

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.

ParameterTypeRequiredDescription
public_keystring (50)YesYour public key.
identifierstring (20)YesYour unique order reference. Must never repeat (a used identifier is rejected). Letters and digits are safest, e.g. ORD1790000000123.
currencystring (4)YesUpper-case code, e.g. PKR.
amountdecimalYesAmount to charge, greater than 0.
detailsstring (100)YesWhat the customer is paying for.
ipn_urlurlYesYour server endpoint that receives the payment notification.
success_urlurlYesWhere the customer is sent after paying.
cancel_urlurlYesWhere the customer is sent if they cancel.
customer_namestring (30)YesCustomer name.
customer_emailemail (30)YesCustomer email.
site_logourlNoLogo shown on checkout.
checkout_themedark | lightNoCheckout 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);
}
Responses
// 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).

FieldDescription
statusAlways success (an IPN is only sent for paid payments).
identifierYour order identifier.
signaturestrtoupper(hmac_sha256(data[amount] . identifier, secret_key))
status_signaturestrtoupper(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
  1. Verify signature and status_signature with your secret key (constant-time compare).
  2. Find YOUR order by identifier, check it is still pending and the amount matches.
  3. Mark it paid only once (database transaction / "UPDATE … WHERE status = pending") — the same payment can be notified more than once.
  4. 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' });
});
What your ipn_url receives
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).

ParameterRequiredDescription
public_keyYesYour public key (or header X-Public-Key).
secret_keyYesYour secret key (or header X-Secret-Key).
identifierOne ofA single identifier → response key payment.
identifiers[]One ofArray (or comma list) of up to 50 identifiers → response key payments.
Status values
statusMeaningWhat to do
successPaid.Verify status_signature, check amount, complete the order (once).
pendingCheckout opened, not paid yet (or still being verified).Check again later.
failedCancelled / expired / rejected (see reason). Mark your order failed.
not_foundThe 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);
Response
// 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:

  1. loads YOUR ZurvaPay orders that are still pending and less than 24 hours old (max 50);
  2. asks /payment/status in one call;
  3. for every success: verifies status_signature, checks the amount, and completes the order with UPDATE … 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.

Crontab
# 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.

MessageFix
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 429Too 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 signature and status_signature using the raw data[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:

We may use cookies or any other tracking technologies when you visit our website, including any other media form, mobile website, or mobile application related or connected to help customize the Site and improve your experience. learn more

Allow