# Paylinky — complete documentation Base URL: https://paylinky.vercel.app/api/v1 --- # Introduction Source: https://paylinky.vercel.app/docs/introduction What Paylinky is and the two ways to accept payments. # Welcome to Paylinky Paylinky lets African businesses accept **Mobile Money** and **crypto** payments with a single integration. Paylinky routes payments through licensed payment partners, adds KYC-verified merchant accounts, a unified wallet, signed webhooks and a real sandbox. ## Two ways to integrate | | Hosted Page API | Direct API (SDK) | | - | - | - | | What you build | Almost nothing — redirect the customer to a Paylinky checkout link | Your own checkout UI, forms and flows | | Endpoint | `POST /api/v1/payment-links` | `POST /api/v1/collect`, `POST /api/v1/crypto/collect` | | Customer experience | Paylinky-hosted page (country, operator, phone, OTP, crypto QR) | Fully branded by you | | Best for | Invoices, WhatsApp/SMS links, quick launches | Apps, marketplaces, custom checkouts | You can use both with the same API key. ## Base URL ```text https://paylinky.vercel.app/api/v1 ``` All requests and responses are JSON. All endpoints require `Authorization: Bearer ` (see [Authentication](/docs/authentication)). ## Environments | Environment | Key prefix | Money moves? | Activation | | - | - | - | - | | Sandbox | `plk_test_…` | No — simulated | Instant | | Live | `plk_live_…` | Yes | Requires approved KYC **and** manual activation by the Paylinky team | ## Conventions - **Amounts** are numbers in the currency's main unit (e.g. `1000` XAF). Crypto amounts are USDT. - **References.** Every payment has a Paylinky `reference` (e.g. `PLK-7XK2M9QPLA`) and your optional `merchant_reference`. - **Statuses:** `pending`, `success`, `failed`, `cancelled`, `expired`, `refunded`. - **Errors** use `{ "error": "code", "message": "Human readable" }` — see [Errors](/docs/errors). - **Idempotency:** re-sending a request with the same `reference` returns the existing payment (`idempotent_replay: true`) instead of creating a duplicate. :::tip Never trust the browser to confirm a payment. Deliver goods only after a `payment.completed` webhook or a server-side `GET /transaction/{id}` that returns `success`. ::: --- # Quickstart Source: https://paylinky.vercel.app/docs/quickstart Make your first sandbox payment in five minutes. # Quickstart ## 1. Create an account and a sandbox key Register, open **Developers → API Keys** and request a **sandbox** key. Sandbox keys are issued instantly. The full key is shown **once** — store it in an environment variable. ```bash export PAYLINKY_KEY="plk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ``` ## 2. List the active countries and operators Always load the catalog instead of hard-coding it — operators can be enabled or disabled at any time. ```bash curl https://paylinky.vercel.app/api/v1/countries \ -H "Authorization: Bearer $PAYLINKY_KEY" ``` ```json [ { "code": "CM", "name": "Cameroon", "currency": "XAF", "operators": ["MTN Mobile Money", "Orange Money"], "unavailable_operators": [] } ] ``` ## 3. Create a Mobile Money payment Use the sandbox test number `+237000000001` — it always succeeds. :::tabs ```bash cURL curl -X POST https://paylinky.vercel.app/api/v1/collect \ -H "Authorization: Bearer $PAYLINKY_KEY" \ -H "Content-Type: application/json" \ -d '{ "amount": 1000, "currency": "XAF", "country_code": "CM", "operator": "MTN Mobile Money", "phone": "+237000000001", "reference": "order_12345" }' ``` ```javascript Node.js const res = await fetch("https://paylinky.vercel.app/api/v1/collect", { method: "POST", headers: { Authorization: `Bearer ${process.env.PAYLINKY_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ amount: 1000, currency: "XAF", country_code: "CM", operator: "MTN Mobile Money", phone: "+237000000001", reference: "order_12345", }), }); const payment = await res.json(); ``` ```python Python import os, requests r = requests.post( "https://paylinky.vercel.app/api/v1/collect", headers={"Authorization": f"Bearer {os.environ['PAYLINKY_KEY']}"}, json={ "amount": 1000, "currency": "XAF", "country_code": "CM", "operator": "MTN Mobile Money", "phone": "+237000000001", "reference": "order_12345", }, ) payment = r.json() ``` ```php PHP true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("PAYLINKY_KEY"), "Content-Type: application/json"], CURLOPT_POSTFIELDS => json_encode([ "amount" => 1000, "currency" => "XAF", "country_code" => "CM", "operator" => "MTN Mobile Money", "phone" => "+237000000001", "reference" => "order_12345", ]), ]); $payment = json_decode(curl_exec($ch), true); ``` ::: Response (`200`, sandbox): ```json { "transaction_id": "0b9c7f0e-2d0b-4d76-9f5a-6b8a1c1d2e3f", "reference": "PLK-SBX-7XK2M9QPLA", "merchant_reference": "order_12345", "status": "success", "payment_method": "mobile_money", "amount": 1000, "fee_amount": 50, "credited_amount": 950, "currency": "XAF", "sandbox": true, "simulation": true, "country_code": "CM", "operator": "MTN Mobile Money", "phone": "+237000000001", "created_at": "2026-01-01T10:00:00.000Z", "expires_at": null, "message": "Payment confirmed immediately (simulated)." } ``` ## 4. Check the status ```bash curl https://paylinky.vercel.app/api/v1/transaction/0b9c7f0e-2d0b-4d76-9f5a-6b8a1c1d2e3f \ -H "Authorization: Bearer $PAYLINKY_KEY" ``` ## 5. Go live 1. Complete **KYC** in the app (front and back of your ID + application). 2. Request a **live key** — the Paylinky team reviews and **activates** it. 3. Swap `plk_test_…` for `plk_live_…` and use real numbers. See the [go-live checklist](/docs/security#go-live-checklist). --- # Access, KYC & key activation Source: https://paylinky.vercel.app/docs/access-kyc How merchants get verified and receive live API access. # Access, KYC & key activation Paylinky is a regulated payment gateway. Live access is granted in three controlled steps. ## 1. KYC verification In the app go to **Profile → Verification** and complete the wizard: 1. Choose a document: national ID, passport, driver's license or residence permit. 2. Upload the **front (recto)** of the document. 3. Upload the **back (verso)** (not required for passports). 4. Confirm your legal details. 5. Write your **application**: who you are, what you sell and how you plan to use Paylinky. Our compliance team reviews the submission. You are notified in-app when it is **approved** or **rejected** (with a reason, so you can resubmit). ## 2. Key request After approval, request a live key from **Developers → API Keys**. Keys start in the `pending` state. A pending key returns: ```json { "error": "key_not_activated", "message": "This API key is waiting for activation by the Paylinky team." } ``` ## 3. Activation by an administrator An administrator reviews and sets the key to `active`. You receive a notification and the key works immediately. Administrators can also `revoke` a key at any time (`401 unauthorized`). | Key status | Meaning | API behaviour | | - | - | - | | `pending` | Waiting for activation | `403 key_not_activated` | | `active` | Usable | Requests succeed | | `rejected` | Request declined | `401 unauthorized` | | `revoked` | Disabled | `401 unauthorized` | :::note Sandbox keys are issued instantly and never touch real funds. Live keys additionally require approved KYC (`403 kyc_required` otherwise). ::: --- # Authentication Source: https://paylinky.vercel.app/docs/authentication Authenticate server-side requests with Bearer API keys. # Authentication Authenticate every request with your secret API key in the `Authorization` header: ```http Authorization: Bearer plk_live_YOUR_SECRET_KEY ``` :::tabs ```bash cURL curl https://paylinky.vercel.app/api/v1/balance -H "Authorization: Bearer $PAYLINKY_KEY" ``` ```javascript Node.js const r = await fetch("https://paylinky.vercel.app/api/v1/balance", { headers: { Authorization: `Bearer ${process.env.PAYLINKY_KEY}` }, }); ``` ```python Python requests.get("https://paylinky.vercel.app/api/v1/balance", headers={"Authorization": f"Bearer {os.environ['PAYLINKY_KEY']}"}) ``` ```php PHP $ch = curl_init("https://paylinky.vercel.app/api/v1/balance"); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("PAYLINKY_KEY")]]); ``` ::: ## Key format | Key | Where | Description | | - | - | - | | `plk_test_<48 hex>` | Server only | Sandbox key — simulated payments | | `plk_live_<48 hex>` | Server only | Live key — real money, requires activation | | `whsec_…` | Server only | Webhook signing secret (not an API key) | Keys are stored hashed. The full key is shown **only once** at creation or when you **roll** it. If you lose it, roll the key from **Developers → API Keys**. ## Authentication errors | HTTP | `error` | Cause | | - | - | - | | 401 | `unauthorized` | Missing, malformed, unknown, rejected or revoked key | | 403 | `key_not_activated` | Key is waiting for administrator activation | | 403 | `kyc_required` | Live key but KYC is not approved | | 403 | `account_suspended` | Account suspended | | 429 | `rate_limited` | Default 120 requests per minute per key | | 503 | `maintenance` | Live API temporarily under maintenance | :::warning Never put a Paylinky key in a browser, mobile app, or Git repository. Call the API from your backend and expose only the resulting `payment_link` or payment status to clients. ::: --- # Hosted Page API Source: https://paylinky.vercel.app/docs/hosted-page Create payment links and let customers pay on a Paylinky-hosted checkout. # Hosted Page API Create a link on your server, redirect the customer to it, and let Paylinky handle country selection, operator, phone number, OTP steps and crypto addresses. ## Flow 1. Your backend calls `POST /api/v1/payment-links`. 2. Paylinky returns a `payment_link` (valid 30 minutes by default). 3. Redirect your customer to `payment_link`. 4. The customer pays on the hosted page (Mobile Money or crypto). 5. Paylinky sends a signed `payment.completed` / `payment.failed` webhook to your server. 6. The customer's browser is redirected to your `success_url` (if set). ## Create a link ```http POST https://paylinky.vercel.app/api/v1/payment-links ``` | Field | Required | Description | | - | - | - | | `title` | Yes* | Short title shown to the customer (*or `description`) | | `description` | No | Longer text | | `amount` | If fixed | Amount in `currency` | | `currency` | Yes | `XAF`, `XOF`, `CDF`, `NGN`, `GHS`, `KES`… — must match a country in `/countries` | | `is_fixed_amount` | No | Default `true`. If `false` the customer enters the amount | | `allowed_countries` | No | Array of country codes, e.g. `["CM"]` | | `expires_in_minutes` | No | Default `30`; `0` or `null` = never; max `525600` | | `reference` | No | Your order reference | | `success_url` / `cancel_url` | No | Redirect targets after a final result | | `Idempotency-Key` header | No | Same key returns the same link (`reused: true`) | :::tabs ```bash cURL curl -X POST https://paylinky.vercel.app/api/v1/payment-links \ -H "Authorization: Bearer $PAYLINKY_KEY" \ -H "Idempotency-Key: order-12345" \ -H "Content-Type: application/json" \ -d '{ "title": "Order #12345", "amount": 10000, "currency": "XAF", "is_fixed_amount": true, "allowed_countries": ["CM"], "success_url": "https://shop.example/thanks" }' ``` ```javascript Node.js const res = await fetch("https://paylinky.vercel.app/api/v1/payment-links", { method: "POST", headers: { Authorization: `Bearer ${process.env.PAYLINKY_KEY}`, "Content-Type": "application/json", "Idempotency-Key": "order-12345", }, body: JSON.stringify({ title: "Order #12345", amount: 10000, currency: "XAF", allowed_countries: ["CM"] }), }); const { payment_link } = await res.json(); // redirect the customer: res.redirect(payment_link) ``` ```python Python r = requests.post("https://paylinky.vercel.app/api/v1/payment-links", headers={"Authorization": f"Bearer {os.environ['PAYLINKY_KEY']}", "Idempotency-Key": "order-12345"}, json={"title": "Order #12345", "amount": 10000, "currency": "XAF", "allowed_countries": ["CM"]}) payment_link = r.json()["payment_link"] ``` ```php PHP $ch = curl_init("https://paylinky.vercel.app/api/v1/payment-links"); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("PAYLINKY_KEY"), "Content-Type: application/json", "Idempotency-Key: order-12345"], CURLOPT_POSTFIELDS => json_encode(["title" => "Order #12345", "amount" => 10000, "currency" => "XAF"]), ]); $link = json_decode(curl_exec($ch), true)["payment_link"]; ``` ::: Response `201`: ```json { "success": true, "payment_id": "5d2f3c0a-8f0e-4f9b-9d7e-0c1a2b3c4d5e", "slug": "pl-k3m9x2q7ab", "payment_link": "https://paylinky.vercel.app/pay/pl-k3m9x2q7ab", "title": "Order #12345", "description": null, "is_fixed_amount": true, "amount": 10000, "currency": "XAF", "allowed_countries": ["CM"], "status": "active", "payments_count": 0, "reference": null, "sandbox": false, "expires_at": "2026-01-01T10:30:00.000Z", "created_at": "2026-01-01T10:00:00.000Z", "reused": false } ``` ## Retrieve a link ```http GET https://paylinky.vercel.app/api/v1/payment-links/{payment_id_or_slug} ``` Returns the same object plus `paid_at` (time of the confirmed payment, or `null`). ### Link statuses | Status | Meaning | | - | - | | `active` | Can be paid | | `paid` | A fixed-amount link that has been paid (single use) | | `expired` | Past `expires_at` — the hosted page shows "link expired" | | `disabled` | Disabled by the merchant or an administrator | Variable-amount links (`is_fixed_amount: false`) stay `active` after payments and increment `payments_count`. ## Return URLs After a final result, the hosted page redirects to `success_url` with `?reference=PLK-…&status=success`. **Never fulfil an order from this redirect alone** — use the webhook or `GET /transaction/{id}`. :::note Payment links created with a sandbox key (`plk_test_…`) open a sandbox checkout banner and never move real money. ::: --- # Direct API overview Source: https://paylinky.vercel.app/docs/direct-api Build your own checkout with the Direct API. # Direct API The Direct API keeps your UI, forms and business logic while Paylinky and its payment partners handle the payment rails. ## Endpoints | Method | Endpoint | Purpose | | - | - | - | | `GET` | `/api/v1/countries` | Active countries, currencies and Mobile Money operators | | `GET` | `/api/v1/crypto/assets` | Active crypto assets and networks | | `GET` | `/api/v1/fees` | Platform fee configuration | | `POST` | `/api/v1/collect` | Create a Mobile Money collection | | `POST` | `/api/v1/crypto/collect` | Create a crypto payment (address + network) | | `GET` | `/api/v1/transaction/{id}` | Status of one transaction | | `GET` | `/api/v1/transactions` | List transactions (`?limit=20&status=success`) | | `POST` | `/api/v1/payment-links` | Hosted page link (see [Hosted Page API](/docs/hosted-page)) | | `GET` | `/api/v1/payment-links/{id}` | Link status | | `GET` | `/api/v1/balance` | Wallet balances (live) | | `POST` | `/api/v1/sandbox/simulate` | Sandbox only: finish a pending test payment | ## Integration principles 1. Load `/countries` (and `/crypto/assets`) at startup or periodically; never hard-code operators. 2. Generate a **unique `reference`** per order on your server. 3. Store the returned `transaction_id`. 4. Wait for the **webhook**, or poll `GET /transaction/{id}`. 5. Never credit an order from a browser callback. 6. Handle all flows: `pending` (USSD push), `wave_url` (redirect), `otp_required` (OTP). ## Admin-controlled availability Administrators can switch off whole payment methods, individual countries, operators or crypto assets at any time. A disabled item disappears from `/countries` and `/crypto/assets`, and `/collect` returns `country_not_supported`, `operator_not_supported`, `asset_not_supported` or `method_disabled`. ## Fees Every transaction returns the exact `fee_amount` and `credited_amount`: ```text credited_amount = amount − fee_amount fee_amount = provider fee + Paylinky platform fee ``` Display the values returned by the API rather than calculating them yourself. --- # Mobile Money Source: https://paylinky.vercel.app/docs/mobile-money Create and track Mobile Money collections. # Mobile Money ```http POST https://paylinky.vercel.app/api/v1/collect ``` ## Request | Field | Required | Description | | - | - | - | | `amount` | Yes | Positive number, within the platform min/max | | `currency` | Yes | The currency of the country (`XAF`, `XOF`, `CDF`…) | | `country_code` | Yes | ISO code returned by `/countries`, e.g. `CM` | | `operator` | Yes | Exact operator name from `/countries` (case-insensitive) | | `phone` | Yes | International wallet number, e.g. `+237670000000`. Spaces, dashes and brackets are accepted | | `reference` | No | Your order reference (≤ 191 chars). Returned as `merchant_reference`; reusing it returns the existing payment | | `notify_url` | No | Public http(s) URL for this payment's webhook (overrides your default endpoint) | | `otp` | No | OTP when confirming an `otp_required` flow (see [Payment flows](/docs/payment-flows)) | :::tabs ```bash cURL curl -X POST https://paylinky.vercel.app/api/v1/collect \ -H "Authorization: Bearer $PAYLINKY_KEY" \ -H "Content-Type: application/json" \ -d '{ "amount": 5000, "currency": "XAF", "country_code": "CM", "operator": "MTN Mobile Money", "phone": "+2376XXXXXXXX", "reference": "order_cm_12345", "notify_url": "https://shop.example/webhooks/paylinky" }' ``` ```javascript Node.js const payment = await plk.collect({ amount: 5000, currency: "XAF", country_code: "CM", operator: "MTN Mobile Money", phone: "+2376XXXXXXXX", reference: "order_cm_12345", }); ``` ```python Python payment = plk.collect(amount=5000, currency="XAF", country_code="CM", operator="MTN Mobile Money", phone="+2376XXXXXXXX", reference="order_cm_12345") ``` ```php PHP $payment = $plk->collect(["amount" => 5000, "currency" => "XAF", "country_code" => "CM", "operator" => "MTN Mobile Money", "phone" => "+2376XXXXXXXX", "reference" => "order_cm_12345"]); ``` ::: ## Response — `202 Accepted` (USSD push) ```json { "transaction_id": "0b9c7f0e-2d0b-4d76-9f5a-6b8a1c1d2e3f", "reference": "PLK-7XK2M9QPLA", "merchant_reference": "order_cm_12345", "status": "pending", "payment_method": "mobile_money", "amount": 5000, "fee_amount": 300, "credited_amount": 4700, "currency": "XAF", "sandbox": false, "country_code": "CM", "operator": "MTN Mobile Money", "phone": "+2376XXXXXXXX", "created_at": "2026-01-01T10:00:00.000Z", "expires_at": null } ``` The customer receives a prompt on their phone and approves with their Mobile Money PIN. `status` stays `pending` until the provider confirms — **never deliver on the `202`**. | Field | Description | | - | - | | `transaction_id` | Paylinky UUID — use with `GET /transaction/{id}` | | `reference` | Paylinky reference | | `merchant_reference` | Your `reference`, or `null` | | `flow` / `wave_url` | Present for redirect flows such as Wave | | `fee_amount` / `credited_amount` | Fee and the amount credited to your balance on success | | `sandbox`, `simulation` | `true` for sandbox keys | ## Idempotent replays Sending the same `reference` with identical `amount`, `currency` and `country_code` returns the existing payment with `idempotent_replay: true`. A different payload with the same `reference` returns `409 reference_conflict`. :::warning After a network timeout (`504 upstream_timeout`) do **not** create a second payment. Check the status of the original `reference`/`transaction_id` first. ::: ## Check the status ```http GET https://paylinky.vercel.app/api/v1/transaction/{transaction_id} ``` You can pass the `transaction_id`, the Paylinky `reference` or your `merchant_reference`. Paylinky re-checks pending live payments with the provider on each call. A `200` means the transaction was found — always read `status`. ```bash curl https://paylinky.vercel.app/api/v1/transaction/order_cm_12345 -H "Authorization: Bearer $PAYLINKY_KEY" ``` --- # Payment flows Source: https://paylinky.vercel.app/docs/payment-flows USSD push, Wave redirect, OTP by SMS and OTP by USSD. # Mobile Money payment flows The flow depends on the country, operator and provider. Do not hard-code it from the brand name: load `/countries`, call `/collect`, and react to the **response**. | Flow | Initial response | Customer action | | - | - | - | | USSD push | `202`, `status: "pending"` | Approve the prompt on the phone | | Redirect (Wave) | `202`, `flow: "wave"`, `wave_url` | Open `wave_url` and approve | | OTP by SMS | `400`, `error: "otp_required"`, `reference` | Read the SMS code, re-send with `otp` | | OTP by USSD | `400`, `error: "otp_required"`, `reference`, `ussd_code` | Dial `ussd_code`, enter the code, re-send with `otp` | In every case the payment remains `pending` until confirmed. ## 1. USSD push Call `/collect` → `202 pending`. Show a "waiting for approval" screen. Receive the webhook (or poll). ## 2. Wave and redirects ```json { "status": "pending", "flow": "wave", "wave_url": "https://pay.wave.com/c/…", "transaction_id": "…", "reference": "PLK-…" } ``` Open `wave_url` exactly as returned (do not build it yourself), then wait for the webhook. ## 3. OTP First request — same body as a normal collect: ```json { "error": "otp_required", "message": "OTP required. Dial the code shown to get your OTP, then retry with 'otp' and 'reference'.", "reference": "PLK-7XK2M9QPLA", "ussd_code": "#144*82#" } ``` Display `ussd_code` (when present) to the customer. After they enter the OTP, **re-send the same request** with: - `reference` = the `reference` from the `otp_required` response (replaces your order reference), - `otp` = the code the customer received. :::tabs ```bash cURL curl -X POST https://paylinky.vercel.app/api/v1/collect \ -H "Authorization: Bearer $PAYLINKY_KEY" \ -H "Content-Type: application/json" \ -d '{ "amount": 5000, "currency": "XOF", "country_code": "CI", "operator": "Orange Money", "phone": "+2250XXXXXXXX", "reference": "PLK-7XK2M9QPLA", "otp": "123456" }' ``` ```javascript Node.js try { await plk.collect({ amount: 5000, currency: "XOF", country_code: "CI", operator: "Orange Money", phone, reference: "order_1" }); } catch (e) { if (e.code === "otp_required") { // ask the customer for the OTP, then: await plk.collect({ amount: 5000, currency: "XOF", country_code: "CI", operator: "Orange Money", phone, reference: e.body.reference, otp }); } } ``` ::: If the OTP session expired you receive `400 otp_expired`/`invalid_reference` — start a new collection with a new `reference`. ## Handling responses | Code | Meaning | What to do | | - | - | - | | `202` | Accepted, pending | Wait for webhook / poll | | `200` | Final result immediately (sandbox) or idempotent replay | Read `status` | | `400 otp_required` | Needs OTP | Show OTP form, retry | | `400 invalid_otp` | Wrong OTP | Ask again | | `409 reference_conflict` | Reference reused with different data | Use a unique reference | --- # Crypto payments Source: https://paylinky.vercel.app/docs/crypto Accept USDT, BTC and more with a dedicated address and network. # Crypto payments ## List active assets ```bash curl https://paylinky.vercel.app/api/v1/crypto/assets -H "Authorization: Bearer $PAYLINKY_KEY" ``` ```json [ { "asset_code": "USDT.TRC20", "name": "Tether (TRON)", "network": "TRC20", "symbol": "USDT" }, { "asset_code": "BTC", "name": "Bitcoin", "network": "BTC", "symbol": "BTC" } ] ``` Use an `asset_code` from this list — never assume a network is active. Administrators can disable any asset (including Bitcoin) at any time. ## Create a crypto payment ```http POST https://paylinky.vercel.app/api/v1/crypto/collect ``` | Field | Required | Description | | - | - | - | | `amount` | Yes | Amount in USDT (string or number) | | `currency` | No | `USDT` (default) | | `asset_code` | Yes | From `/crypto/assets` | | `customer.email` | Yes | Payer email | | `customer.firstName` | Yes | Payer first name | | `customer.lastName` | Yes | Payer last name | | `reference` | No | Your order reference (idempotent) | | `country` | No | Payer country code | | `notify_url` | No | Webhook URL for this payment | :::warning `customer.email`, `customer.firstName` and `customer.lastName` are **mandatory**. Never invent them — ask the payer. ::: :::tabs ```bash cURL curl -X POST https://paylinky.vercel.app/api/v1/crypto/collect \ -H "Authorization: Bearer $PAYLINKY_KEY" \ -H "Content-Type: application/json" \ -d '{ "amount": "25", "currency": "USDT", "asset_code": "USDT.TRC20", "reference": "crypto_order_12345", "customer": { "email": "payer@example.com", "firstName": "Ada", "lastName": "Lovelace" } }' ``` ```javascript Node.js const payment = await plk.cryptoCollect({ amount: "25", currency: "USDT", asset_code: "USDT.TRC20", reference: "crypto_order_12345", customer: { email: "payer@example.com", firstName: "Ada", lastName: "Lovelace" }, }); ``` ```python Python payment = plk.crypto_collect(amount="25", currency="USDT", asset_code="USDT.TRC20", reference="crypto_order_12345", customer={"email": "payer@example.com", "firstName": "Ada", "lastName": "Lovelace"}) ``` ```php PHP $payment = $plk->cryptoCollect(["amount" => "25", "currency" => "USDT", "asset_code" => "USDT.TRC20", "reference" => "crypto_order_12345", "customer" => ["email" => "payer@example.com", "firstName" => "Ada", "lastName" => "Lovelace"]]); ``` ::: ### Response — `202 Accepted` ```json { "transaction_id": "7a1f…", "reference": "PLK-3QW8N5ZK2D", "merchant_reference": "crypto_order_12345", "status": "pending", "payment_method": "crypto", "amount": 25, "fee_amount": 0.5, "credited_amount": 24.5, "currency": "USDT", "asset_code": "USDT.TRC20", "network": "TRC20", "address": "T…", "memo": null, "amount_usdt": 25, "credited_amount_usdt": 24.5, "fee_amount_usdt": 0.5, "customer": { "email": "payer@example.com", "firstName": "Ada", "lastName": "Lovelace" }, "sandbox": false, "expires_at": "2026-01-01T10:15:00.000Z" } ``` ## Address, memo and QR code Show the `address` (and `memo` when present) and render a QR code in your app. The payer must send on **exactly the returned `network`** — a wrong network can make funds unrecoverable. ## Expiry and confirmation Addresses are temporary: a crypto payment still `pending` after **15 minutes** (`expires_at`) becomes `expired`. Never reuse an expired address. The payment is complete only after the `payment.completed` webhook (or `status: "success"`). --- # Webhooks Source: https://paylinky.vercel.app/docs/webhooks Receive signed events when payments change status. # Webhooks Set your default endpoint in **Developers → Webhooks**, or send `notify_url` with a payment. The URL must be public `http(s)` — `localhost` and private networks are blocked. ## Events | Event | When | | - | - | | `payment.completed` | Payment confirmed (`status: "success"`) | | `payment.failed` | Payment `failed`, `cancelled` or `expired` | ## Request ```http POST https://yourapp.com/webhooks/paylinky Content-Type: application/json X-Paylinky-Event-Id: 6f1c0f2e-… X-Paylinky-Timestamp: 1735643554 X-Paylinky-Signature: sha256=3b6a… ``` ```json { "event": "payment.completed", "transaction_id": "0b9c7f0e-2d0b-4d76-9f5a-6b8a1c1d2e3f", "reference": "PLK-7XK2M9QPLA", "merchant_reference": "order_cm_12345", "status": "success", "payment_method": "mobile_money", "amount": 5000, "fee_amount": 300, "credited_amount": 4700, "currency": "XAF", "country_code": "CM", "operator": "MTN Mobile Money", "phone": "+2376XXXXXXXX", "sandbox": false, "created_at": "2026-01-01T10:00:00.000Z", "expires_at": null, "timestamp": "2026-01-01T10:00:41.000Z" } ``` ## Verify the signature The signature is a hex HMAC-SHA256 of `timestamp + "." + raw_request_body` using your `whsec_…` secret. Verify against the **raw body** *before* JSON parsing and reject timestamps older than 5 minutes. :::tabs ```javascript Node.js (Express) const { Paylinky } = require("./paylinky.js"); app.post("/webhooks/paylinky", express.raw({ type: "application/json" }), async (req, res) => { const raw = req.body.toString("utf8"); if (!Paylinky.verifyWebhook(raw, req.headers, process.env.PAYLINKY_WEBHOOK_SECRET)) return res.sendStatus(401); res.sendStatus(200); // acknowledge quickly const event = JSON.parse(raw); if (event.event === "payment.completed") await fulfillOrderOnce(event.merchant_reference, event.transaction_id); }); ``` ```python Python (Flask) from paylinky import Paylinky @app.post("/webhooks/paylinky") def hook(): raw = request.get_data() if not Paylinky.verify_webhook(raw, request.headers, os.environ["PAYLINKY_WEBHOOK_SECRET"]): return "", 401 event = json.loads(raw) if event["event"] == "payment.completed": fulfill_once(event["merchant_reference"], event["transaction_id"]) return "", 200 ``` ```php PHP $_SERVER["HTTP_X_PAYLINKY_TIMESTAMP"] ?? "", "X-Paylinky-Signature" => $_SERVER["HTTP_X_PAYLINKY_SIGNATURE"] ?? ""]; if (!Paylinky::verifyWebhook($raw, $headers, getenv("PAYLINKY_WEBHOOK_SECRET"))) { http_response_code(401); exit; } http_response_code(200); $event = json_decode($raw, true); ``` ::: ## Best practices 1. Respond `2xx` quickly (within 8 seconds). 2. Verify the signature with the raw body. 3. Make processing **idempotent** using `transaction_id` / `merchant_reference`. 4. Re-check the status with `GET /transaction/{id}` before fulfilling high-value orders. 5. Never trust the browser redirect. ## Delivery & retries Each event is delivered once immediately and logged (status, HTTP code, response excerpt) in **Developers → Webhooks**, where you can see failures. If your server was down, reconcile with `GET /transaction/{id}` or `GET /transactions?status=success`; the Paylinky team can also retry failed deliveries from the admin panel. Rotate the secret any time from the dashboard. In the sandbox, use `POST /sandbox/simulate` to trigger `payment.completed` / `payment.failed` to your endpoint. --- # API reference Source: https://paylinky.vercel.app/docs/reference Every endpoint with request and response examples. # API reference Base URL: `https://paylinky.vercel.app/api/v1` · Auth: `Authorization: Bearer ` · Machine-readable spec: [`/openapi.json`](https://paylinky.vercel.app/openapi.json) ## GET /countries Active countries, currencies and operators (after administrator filtering). ```json [ { "code": "CI", "name": "Côte d'Ivoire", "currency": "XOF", "operators": ["Orange Money", "MTN Mobile Money", "Moov Money", "Wave"], "unavailable_operators": [] } ] ``` ## GET /crypto/assets ```json [ { "asset_code": "USDT.TRC20", "name": "Tether (TRON)", "network": "TRC20", "symbol": "USDT" } ] ``` ## GET /fees ```json { "platform_fee_percent": 1, "withdrawal_fee_percent": 1, "minimum_amount": 100, "maximum_amount": 5000000, "sandbox_fee_percent": 5, "provider_fees": null, "note": "Final fees are always returned on each transaction (fee_amount, credited_amount)." } ``` ## POST /collect See [Mobile Money](/docs/mobile-money) and [Payment flows](/docs/payment-flows). ## POST /crypto/collect See [Crypto payments](/docs/crypto). ## GET /transaction/{id} `id` can be `transaction_id`, `reference` or `merchant_reference`. ```json { "transaction_id": "0b9c…", "reference": "PLK-7XK2M9QPLA", "merchant_reference": "order_cm_12345", "status": "success", "payment_method": "mobile_money", "amount": 5000, "fee_amount": 300, "credited_amount": 4700, "currency": "XAF", "country_code": "CM", "operator": "MTN Mobile Money", "phone": "+2376XXXXXXXX", "sandbox": false, "created_at": "2026-01-01T10:00:00.000Z", "expires_at": null } ``` | `status` | Meaning | | - | - | | `pending` | Waiting for the customer / provider | | `success` | Confirmed, wallet credited (live) | | `failed` | Declined or failed | | `cancelled` | Cancelled by the customer | | `expired` | Crypto address expired | | `refunded` | Refunded by an administrator | ## GET /transactions Query: `limit` (max 100, default 20), `status`. Returns `{ "data": [ … ], "count": 20 }` for the environment of your key. ## POST /payment-links · GET /payment-links/{id} See [Hosted Page API](/docs/hosted-page). ## GET /balance ```json { "balances": [ { "currency": "XAF", "available": 124500 }, { "currency": "USDT", "available": 24.5 } ], "sandbox": false } ``` ## POST /sandbox/simulate Sandbox keys only. Finishes a pending sandbox payment and dispatches the webhook. ```json { "transaction_id": "0b9c…", "status": "success" } ``` `status` ∈ `success`, `failed`, `cancelled`. --- # Errors & status codes Source: https://paylinky.vercel.app/docs/errors Error format and the full list of error codes. # Errors Errors always use this shape: ```json { "error": "invalid_amount", "message": "amount must be a positive number." } ``` Some errors include extra fields (for example `reference` and `ussd_code` on `otp_required`, or `transaction_id` on provider errors). ## HTTP status codes | Status | Meaning | Action | | - | - | - | | `200` | OK / final result / idempotent replay | Read `status` | | `201` | Resource created (payment link) | — | | `202` | Accepted, pending | Wait for webhook | | `400` | Invalid request | Fix the request | | `401` | Missing/invalid/revoked key | Check your key | | `403` | Not allowed (activation, KYC, suspended) | See `error` | | `404` | Not found | Check the id | | `409` | Reference conflict | Use a unique reference | | `429` | Rate limit | Back off and retry | | `5xx` | Provider or server problem | Check the status before retrying | ## Error codes | `error` | HTTP | Description | | - | - | - | | `unauthorized` | 401 | Bad key | | `key_not_activated` | 403 | Key awaiting admin activation | | `kyc_required` | 403 | Live access needs approved KYC | | `account_suspended` | 403 | Account suspended | | `sandbox_only` | 403 | Endpoint requires a sandbox key | | `method_disabled` | 403 | Method disabled by an administrator | | `invalid_json` | 400 | Body is not valid JSON | | `invalid_amount` / `amount_too_low` / `amount_too_high` | 400 | Amount rules | | `invalid_currency` | 400 | Currency doesn't match the country | | `invalid_country` / `country_not_supported` | 400 | Unknown or disabled country | | `invalid_operator` / `operator_not_supported` | 400 | Unknown or disabled operator | | `invalid_phone` | 400 | Phone number invalid | | `invalid_customer` | 400 | Crypto customer fields missing | | `asset_not_supported` | 400 | Crypto asset inactive | | `invalid_title` / `invalid_url` | 400 | Payment link validation | | `otp_required` | 400 | OTP flow — see [Payment flows](/docs/payment-flows) | | `invalid_otp` / `invalid_reference` | 400 | Wrong OTP or unknown session reference | | `reference_conflict` | 409 | Reference reused with different data | | `not_found` | 404 | Unknown resource or endpoint | | `rate_limited` | 429 | Too many requests | | `maintenance` | 503 | Live API in maintenance | | `provider_unavailable` / `provider_error` | 502 | Payment provider issue | | `upstream_timeout` | 504 | Provider timeout — verify before retrying | | `internal_error` | 500 | Unexpected error | ## Retrying safely Do not blindly retry payment creation after a timeout. Save your `reference`, then call `GET /transaction/{reference}`. Retrying `POST /collect` with the **same** `reference` is safe: you get the original payment back. --- # Sandbox & testing Source: https://paylinky.vercel.app/docs/sandbox Simulate every payment outcome without moving money. # Sandbox Use a `plk_test_…` key (or **Developers → Sandbox** in the app, which runs real requests with your account). Sandbox payments: - never call the payment provider and never move funds, - return `sandbox: true` and `simulation: true`, - are listed in your dashboard under **Transactions → Sandbox**, - fire real webhooks to your endpoint when you simulate a result. ## Test phone numbers The scenario is chosen by the **last digit** of a number ending in `00000000N` and works with every active operator of the country. Any country code works. | Number (example CM) | HTTP | Result | | - | - | - | | `+237000000001` | `200` | `status: "success"` — confirmed immediately | | `+237000000002` | `202` | `status: "pending"` — stays pending until you simulate | | `+237000000003` | `200` | `status: "failed"` | | `+237000000004` | `400` | `otp_required` — retry with `otp: "000000"` → `202 pending` | | `+237000000005` | `200` | `status: "cancelled"` | Other numbers return `202 pending`. | Country | Code | Prefix | | - | - | - | | Cameroon | CM | +237 | | Chad | TD | +235 | | Central African Rep. | CF | +236 | | Congo | CG | +242 | | Gabon | GA | +241 | | Equatorial Guinea | GQ | +240 | | Senegal | SN | +221 | | Côte d'Ivoire | CI | +225 | | Mali | ML | +223 | | Burkina Faso | BF | +226 | | Niger | NE | +227 | | Togo | TG | +228 | | Benin | BJ | +229 | | Guinea-Bissau | GW | +245 | | DR Congo | CD | +243 | | Nigeria | NG | +234 | | Ghana | GH | +233 | | Kenya | KE | +254 | ## OTP simulation ```json { "error": "otp_required", "sandbox": true, "simulation": true, "reference": "PLK-SBX-…", "ussd_code": "#SANDBOX#", "message": "Demo OTP required. Use 000000 with the same request." } ``` Re-send with `"reference": ""` and `"otp": "000000"`. Any other OTP returns `invalid_otp`. ## Finishing pending payments ```bash curl -X POST https://paylinky.vercel.app/api/v1/sandbox/simulate \ -H "Authorization: Bearer $PAYLINKY_TEST_KEY" \ -H "Content-Type: application/json" \ -d '{ "transaction_id": "0b9c…", "status": "success" }' ``` This updates the transaction and sends `payment.completed` (or `payment.failed`) to your webhook. ## Crypto in the sandbox `/crypto/collect` returns a clearly fake address and stays `pending` until you call `/sandbox/simulate`. ## Test checklist - `200` on `/countries` and `/crypto/assets` - one `pending`, one `success`, one `failed`, one `cancelled` - the OTP round-trip - webhook received, signature verified, duplicate delivery ignored - timeout → status check instead of a second payment - errors `400`, `401`, `403`, `429` --- # Security & go-live Source: https://paylinky.vercel.app/docs/security Best practices to protect keys and payments. # Security - Keep keys in server-side secrets or environment variables. - Call Paylinky only from your backend; send the browser a `payment_link`, never a key. - Use a distinct key per environment/service and **roll** a key immediately if exposed. - Verify every webhook signature; reject old timestamps. - Use HTTPS for webhooks, redirect URLs and your whole site. - Make fulfilment idempotent (`transaction_id`, `merchant_reference`). - Validate amounts server-side: never trust an amount sent from the browser. - Log `transaction_id`, `reference` and webhook `X-Paylinky-Event-Id` for reconciliation. - Do not log full keys, secrets or customer PINs/OTPs. ## Go-live checklist 1. KYC approved and live key **activated**. 2. All sandbox scenarios tested (success, pending, failed, cancelled, OTP). 3. Webhook endpoint public, HTTPS, signature verified, idempotent. 4. `/countries` loaded dynamically; unsupported operators handled. 5. Timeout handling: status check before retry. 6. Keys stored in secrets; sandbox and live keys separated. 7. Customer-facing messages for `pending`, `failed`, `expired`. 8. Support contact visible to your customers. ## Account protection Paylinky stores API keys hashed, applies a per-key rate limit (default 120 requests/minute), logs every API request, and lets administrators suspend accounts or revoke keys instantly. --- # SDKs Source: https://paylinky.vercel.app/docs/sdks Zero-dependency clients for Node.js, Python and PHP. # SDKs Drop-in, zero-dependency clients. Download from the links below and replace `YOUR_PAYLINKY_DOMAIN` with `https://paylinky.vercel.app` (or pass `baseUrl`). | Language | Download | Requires | | - | - | - | | Node.js | [`paylinky.js`](https://paylinky.vercel.app/sdk/paylinky.js) | Node 18+ | | Python | [`paylinky.py`](https://paylinky.vercel.app/sdk/paylinky.py) | `requests` | | PHP | [`Paylinky.php`](https://paylinky.vercel.app/sdk/Paylinky.php) | PHP 7.4+, ext-curl | :::tabs ```javascript Node.js const { Paylinky, PaylinkyError } = require("./paylinky.js"); const plk = new Paylinky(process.env.PAYLINKY_KEY, { baseUrl: "https://paylinky.vercel.app" }); // Direct API const tx = await plk.collect({ amount: 1000, currency: "XAF", country_code: "CM", operator: "MTN Mobile Money", phone: "+237000000001", reference: "order_1" }); // Hosted page const link = await plk.createPaymentLink({ title: "Order #1", amount: 10000, currency: "XAF" }, "order-1"); console.log(link.payment_link); // Errors try { await plk.collect({}); } catch (e) { if (e instanceof PaylinkyError) console.log(e.status, e.code, e.message); } ``` ```python Python from paylinky import Paylinky, PaylinkyError plk = Paylinky(os.environ["PAYLINKY_KEY"], base_url="https://paylinky.vercel.app") tx = plk.collect(amount=1000, currency="XAF", country_code="CM", operator="MTN Mobile Money", phone="+237000000001", reference="order_1") link = plk.create_payment_link(idempotency_key="order-1", title="Order #1", amount=10000, currency="XAF") print(link["payment_link"]) ``` ```php PHP require "Paylinky.php"; $plk = new Paylinky(getenv("PAYLINKY_KEY"), "https://paylinky.vercel.app"); $tx = $plk->collect(["amount" => 1000, "currency" => "XAF", "country_code" => "CM", "operator" => "MTN Mobile Money", "phone" => "+237000000001", "reference" => "order_1"]); $link = $plk->createPaymentLink(["title" => "Order #1", "amount" => 10000, "currency" => "XAF"], "order-1"); echo $link["payment_link"]; ``` ::: ## Methods | Method | Endpoint | | - | - | | `countries()` | `GET /countries` | | `cryptoAssets()` | `GET /crypto/assets` | | `fees()` / `balance()` | `GET /fees`, `GET /balance` | | `collect(params)` | `POST /collect` | | `cryptoCollect(params)` | `POST /crypto/collect` | | `transaction(id)` | `GET /transaction/{id}` | | `createPaymentLink(params, idempotencyKey)` | `POST /payment-links` | | `paymentLink(id)` | `GET /payment-links/{id}` | | `simulate(id, status)` | `POST /sandbox/simulate` | | `verifyWebhook(raw, headers, secret)` | Signature check | --- # AI integration Source: https://paylinky.vercel.app/docs/ai Give your AI coding agent accurate Paylinky knowledge. # AI integration Paylinky publishes machine-readable documentation so assistants (ChatGPT, Claude, Cursor, Copilot…) can integrate it correctly. | Resource | URL | | - | - | | Documentation index | [`https://paylinky.vercel.app/llms.txt`](https://paylinky.vercel.app/llms.txt) | | **Full docs in one file** | [`https://paylinky.vercel.app/llms-full.txt`](https://paylinky.vercel.app/llms-full.txt) | | OpenAPI 3.1 spec | [`https://paylinky.vercel.app/openapi.json`](https://paylinky.vercel.app/openapi.json) | | Any page as Markdown | `https://paylinky.vercel.app/docs/raw/{slug}` (e.g. `/docs/raw/webhooks`) | ## Prompt to give your agent ```text You are integrating Paylinky payments. First fetch and read https://paylinky.vercel.app/llms-full.txt. Rules: - Base URL https://paylinky.vercel.app/api/v1, auth "Authorization: Bearer plk_(test|live)_…" from server-side env var PAYLINKY_KEY. - Never expose the key to the browser or mobile app. - Load GET /countries and GET /crypto/assets; never hard-code operators. - Use a unique `reference` per order; handle 202 pending, wave_url, and 400 otp_required (retry with the returned `reference` + `otp`). - Crypto requires customer.email, customer.firstName, customer.lastName — never invent them. - Fulfil orders only on the signed webhook (HMAC-SHA256 of timestamp + "." + raw body with whsec_ secret) or GET /transaction/{id} === success. - Test with plk_test_ keys and numbers ending 000000001..5 before going live. - Live keys need KYC approval and manual activation; if you get key_not_activated or kyc_required, stop and tell the user. ``` ## Facts an agent must know - Two products: **Hosted Page API** (`POST /payment-links` → `payment_link`) and **Direct API** (`POST /collect`, `POST /crypto/collect`). - Statuses: `pending`, `success`, `failed`, `cancelled`, `expired`, `refunded`. Webhook events: `payment.completed`, `payment.failed`. - Sandbox is deterministic by the last digit of the phone number (`1` success, `2` pending, `3` failed, `4` OTP `000000`, `5` cancelled). - Administrators may disable countries, operators, crypto assets or whole methods at any time — always read the catalog. - The agent cannot bypass key activation or KYC. ## Skill file (for agents that support skills) ```markdown --- name: paylinky description: Integrate Paylinky Mobile Money and crypto payments (Hosted Page API and Direct API). --- 1. Read https://paylinky.vercel.app/llms-full.txt and https://paylinky.vercel.app/openapi.json. 2. Ask the user which mode they need: hosted payment link or direct API. 3. Create server-side code only; keys in environment variables. 4. Implement webhook signature verification and idempotent fulfilment. 5. Provide sandbox test steps using numbers ending 000000001–000000005. ```