Developer API documentation
Collect payments, track their status and receive signed webhooks through one REST API. Build against the sandbox, then switch keys to go live.
Overview
The Lyonga API lets you collect payments, track their status, issue refunds and receive real-time notifications from your own systems. It is a REST API: resources have predictable URLs, requests and responses use JSON, and errors use standard HTTP status codes with a machine-readable error code.
- All requests are made over HTTPS. Plain HTTP requests are refused.
- Amounts are integers in the smallest currency unit. For Zambian kwacha that is ngwee, so K1,250.00 is
125000. - Timestamps are ISO 8601 strings in UTC, for example
2026-10-10T09:14:22Z. - Object IDs carry a prefix that tells you the type:
pay_,re_,evt_. - There is no official SDK yet. The Node.js examples use the built-in
fetchavailable in Node.js 18 and later.
Payments are processed through the payment methods approved for your account, in partnership with Lipila, a licensed payment service provider.
Environments
Lyonga has two fully separate environments. Data, keys and webhooks in one are never visible in the other.
| Environment | Base URL | Keys |
|---|---|---|
| Sandbox | https://sandbox.api.lyonga.com | pk_test_... and sk_test_... |
| Live | https://api.lyonga.com | pk_live_... and sk_live_... |
The sandbox behaves like live but never moves real money. Use it to build and test your integration, then switch the base URL and keys once your merchant account is approved for live payments. A test key sent to the live API, or a live key sent to the sandbox, is rejected with authentication_failed.
Each environment has a publishable key (pk_) that identifies your account and is safe to embed in client-side code, and a secret key (sk_) that authorises API requests and must stay on your server.
Authentication
Authenticate every request by sending your secret key as a bearer token in the Authorization header.
curl https://sandbox.api.lyonga.com/v1/payments \
-H "Authorization: Bearer sk_test_your_secret_key"// Read the key from your server's environment. Never ship it to a browser or app.
const LYONGA_SECRET_KEY = process.env.LYONGA_SECRET_KEY;
const res = await fetch("https://sandbox.api.lyonga.com/v1/payments", {
headers: { Authorization: `Bearer ${LYONGA_SECRET_KEY}` },
});Keep secret keys on your server
- Call the API only from your backend. Never put a secret key in a website, browser JavaScript, a mobile app or anything else you ship to customers, where it can be extracted.
- Store keys in environment variables or a secrets manager, not in source control.
- Give dashboard access only to the people who need it, and remove it when they leave.
Rotating keys
Your secret key is shown once when it is created. If you lose it or think it may have been exposed, create a new key in the dashboard, deploy it, then revoke the old one. You can keep both active for a short overlap so that rotation causes no downtime. If you believe a live key has leaked, revoke it immediately and email developers@lyonga.com.
Payment integration guide
A typical server-side integration follows these steps.
- Request sandbox access. Once granted, you receive sandbox keys and can set a webhook endpoint in the dashboard.
- Create a payment from your server when the customer checks out. Send the amount in ngwee,
currency: "ZMW", your own orderreferenceand anIdempotency-Key. Save the returned paymentidagainst your order. - Let the customer complete the payment using one of the payment methods approved for your account. While this happens the payment is
pendingorprocessing. - Listen for webhooks. Fulfil the order when you receive
payment.succeeded, and tell the customer when you receivepayment.failed. Verify the signature on every event. - Confirm the final status from your server by retrieving the payment if you need to. Never treat a redirect, a browser callback or a customer's screenshot as proof of payment.
- Reconcile using your
referenceand thesettlement.paidevent, which tells you when funds have been paid out to your account. - Go live after your merchant account is approved: swap in live keys, set your live webhook endpoint and run a small real payment end to end.
Payments
A payment represents one attempt to collect money from a customer. It moves through statuses as the customer and payment provider act on it.
The payment object
| Field | Type | Description |
|---|---|---|
id | string | Unique identifier, prefixed pay_. |
object | string | Always payment. |
amount | integer | Amount in the smallest currency unit (ngwee). K1,250.00 is 125000. |
currency | string | Three-letter ISO currency code. Currently ZMW only. |
status | string | Current status. See the table below. |
reference | string | Your own reference for the payment, such as an order number. |
description | string or null | Short description shown in the dashboard and reports. |
customer | object | name, email and phone for the paying customer. Phone numbers use international format, for example +260970000001. |
payment_method_type | string or null | The type of payment method used, from those approved for your account. Null until the customer has chosen one. |
metadata | object | Up to 20 key-value string pairs you can attach for your own use. |
created_at | string | When the payment was created. |
updated_at | string | When the payment last changed. |
Payment statuses
| Status | Meaning |
|---|---|
pending | Created and waiting for the customer to complete it. |
processing | The customer has acted and the result is being confirmed with the provider. |
succeeded | Funds were collected. This is the status to fulfil on. |
failed | The payment did not complete, for example it was declined or timed out. |
cancelled | Cancelled before it completed. No funds were collected. |
refunded | The full amount has been refunded. |
partially_refunded | Part of the amount has been refunded. |
succeeded, failed and cancelled are final for collection. A succeeded payment can later move to partially_refunded or refunded.
Create a payment
POST/v1/payments
| Parameter | Required | Description |
|---|---|---|
amount | Yes | Integer amount in ngwee. |
currency | Yes | ZMW. |
reference | Yes | Your reference, up to 100 characters. Use a value you can reconcile against. |
description | No | Up to 255 characters. |
customer | No | Object with name, email and phone. |
payment_method_type | No | Limit the payment to one payment method type approved for your account. If omitted, any approved method can be used. |
metadata | No | Key-value pairs for your own use. |
curl https://sandbox.api.lyonga.com/v1/payments \
-X POST \
-H "Authorization: Bearer sk_test_your_secret_key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c9a2e-4b7d-4c1e-9a55-0d2f8e7b1c34" \
-d '{
"amount": 125000,
"currency": "ZMW",
"reference": "order_10482",
"description": "Order 10482",
"customer": {
"name": "Mwila Banda",
"email": "mwila@example.com",
"phone": "+260970000001"
},
"metadata": { "order_id": "10482" }
}'import { randomUUID } from "node:crypto";
const res = await fetch("https://sandbox.api.lyonga.com/v1/payments", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LYONGA_SECRET_KEY}`,
"Content-Type": "application/json",
// Store this with your order so a retry reuses the same key.
"Idempotency-Key": randomUUID(),
},
body: JSON.stringify({
amount: 125000, // K1,250.00 in ngwee
currency: "ZMW",
reference: "order_10482",
description: "Order 10482",
customer: { name: "Mwila Banda", email: "mwila@example.com", phone: "+260970000001" },
metadata: { order_id: "10482" },
}),
});
const payment = await res.json();
if (!res.ok) throw new Error(payment.error.message);
console.log(payment.id, payment.status); // "pay_8K2Q4M7T" "pending"Returns 201 Created with the payment object. A new payment usually starts as pending.
{
"id": "pay_8K2Q4M7T",
"object": "payment",
"amount": 125000,
"currency": "ZMW",
"status": "pending",
"reference": "order_10482",
"description": "Order 10482",
"customer": {
"name": "Mwila Banda",
"email": "mwila@example.com",
"phone": "+260970000001"
},
"payment_method_type": null,
"metadata": { "order_id": "10482" },
"created_at": "2026-10-10T09:14:22Z",
"updated_at": "2026-10-10T09:14:22Z"
}Retrieve a payment
GET/v1/payments/{id}
Returns the payment object, or 404 with not_found if no payment has that ID.
curl https://sandbox.api.lyonga.com/v1/payments/pay_8K2Q4M7T \
-H "Authorization: Bearer sk_test_your_secret_key"const res = await fetch("https://sandbox.api.lyonga.com/v1/payments/pay_8K2Q4M7T", {
headers: { Authorization: `Bearer ${process.env.LYONGA_SECRET_KEY}` },
});
const payment = await res.json();List payments
GET/v1/payments
Returns payments newest first. Filter with status or reference, and page through results with limit and starting_after (see Pagination).
curl "https://sandbox.api.lyonga.com/v1/payments?limit=20&status=succeeded" \
-H "Authorization: Bearer sk_test_your_secret_key"const params = new URLSearchParams({ limit: "20", status: "succeeded" });
const res = await fetch(`https://sandbox.api.lyonga.com/v1/payments?${params}`, {
headers: { Authorization: `Bearer ${process.env.LYONGA_SECRET_KEY}` },
});
const { data, has_more } = await res.json();Refunds
Refund all or part of a succeeded payment. Refunds are available where the payment method used supports them; if it does not, the request fails with payment_method_not_enabled. You can make several partial refunds until the full amount has been returned.
Create a refund
POST/v1/refunds
| Parameter | Required | Description |
|---|---|---|
payment | Yes | ID of the payment to refund. |
amount | No | Amount in ngwee. Defaults to the full amount not yet refunded. |
reason | No | Short note for your records, up to 255 characters. |
metadata | No | Key-value pairs for your own use. |
curl https://sandbox.api.lyonga.com/v1/refunds \
-X POST \
-H "Authorization: Bearer sk_test_your_secret_key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 0b8e1f52-7a3c-4d9b-8e21-5c6a9f0d4e17" \
-d '{
"payment": "pay_8K2Q4M7T",
"amount": 25000,
"reason": "Customer returned one item"
}'const res = await fetch("https://sandbox.api.lyonga.com/v1/refunds", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LYONGA_SECRET_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": refundKey, // one key per refund you intend to make
},
body: JSON.stringify({
payment: "pay_8K2Q4M7T",
amount: 25000, // omit to refund the full remaining amount
reason: "Customer returned one item",
}),
});
const refund = await res.json();A refund starts as pending and moves to succeeded or failed. You receive a refund.succeeded or refund.failed webhook when it settles, and the payment status updates to partially_refunded or refunded.
{
"id": "re_3D9F2K1M",
"object": "refund",
"payment": "pay_8K2Q4M7T",
"amount": 25000,
"currency": "ZMW",
"status": "pending",
"reason": "Customer returned one item",
"created_at": "2026-10-11T08:02:10Z",
"updated_at": "2026-10-11T08:02:10Z"
}Webhooks
Webhooks tell your server when something happens, so you do not need to poll. Set an HTTPS endpoint in the dashboard for each environment. Lyonga sends a POST request with a JSON event to that URL.
Event types
| Event | Sent when |
|---|---|
payment.succeeded | A payment has completed and funds were collected. |
payment.failed | A payment did not complete. |
payment.pending | A payment is waiting on the customer or provider. |
refund.succeeded | A refund has been completed. |
refund.failed | A refund could not be completed. |
settlement.paid | A settlement has been paid out to your bank account. |
{
"id": "evt_5R8T2W9Q",
"object": "event",
"type": "payment.succeeded",
"created_at": "2026-10-10T09:15:03Z",
"data": {
"object": {
"id": "pay_8K2Q4M7T",
"object": "payment",
"status": "succeeded",
"amount": 125000,
"currency": "ZMW",
"reference": "order_10482"
}
}
}Verify the signature
Every request includes a Lyonga-Signature header. It contains a Unix timestamp t and a v1 signature: the hex-encoded HMAC-SHA256 of the string timestamp.payload (the timestamp, a full stop, then the raw request body), keyed with your endpoint's webhook signing secret.
Lyonga-Signature: t=1791623703,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdTo verify an event:
- Read the raw request body before any JSON parsing.
- Split the header into
tandv1. - Compute the HMAC-SHA256 of
t + "." + bodywith your signing secret. - Compare it to
v1with a constant-time comparison. - Reject the event if the timestamp is more than five minutes from your server's clock.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SIGNING_SECRET = process.env.LYONGA_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 300;
// Use the raw body: re-serialised JSON will not match the signature.
app.post("/webhooks/lyonga", express.raw({ type: "application/json" }), async (req, res) => {
const header = req.get("Lyonga-Signature") ?? "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const timestamp = Number(parts.t);
const received = parts.v1 ?? "";
const expected = crypto
.createHmac("sha256", SIGNING_SECRET)
.update(`${parts.t}.${req.body.toString("utf8")}`)
.digest("hex");
const valid =
received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received, "hex"), Buffer.from(expected, "hex"));
const fresh = Math.abs(Date.now() / 1000 - timestamp) <= TOLERANCE_SECONDS;
if (!valid || !fresh) return res.status(400).send("Invalid signature");
const event = JSON.parse(req.body.toString("utf8"));
// Acknowledge first, then process. Skip events you have already handled.
res.sendStatus(200);
if (await alreadyProcessed(event.id)) return;
await enqueue(event); // e.g. mark the order paid on payment.succeeded
});Delivery and retries
- Respond with any
2xxstatus as quickly as possible, ideally within a few seconds. Do slow work, such as sending emails or updating stock, after you respond. - If your endpoint times out or returns anything other than
2xx, we retry with exponential backoff over several days. - The same event can arrive more than once, and events can arrive out of order. Store each event
idyou process and skip duplicates. If order matters, retrieve the latest object from the API.
Idempotency
Network failures happen. To retry a POST request safely without creating a second payment or refund, send an Idempotency-Key header with a unique value, such as a UUID, and reuse it on every retry of that request.
curl https://sandbox.api.lyonga.com/v1/payments \
-X POST \
-H "Authorization: Bearer sk_test_your_secret_key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order_10482_attempt_1" \
-d '{ "amount": 125000, "currency": "ZMW", "reference": "order_10482" }'- The first response for a key, success or error, is stored for 24 hours. Retries with the same key and the same body return that stored response.
- Reusing a key with a different request body returns
409 Conflictwithidempotency_conflict. - Keys can be up to 255 characters. Use a new key for each new payment or refund.
GETrequests are idempotent by nature and ignore the header.
Errors
Lyonga uses conventional HTTP status codes. Codes in the 2xx range mean success, 4xx means something in the request needs fixing, and 5xx means a problem on our side or with a provider.
| Status | Meaning |
|---|---|
200 OK | The request succeeded. |
201 Created | A new object was created. |
400 Bad Request | The request was invalid, for example a missing or malformed parameter. |
401 Unauthorized | No valid API key was provided. |
403 Forbidden | The key is valid but cannot perform this action. |
404 Not Found | The object does not exist. |
409 Conflict | The request conflicts with another, for example an idempotency key reused. |
429 Too Many Requests | Too many requests in a short time. Back off and retry. |
500, 502, 503 | Something went wrong on our side or with a provider. Retry later. |
The error object
{
"error": {
"type": "invalid_request_error",
"code": "amount_too_small",
"message": "Amount must be at least the minimum allowed for this payment method.",
"param": "amount",
"request_id": "req_4N7B2X8C"
}
}| Field | Description |
|---|---|
type | Broad category: invalid_request_error, authentication_error, rate_limit_error or api_error. |
code | Specific, stable code you can handle in your code. See below. |
message | Human-readable explanation. Do not parse it; wording may change. |
param | The parameter that caused the error, if any. |
request_id | ID of the request. Include it when you contact support. |
Error codes
| Code | Status | Description |
|---|---|---|
invalid_request | 400 | A parameter is missing, malformed or not allowed. |
authentication_failed | 401 | The API key is missing, invalid, revoked or for the wrong environment. |
permission_denied | 403 | The key does not have permission for this action. |
not_found | 404 | No object exists with that ID. |
idempotency_conflict | 409 | The idempotency key was already used with a different request, or that request is still in progress. |
payment_not_refundable | 409 | The payment has not succeeded or has already been fully refunded. |
refund_not_supported | 409 | Refunds for this payment method are handled by Lyonga support. Open a support ticket. |
rate_limited | 429 | Too many requests. Wait and retry. |
amount_too_small | 400 | The amount is below the minimum for this payment. |
currency_not_supported | 400 | The currency is not supported. Use ZMW. |
payment_method_not_enabled | 400 | The payment method type is not approved for your account, or does not support this action. |
provider_unavailable | 503 | The payment provider is temporarily unavailable. Retry later. |
internal_error | 500 | An unexpected error on our side. Retry later. |
const res = await fetch(url, options);
if (!res.ok) {
const { error } = await res.json();
// Log request_id: support can trace the exact request with it.
console.error(error.code, error.message, error.request_id);
if (res.status === 429 || res.status >= 500) {
// Safe to retry with the same Idempotency-Key, after a backoff.
}
}Pagination
List endpoints are cursor-based. They return a list object with a data array and a has_more flag.
limit: how many objects to return, from 1 to 100. The default is 20.starting_after: an object ID. Returns the objects that come after it in the list. To get the next page, pass theidof the last object you received.
{
"object": "list",
"data": [
{ "id": "pay_8K2Q4M7T", "object": "payment", "status": "succeeded", "...": "..." },
{ "id": "pay_7H1P3L6R", "object": "payment", "status": "succeeded", "...": "..." }
],
"has_more": true
}async function* allPayments() {
let startingAfter;
while (true) {
const params = new URLSearchParams({ limit: "100" });
if (startingAfter) params.set("starting_after", startingAfter);
const res = await fetch(`https://sandbox.api.lyonga.com/v1/payments?${params}`, {
headers: { Authorization: `Bearer ${process.env.LYONGA_SECRET_KEY}` },
});
const page = await res.json();
yield* page.data;
if (!page.has_more) return;
startingAfter = page.data[page.data.length - 1].id;
}
}
for await (const payment of allPayments()) {
console.log(payment.id, payment.status);
}Rate limits
To keep the platform reliable for everyone, the API limits how many requests each account can make in a short period. Limits apply separately to the sandbox and live environments. We will confirm the exact limits for your account when you are given access.
Every response includes RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset headers. When you exceed the limit you receive 429 with rate_limited and a Retry-After header in seconds.
HTTP/1.1 429 Too Many Requests
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 12
Retry-After: 12Retry after the time given, with exponential backoff and some random jitter. Prefer webhooks to polling, and cache results you read often.
Testing
In the sandbox, nothing reaches a real customer or moves real money. Use these test values as the customer phone number to simulate different outcomes. They work only in the sandbox.
| Test value | Simulated outcome |
|---|---|
+260 970 000 001 | Payment succeeded |
+260 970 000 002 | Payment failed with insufficient_funds |
+260 970 000 003 | Payment stays pending, so you can test how you handle waiting |
+260 970 000 004 | Payment failed with declined |
+260 970 000 005 | Payment succeeded after about 30 seconds, to test late webhooks |
| Any other number | Payment succeeded after about 3 seconds |
Test values for other payment method types are provided with your sandbox access. Before going live, test each outcome above, a duplicate webhook delivery, a retry with the same idempotency key and a refund.
Versioning
The major version is part of the URL: /v1. Within a version we only make additive, backwards compatible changes, such as:
- adding new endpoints or optional request parameters,
- adding new fields to responses,
- adding new event types, error codes or status values.
Build your integration to ignore unknown fields and handle unknown values gracefully. Breaking changes are released only as a new major version, such as /v2. When we plan to retire a version or feature, we will give advance notice by email to your account contacts and in these docs.
This documentation describes v1 (draft). Details may change before the sandbox opens.
Support
- Integration questions and sandbox access: developers@lyonga.com
- Always include the
request_idfrom the error or response headers, the environment and the approximate time. - Never send secret keys, webhook signing secrets or full customer details by email. If a key has been exposed, revoke it first.
You can also contact technical support through our contact form.