Pay&Go
PayGo API · v1

PayGo Developers

Accept payments and collect bills from your own website or app, on the same rails that power the PayGo cabinet. Base URL: https://paygo.megapayai.com/api/v1

Quickstart

Mint a hosted payment link, redirect your customer to it, and get paid — no card handling, no PCI scope. Sandbox keys work exactly like live ones so you can build the whole integration before you have a live key.

1. Get a sandbox key

Sign in and create a workspace in the Console — a pg_test_… key is issued instantly, no approval needed.

2. Create a PayLink

POST /paylinks with an amount and reference. You get back a hosted checkout url.

3. Redirect your customer

Send the customer to that url. They pay on our hosted /l/<token> page — you never touch card data.

4. Confirm the payment

Trust only the webhook or a GET /paylinks/:id poll — never the redirect itself (see note below).

Create a PayLink

curl -X POST https://paygo.megapayai.com/api/v1/paylinks \
  -H "Authorization: Bearer pg_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-4471" \
  -d '{
    "amount": 2500,
    "reference": "Order #4471",
    "customer": { "name": "Nimal Perera", "phone": "0771234567" },
    "success_url": "https://your-site.com/thank-you",
    "cancel_url": "https://your-site.com/cart"
  }'
The success_url/cancel_url redirect carries NO trusted state (no signature, no amount) — it is a convenience for returning the customer to your site, nothing more. A malicious customer can hit success_url without ever paying. Always confirm payment via the webhook or by polling GET /paylinks/:id server-side.
Sandbox links use the same "preview payment" flow as the live checkout page — simulate a completed payment headlessly with POST /test/paylinks/:id/pay so your own test suite never needs a human to click through the page.

Sandbox

Every workspace starts in sandbox — self-serve, no KYC, no approval. Sandbox keys (pg_test_…) are fully isolated from real money and real reporting.

  • Sandbox PayLinks post to the same /l/<token> hosted page, but the "Pay" button simulates the result instead of charging a real card.
  • POST /test/paylinks/:id/pay drives a sandbox link straight to paid programmatically — useful for CI.
  • The bill-collection wallet starts with a simulated balance; POST /test/wallet/topup credits it without a real bank transfer.
  • Sandbox rows never appear in settlement, ledger or analytics — they exist only for your own workspace's testing.
  • Webhooks deliver identically in sandbox and live — that is the point of sandbox: exercise your real webhook handler safely.
  • Requesting live access does not disable sandbox — both modes work side-by-side, forever, off the same workspace.

Webhooks

Subscribe to a URL and PayGo POSTs a signed JSON event the moment something happens — paid, settled, cancelled, a bill payment, a wallet top-up. Configure the URL, secret and event list in the Console.

Header format

X-PayGo-Signature: t=<unix seconds>, v1=<hex hmac-sha256(secret, t + "." + rawBody)>X-PayGo-Signature: t=1752345600, v1=8f14e45fceea167a5a36dedd4bea2543...
  1. 1Parse the header as comma-separated key=value pairs: t (unix seconds) and v1 (hex signature).
  2. 2Reject if |now − t| exceeds a tolerance window (we recommend ≤ 300 seconds) — this stops replayed requests.
  3. 3Recompute HMAC-SHA256 of the exact string `t + "." + rawBody` using your webhook secret, and compare it to v1 using a constant-time comparison.
  4. 4rawBody MUST be the exact bytes received on the wire — re-serializing the parsed JSON can reorder keys or change whitespace and will break verification.

Verify the signature

// Verify an inbound X-PayGo-Signature header. Never trust an unsigned/expired webhook.
const crypto = require('crypto');

