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"
}'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...- 1Parse the header as comma-separated key=value pairs: t (unix seconds) and v1 (hex signature).
- 2Reject if |now − t| exceeds a tolerance window (we recommend ≤ 300 seconds) — this stops replayed requests.
- 3Recompute HMAC-SHA256 of the exact string `t + "." + rawBody` using your webhook secret, and compare it to v1 using a constant-time comparison.
- 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 401The Authorization header is missing/malformed, or the key is unknown/revoked.
forbiddenHTTP 403This 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 429This key's per-minute request rate was exceeded. A Retry-After header (seconds) tells you how long to back off.
invalid_requestHTTP 400A field failed validation — a required field is missing, out of range, or an Idempotency-Key required for that endpoint was omitted.
not_foundHTTP 404No resource with that id/token belongs to this API partner in this mode (sandbox and live are strictly isolated).
conflictHTTP 409The 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 409The same Idempotency-Key was reused with a DIFFERENT request body. Use a new key for a genuinely different request.
insufficient_fundsHTTP 402The bill-collection wallet balance can't cover this payment.
unavailableHTTP 503The 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.