A REST API for your orders, customers, transactions and invoices — plus signed webhooks that push events to your server the moment they happen.
Three steps and you're pulling live data. Everything below works against your real account — there is no separate sandbox host, so start with a Free-plan account if you'd rather not touch production data.
psk_ and is shown once.# List the 25 most recent orders curl "https://www.paysana.co/api/v1/orders" \ -H "Authorization: Bearer psk_your_key_here" \ -H "Accept: application/json"
Every request carries a bearer API key. Keys belong to one merchant account and inherit that account's data — there is no cross-account access.
Authorization: Bearer psk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Create and revoke keys under API Access in your dashboard. Paysana stores only a hash of the key, so a lost key can't be recovered — revoke it and mint a new one. Every key records when it was last used, so you can spot an unused one before revoking it.
The API is REST over HTTPS, returns JSON, and behaves the same way everywhere.
| Convention | How it works |
|---|---|
Base URL | https://www.paysana.co/api/v1 |
| Money | Always an integer in minor units — fields ending in _sen. 8900 means 89.00. Never a float, so nothing rounds badly in transit. |
| Currency | Every money-bearing object carries a currency field with your account's ISO code. It follows your account — don't assume one. |
| Dates | ISO 8601 with timezone, e.g. 2026-08-07T09:31:04+08:00. A null date means the thing hasn't happened yet. |
| Envelope | Single objects come back as {"data": {…}}; lists as {"data": […], "meta": {…}}. |
| Pagination | ?page= and ?per_page= (default 25, max 100). meta returns page, per_page and total. |
Errors use standard HTTP status codes with a JSON body. Validation failures follow Laravel's shape, with a message per field.
| Status | Meaning |
|---|---|
200 / 201 | Success. 201 when something was created. |
401 | Missing, malformed, revoked or unknown API key. |
404 | No such record on your account — the same response another merchant's reference would give. |
422 | Validation failed, or the action doesn't apply (e.g. shipping an order with nothing to ship). |
429 | Rate limited — see below. |
{
"message": "The amount sen field is required.",
"errors": {
"amount_sen": ["The amount sen field is required."]
}
}
120 requests per minute per API key. Responses carry X-RateLimit-Limit and X-RateLimit-Remaining; a 429 includes Retry-After in seconds. Back off and retry rather than hammering — and if you're polling for new orders, use webhooks instead.
Every sale across all your channels — payment forms, your storefront and invoice payments.
/api/v1/ordersList orders, newest firstFilter with ?status= (paid, pending, failed, refunded) and paginate with ?page= / ?per_page=.
{
"data": [
{
"reference": "PSN-4F2K1A",
"status": "paid",
"source": "form",
"item": "Merch Tee — Crew",
"quantity": 1,
"subtotal_sen": 8900,
"discount_sen": 0,
"shipping_sen": 800,
"total_sen": 9700,
"refund_sen": 0,
"currency": "MYR",
"payment_method": "FPX",
"buyer": {
"name": "Nur Aisyah",
"email": "nur@example.com",
"phone": "+60123334444"
},
"fulfilment": "To fulfil",
"tracking_number": null,
"created_at": "2026-08-07T09:31:04+08:00",
"paid_at": "2026-08-07T09:31:22+08:00"
}
],
"meta": { "page": 1, "per_page": 25, "total": 187 }
}
/api/v1/orders/{reference}Fetch one orderReturns the same object shape wrapped in data, or 404 if the reference isn't on your account.
| Field | Notes |
|---|---|
status | pending (awaiting payment), paid, failed, refunded (fully). A partial refund stays paid with refund_sen above zero. |
source | form for a payment-form checkout, store for a storefront basket. |
item | A readable summary — a multi-line basket reads "First item + 2 more". |
fulfilment | To fulfil, Shipped, Delivered, or — for anything that isn't shipped physically. |
refund_sen | Cumulative refunded amount. Net revenue is total_sen − refund_sen. |
Every buyer, captured automatically at checkout and rolled up across their orders.
/api/v1/customersList customers A–Z{
"data": [
{
"name": "Nur Aisyah",
"email": "nur@example.com",
"phone": "+60123334444",
"segment": "Returning",
"orders_count": 4,
"total_spent_sen": 38800,
"created_at": "2026-05-02T14:08:51+08:00"
}
],
"meta": { "page": 1, "per_page": 25, "total": 64 }
}
segment is derived, not stored: New, Returning (2+ orders) or VIP (high spend or 8+ orders). total_spent_sen counts paid orders net of refunds.
The money ledger behind every payment — one row per settled payment, plus negative rows for partial refunds so sums always net correctly.
/api/v1/transactionsList ledger rows, newest first{
"data": [
{
"reference": "PSN-4F2K1A",
"source": "form",
"description": "Merch Tee — Crew",
"amount_sen": 9700,
"fee_sen": 97,
"currency": "MYR",
"status": "paid",
"payment_method": "FPX",
"paid_at": "2026-08-07T09:31:22+08:00"
}
],
"meta": { "page": 1, "per_page": 25, "total": 412 }
}
fee_sen is your gateway's fee, not a Paysana cut — buyers pay into your own gateway account and paid plans take 0% of your sales.Create an invoice from your own system and hand the buyer a payable link. The invoice is created pending and settles itself the moment it's paid — no polling needed if you subscribe to webhooks.
/api/v1/invoicesCreate a pending invoice| Body field | Rules |
|---|---|
customer_name | Required. String, max 150. |
customer_email | Optional. A valid email, max 160 — supply it and the buyer can be emailed the link. |
amount_sen | Required. Integer minor units, min 100 (1.00), max 100000000. |
reference | Optional. Your own reference, max 150 — carried onto the invoice. |
due_days | Optional. 1–365, default 14. |
curl -X POST "https://www.paysana.co/api/v1/invoices" \ -H "Authorization: Bearer psk_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "customer_name": "Nur Aisyah", "customer_email": "nur@example.com", "amount_sen": 45000, "reference": "SO-2291", "due_days": 7 }'
{
"data": {
"number": "INV-0184",
"status": "pending",
"amount_sen": 45000,
"due_at": "2026-08-14T09:31:04+08:00",
"public_url": "https://www.paysana.co/i/9f2b7c1e…",
"pdf_url": "https://www.paysana.co/i/9f2b7c1e…/pdf"
}
}
public_url is keyed on an unguessable token, never the sequential invoice number — so your invoice book can't be enumerated by anyone who receives one link.Push shipping updates from your own WMS or courier integration. Marking an order shipped emails the buyer their courier and tracking number, and fires the order.shipped webhook.
/api/v1/orders/{reference}/fulfilmentMark shipped or delivered| Body field | Rules |
|---|---|
status | Required. shipped or delivered. |
courier | Optional. Max 60 — shown to the buyer. |
tracking_number | Optional. Max 80 — shown to the buyer and in their order lookup. |
curl -X PUT "https://www.paysana.co/api/v1/orders/PSN-4F2K1A/fulfilment" \ -H "Authorization: Bearer psk_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "status": "shipped", "courier": "J&T Express", "tracking_number": "JT0099881234" }'
Returns the updated order object. An order with nothing to ship — unpaid, or a digital/ticket/service purchase — returns 422 rather than silently pretending.
Register an endpoint under Logs → Webhooks in your dashboard, choose the events you care about, and Paysana POSTs each one to your server as JSON. You get a signing secret once, at creation.
| Event | Fires when |
|---|---|
order.paid | A payment settles (form, store or renewal) |
order.refunded | An order is refunded (full or partial) |
order.shipped | A physical order is marked shipped |
{
"event": "order.paid",
"created_at": "2026-08-07T09:31:22+08:00",
"data": {
"reference": "PSN-4F2K1A",
"status": "paid",
"source": "form",
"item": "Merch Tee — Crew",
"quantity": 1,
"total_sen": 9700,
"currency": "MYR",
"buyer": { "name": "Nur Aisyah", "email": "nur@example.com", "phone": "+60123334444" },
"paid_at": "2026-08-07T09:31:22+08:00"
}
}
order.refunded adds refund_sen, refund_total_sen and a partial boolean. order.shipped adds courier and tracking_number.
| Header | Value |
|---|---|
X-Paysana-Event | The event name, e.g. order.paid. |
X-Paysana-Signature | sha256=<hex hmac of the raw body>. |
X-Paysana-Delivery | Unique delivery id — use it to make your handler idempotent. |
Always verify before acting. Compute an HMAC-SHA256 of the raw request body using your endpoint secret and compare it to the header with a timing-safe comparison.
$payload = file_get_contents('php://input'); $signature = $_SERVER['HTTP_X_PAYSANA_SIGNATURE'] ?? ''; $expected = 'sha256=' . hash_hmac('sha256', $payload, $secret); if (! hash_equals($expected, $signature)) { http_response_code(403); exit; } $event = json_decode($payload, true); // … handle $event['event'] and $event['data'] … http_response_code(200);
const crypto = require('crypto'); app.post('/paysana/webhook', express.raw({ type: 'application/json' }), // raw body, not parsed (req, res) => { const expected = 'sha256=' + crypto.createHmac('sha256', process.env.PAYSANA_SECRET) .update(req.body).digest('hex'); const given = req.get('X-Paysana-Signature') || ''; if (expected.length !== given.length || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(given))) { return res.sendStatus(403); } const event = JSON.parse(req.body.toString()); // … handle event.event and event.data … res.sendStatus(200); });
Reply 2xx as soon as you've stored the event — do the slow work afterwards. Anything else counts as a failure.
X-Paysana-Delivery (or the order reference) so replays are harmless.Join the businesses already using Paysana to collect payments, send invoices and grow.