function verifyPayGoSignature(secret, header, rawBody, toleranceSec = 300) {
  if (typeof header !== 'string' || !header.trim()) return false;
  let t = null, v1 = null;
  for (const part of header.trim().split(',')) {
    const eq = part.indexOf('=');
    if (eq === -1) continue;
    const key = part.slice(0, eq).trim();
    const value = part.slice(eq + 1).trim();
    if (key === 't') t = value; else if (key === 'v1') v1 = value;
  }
  if (!t || !v1 || !/^\d+$/.test(t) || !/^[0-9a-f]+$/i.test(v1)) return false;

  const ts = parseInt(t, 10);
  if (Math.abs(Math.floor(Date.now() / 1000) - ts) > toleranceSec) return false; // replay/clock-skew guard

  const expected = crypto.createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex');
  if (expected.length !== v1.length) return false;
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1.toLowerCase()));
}

// Express example (use a raw-body parser for this route — re-serialized JSON breaks the signature):
app.post('/webhooks/paygo', express.raw({ type: 'application/json' }), (req, res) => {
  const ok = verifyPayGoSignature(process.env.PAYGO_WEBHOOK_SECRET, req.header('X-PayGo-Signature'), req.body);
  if (!ok) return res.sendStatus(400);
  const event = JSON.parse(req.body);
  // … handle event.type (e.g. "paylink.paid") using event.data
  res.sendStatus(200);
});

Events

  • paylink.paidA PayLink was paid in full.
  • paylink.partial_paidA partial-pay (instalment) link received one payment but is not yet fully collected.
  • paylink.settledA paid PayLink was swept into a settlement batch (payout).
  • paylink.cancelledAn unpaid PayLink was cancelled.
  • bill.payment.succeededA bill-collection payment completed.
  • bill.payment.failedA bill-collection payment attempt failed.
  • wallet.creditedThe bill-collection wallet received a top-up.

A non-2xx response (or a timeout) is retried on a backoff ladder: 1 minute → 5 minutes → 30 minutes → 2 hours → 6 hours → 24 hours, then the delivery is marked dead. There is no paylink.expired event — expiry is derived, never persisted; poll expires_at (or GET /paylinks/:id) to detect it.

Dead deliveries are visible in the Console's delivery log with a manual "Redeliver" option — nothing is silently dropped.

Errors

Every /api/v1 error uses exactly one envelope: { "error": { "code": "…", "message": "…" } }. code is a fixed, stable enum — safe to switch on in your code. message is for humans and may change wording; never parse it.

  • unauthorizedHTTP 401

    The Authorization header is missing/malformed, or the key is unknown/revoked.

  • forbiddenHTTP 403

    This partner is suspended, the request IP is not on this key's allowlist, or the partner is not enabled for this product/endpoint.

  • rate_limitedHTTP 429

    This key's per-minute request rate was exceeded. A Retry-After header (seconds) tells you how long to back off.

  • invalid_requestHTTP 400

    A field failed validation — a required field is missing, out of range, or an Idempotency-Key required for that endpoint was omitted.

  • not_foundHTTP 404

    No resource with that id/token belongs to this API partner in this mode (sandbox and live are strictly isolated).

  • conflictHTTP 409

    The resource is in a state that can't be changed this way (e.g. cancelling a link that already has a payment on it), or a request with the same Idempotency-Key is still in flight.

  • idempotency_replay_mismatchHTTP 409

    The same Idempotency-Key was reused with a DIFFERENT request body. Use a new key for a genuinely different request.

  • insufficient_fundsHTTP 402

    The bill-collection wallet balance can't cover this payment.

  • unavailableHTTP 503

    The API platform (or sandbox mode specifically) is currently disarmed, or an unexpected internal error occurred. Safe to retry later.

Rate limits

  • Every key has a per-minute token-bucket limit (a sensible default; contact us if you need a higher ceiling for a specific key).
  • Exceeding it returns 429 rate_limited with a Retry-After header in seconds — back off for at least that long before retrying.
  • Rate limits are per-key, not per-workspace — sandbox and live keys (and any additional keys we issue you) each have their own bucket.

API reference

Generated directly from our live OpenAPI 3.1 specification — every request/response shape below is exactly what the API sends and accepts today, never hand-maintained separately.

List PayLinks

Create a PayLink

Retrieve a PayLink by id or token

Cancel an unpaid PayLink

Ready to build?

Create a workspace and get a sandbox key in under a minute.