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.
Authentication
Every API call is authenticated with your merchant profile's key pair, sent as headers:
x-api-key: pk_your_public_key
x-api-secret: sk_your_secret_keyThe 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
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:
{
"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
No auth required; the order id is unguessable enough for polling from your checkout, but rely on the webhook for crediting.
{ "order_id": 42, "status": "paid" } // pending | paid | expiredWebhook & 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):
{
"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
$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 balanceRespond 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.
| Status | Meaning |
|---|---|
400 | Invalid or missing parameters (details in error) |
401 | Missing/invalid API keys, unapproved profile, or bad signature |
404 | Order not found |
429 | Too many requests — retry with backoff |
500 | Server 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:
| Setting | Value |
|---|---|
| Payment URL | https://paymentoz.com/api/perfectpanel/pay |
| Key | Your profile's public API key (pk_…) |
| Parameters sent | key, amount (VND), order_id (panel reference), sign |
| Request signature | sign = 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:
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.