Features
Integrations Pricing Developers Guides
Developers

Build on Paysana

A REST API for your orders, customers, transactions and invoices — plus signed webhooks that push events to your server the moment they happen.

Quickstart

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.

  • Create an account and open API Access in your dashboard.
  • Mint a key — it starts with psk_ and is shown once.
  • Send it as a Bearer token on every request.
Your first request
# 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"
Prefer events over polling? Register a webhook endpoint instead and Paysana will push each order to you as it happens — see Webhooks.

Authentication

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 header
Authorization: Bearer psk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Managing keys

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.

Keys are secrets. Keep them server-side — never ship one in a mobile app, a browser bundle or a public repository. A revoked key stops working immediately.

Conventions

The API is REST over HTTPS, returns JSON, and behaves the same way everywhere.

ConventionHow it works
Base URLhttps://www.paysana.co/api/v1
MoneyAlways an integer in minor units — fields ending in _sen. 8900 means 89.00. Never a float, so nothing rounds badly in transit.
CurrencyEvery money-bearing object carries a currency field with your account's ISO code. It follows your account — don't assume one.
DatesISO 8601 with timezone, e.g. 2026-08-07T09:31:04+08:00. A null date means the thing hasn't happened yet.
EnvelopeSingle 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 & rate limits

Errors use standard HTTP status codes with a JSON body. Validation failures follow Laravel's shape, with a message per field.

StatusMeaning
200 / 201Success. 201 when something was created.
401Missing, malformed, revoked or unknown API key.
404No such record on your account — the same response another merchant's reference would give.
422Validation failed, or the action doesn't apply (e.g. shipping an order with nothing to ship).
429Rate limited — see below.
422 Unprocessable Entity
{
  "message": "The amount sen field is required.",
  "errors": {
    "amount_sen": ["The amount sen field is required."]
  }
}

Rate limit

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.

Orders

Every sale across all your channels — payment forms, your storefront and invoice payments.

GET/api/v1/ordersList orders, newest first

Filter with ?status= (paid, pending, failed, refunded) and paginate with ?page= / ?per_page=.

200 OK
{
  "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 }
}
GET/api/v1/orders/{reference}Fetch one order

Returns the same object shape wrapped in data, or 404 if the reference isn't on your account.

Field notes

FieldNotes
statuspending (awaiting payment), paid, failed, refunded (fully). A partial refund stays paid with refund_sen above zero.
sourceform for a payment-form checkout, store for a storefront basket.
itemA readable summary — a multi-line basket reads "First item + 2 more".
fulfilmentTo fulfil, Shipped, Delivered, or for anything that isn't shipped physically.
refund_senCumulative refunded amount. Net revenue is total_sen − refund_sen.

Customers

Every buyer, captured automatically at checkout and rolled up across their orders.

GET/api/v1/customersList customers A–Z
200 OK
{
  "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.

Transactions

The money ledger behind every payment — one row per settled payment, plus negative rows for partial refunds so sums always net correctly.

GET/api/v1/transactionsList ledger rows, newest first
200 OK
{
  "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.

Invoices

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.

POST/api/v1/invoicesCreate a pending invoice
Body fieldRules
customer_nameRequired. String, max 150.
customer_emailOptional. A valid email, max 160 — supply it and the buyer can be emailed the link.
amount_senRequired. Integer minor units, min 100 (1.00), max 100000000.
referenceOptional. Your own reference, max 150 — carried onto the invoice.
due_daysOptional. 1–365, default 14.
Request
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
  }'
201 Created
{
  "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.

Fulfilment

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.

PUT/api/v1/orders/{reference}/fulfilmentMark shipped or delivered
Body fieldRules
statusRequired. shipped or delivered.
courierOptional. Max 60 — shown to the buyer.
tracking_numberOptional. Max 80 — shown to the buyer and in their order lookup.
Request
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.

Webhooks

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.

Events

EventFires when
order.paidA payment settles (form, store or renewal)
order.refundedAn order is refunded (full or partial)
order.shippedA physical order is marked shipped

Payload

POST to your endpoint
{
  "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.

Headers

HeaderValue
X-Paysana-EventThe event name, e.g. order.paid.
X-Paysana-Signaturesha256=<hex hmac of the raw body>.
X-Paysana-DeliveryUnique delivery id — use it to make your handler idempotent.

Verifying signatures

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.

Hash the raw body bytes — never a re-encoded version. Decoding and re-encoding the JSON changes whitespace and key order, and the digest will never match.
PHP
$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);
Node.js (Express)
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);
  });

Delivery & retries

Reply 2xx as soon as you've stored the event — do the slow work afterwards. Anything else counts as a failure.

  • Paysana retries up to 5 attempts with backoff: 1 minute, 5 minutes, 30 minutes, then 2 hours.
  • After the final attempt the delivery is marked failed — every attempt, response code and payload is visible under Logs → Webhooks.
  • Requests time out after 5 seconds, so acknowledge quickly and process asynchronously.
  • Retries mean an event can arrive more than once. Key your handler on X-Paysana-Delivery (or the order reference) so replays are harmless.
A webhook failure never affects the underlying sale — the order is already settled and the buyer already has their receipt.
Ready to build? Get your API key Or use a ready-made integration

One platform. Your business.
Your way.

Join the businesses already using Paysana to collect payments, send invoices and grow.

Free plan forever · No card to sign up · Your own gateway
We use cookies to measure how this site is used and how well our advertising works. They are optional — the site works exactly the same either way. Read our privacy policy.