Developer Docs

PaymentOZ API

Accept VND bank-transfer payments with two endpoints, a hosted checkout page, and a signed webhook. Your keys are on your dashboard under Projects → your site → Integration.

REST + JSONHosted checkoutHMAC-signed webhooksPerfectPanel ready

Authentication

Every API call is authenticated with your merchant profile's key pair, sent as headers:

http
x-api-key: pk_your_public_key
x-api-secret: sk_your_secret_key

The secret is shown once when keys are created or regenerated — store it safely. Keys of profiles that are not yet approved are rejected.

Create a payment

POST https://paymentoz.com/api/create_payment
curl
curl -X POST https://paymentoz.com/api/create_payment \
  -H "x-api-key: pk_your_public_key" \
  -H "x-api-secret: sk_your_secret_key" \
  -H "Content-Type: application/json" \
  -d '{"amount_vnd": 100000, "merchant_order_ref": "INV-1001", "method": "vnd"}'

Response 201:

json
{
  "order_id": 42,
  "memo": "ZFZT4B",
  "amount_vnd": 100000,
  "method": "vnd",
  "checkout_url": "/pay/42"
}

Send the customer to https://paymentoz.com/pay/<order_id>. The order expires after 30 minutes. merchant_order_ref is your own reference (invoice number, panel order id) and is echoed back in the webhook.

Check payment status

GET https://paymentoz.com/api/payment_status/:order_id

No auth required; the order id is unguessable enough for polling from your checkout, but rely on the webhook for crediting.

json
{ "order_id": 42, "status": "paid" }  // pending | paid | expired

Webhook & signature verification

When a payment is confirmed by our bank-side detection, we POST JSON to your callback URL (set per merchant profile on the Integration page):

json
{
  "event": "payment.paid",
  "event_id": "b3e1…",            // idempotency key — ignore duplicates
  "order_id": 42,
  "merchant_order_ref": "INV-1001",
  "memo": "ZFZT4B",
  "amount_vnd": 100000,              // actual credited amount
  "expected_amount_vnd": 100000,
  "amount_flag": null,               // null | "overpaid"
  "method": "vnd",
  "bank_txn_id": "FT2026…",
  "paid_at": "2026-08-13T14:00:00.000Z"
}

The request carries two headers: X-Paymentoz-Timestamp: <unix seconds> and X-Paymentoz-Signature: t=<timestamp>,v1=<hex>. The v1 value is an HMAC-SHA256 of "<timestamp>.<raw request body>" using your profile's webhook secret. Always verify the signature and reject timestamps older than 5 minutes (replay protection) before crediting:

php
<?php
$raw = file_get_contents('php://input');
$sig_header = $_SERVER['HTTP_X_PAYMENTOZ_SIGNATURE'] ?? '';

// Parse "t=<timestamp>,v1=<hex>"
if (!preg_match('/^t=(\d+),v1=([0-9a-f]+)$/', $sig_header, $m)) {
  http_response_code(401); exit;
}
[, $ts, $sig] = $m;

// Reject replays: timestamp must be within 5 minutes
if (abs(time() - (int)$ts) > 300) { http_response_code(401); exit; }

// HMAC over "<timestamp>.<raw body>"
$expected = hash_hmac('sha256', $ts . '.' . $raw, $webhook_secret);
if (!hash_equals($expected, $sig)) { http_response_code(401); exit; }

$event = json_decode($raw, true);
// mark $event['merchant_order_ref'] as paid, credit the balance

Respond with HTTP 2xx. Anything else is retried with backoff up to 8 times. Deliveries can arrive more than once — use event_id for idempotency.

Amount tolerance: if the customer transfers more than the expected amount, the payment is confirmed and the actual received amount is credited (amount_flag: "overpaid"). If they transfer less, the payment is not auto-confirmed — it's held for manual review and no webhook is sent until it's resolved.

Idempotent creation: you may send an Idempotency-Key header on create_payment. If the same key is seen again within 24 hours, the original order is returned (HTTP 200) instead of creating a duplicate.

Error codes

All errors return JSON with an error field describing the problem.

StatusMeaning
400Invalid or missing parameters (details in error)
401Missing/invalid API keys, unapproved profile, or bad signature
404Order not found
429Too many requests — retry with backoff
500Server error — safe to retry; no order was created unless a body was returned

PerfectPanel setup guide

In your PerfectPanel admin, add a custom / manual payment gateway with:

SettingValue
Payment URLhttps://paymentoz.com/api/perfectpanel/pay
KeyYour profile's public API key (pk_…)
Parameters sentkey, amount (VND), order_id (panel reference), sign
Request signaturesign = md5(key + amount + order_id + webhook_secret)

Then, on your PaymentOZ Integration page, set your panel's callback URL and return URL, and switch the callback format to PerfectPanel. When a payment is confirmed we POST (form-encoded) to your callback:

form
order_id=INV-1001&payment_id=42&amount=100000&status=success&sign=md5(order_id + amount + status + webhook_secret)

The customer is redirected back to your return URL with order_id, payment_id and status query parameters. Field names are adjustable on our side — if PerfectPanel's spec expects different parameters, send it to us and we align the gateway to it.