Skip to content
lyonga

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.

API version v1 (draft). The sandbox is not open yet. Request sandbox access.

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 fetch available 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.

EnvironmentBase URLKeys
Sandboxhttps://sandbox.api.lyonga.compk_test_... and sk_test_...
Livehttps://api.lyonga.compk_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"

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.

  1. Request sandbox access. Once granted, you receive sandbox keys and can set a webhook endpoint in the dashboard.
  2. Create a payment from your server when the customer checks out. Send the amount in ngwee, currency: "ZMW", your own order reference and an Idempotency-Key. Save the returned payment id against your order.
  3. Let the customer complete the payment using one of the payment methods approved for your account. While this happens the payment is pending or processing.
  4. Listen for webhooks. Fulfil the order when you receive payment.succeeded, and tell the customer when you receive payment.failed. Verify the signature on every event.
  5. 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.
  6. Reconcile using your reference and the settlement.paid event, which tells you when funds have been paid out to your account.
  7. 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

FieldTypeDescription
idstringUnique identifier, prefixed pay_.
objectstringAlways payment.
amountintegerAmount in the smallest currency unit (ngwee). K1,250.00 is 125000.
currencystringThree-letter ISO currency code. Currently ZMW only.
statusstringCurrent status. See the table below.
referencestringYour own reference for the payment, such as an order number.
descriptionstring or nullShort description shown in the dashboard and reports.
customerobjectname, email and phone for the paying customer. Phone numbers use international format, for example +260970000001.
payment_method_typestring or nullThe type of payment method used, from those approved for your account. Null until the customer has chosen one.
metadataobjectUp to 20 key-value string pairs you can attach for your own use.
created_atstringWhen the payment was created.
updated_atstringWhen the payment last changed.

Payment statuses

StatusMeaning
pendingCreated and waiting for the customer to complete it.
processingThe customer has acted and the result is being confirmed with the provider.
succeededFunds were collected. This is the status to fulfil on.
failedThe payment did not complete, for example it was declined or timed out.
cancelledCancelled before it completed. No funds were collected.
refundedThe full amount has been refunded.
partially_refundedPart 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

ParameterRequiredDescription
amountYesInteger amount in ngwee.
currencyYesZMW.
referenceYesYour reference, up to 100 characters. Use a value you can reconcile against.
descriptionNoUp to 255 characters.
customerNoObject with name, email and phone.
payment_method_typeNoLimit the payment to one payment method type approved for your account. If omitted, any approved method can be used.
metadataNoKey-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" }
  }'

Returns 201 Created with the payment object. A new payment usually starts as pending.

Responsejson
{
  "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"

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"

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

ParameterRequiredDescription
paymentYesID of the payment to refund.
amountNoAmount in ngwee. Defaults to the full amount not yet refunded.
reasonNoShort note for your records, up to 255 characters.
metadataNoKey-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"
  }'

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.

Refund objectjson
{
  "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

EventSent when
payment.succeededA payment has completed and funds were collected.
payment.failedA payment did not complete.
payment.pendingA payment is waiting on the customer or provider.
refund.succeededA refund has been completed.
refund.failedA refund could not be completed.
settlement.paidA settlement has been paid out to your bank account.
Example eventjson
{
  "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.

Header
Lyonga-Signature: t=1791623703,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

To verify an event:

  1. Read the raw request body before any JSON parsing.
  2. Split the header into t and v1.
  3. Compute the HMAC-SHA256 of t + "." + body with your signing secret.
  4. Compare it to v1 with a constant-time comparison.
  5. Reject the event if the timestamp is more than five minutes from your server's clock.
Verify and handle webhooksNode.js
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 2xx status 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 id you 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.

Idempotent requestcurl
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 Conflict with idempotency_conflict.
  • Keys can be up to 255 characters. Use a new key for each new payment or refund.
  • GET requests 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.

StatusMeaning
200 OKThe request succeeded.
201 CreatedA new object was created.
400 Bad RequestThe request was invalid, for example a missing or malformed parameter.
401 UnauthorizedNo valid API key was provided.
403 ForbiddenThe key is valid but cannot perform this action.
404 Not FoundThe object does not exist.
409 ConflictThe request conflicts with another, for example an idempotency key reused.
429 Too Many RequestsToo many requests in a short time. Back off and retry.
500, 502, 503Something went wrong on our side or with a provider. Retry later.

The error object

Error responsejson
{
  "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"
  }
}
FieldDescription
typeBroad category: invalid_request_error, authentication_error, rate_limit_error or api_error.
codeSpecific, stable code you can handle in your code. See below.
messageHuman-readable explanation. Do not parse it; wording may change.
paramThe parameter that caused the error, if any.
request_idID of the request. Include it when you contact support.

Error codes

CodeStatusDescription
invalid_request400A parameter is missing, malformed or not allowed.
authentication_failed401The API key is missing, invalid, revoked or for the wrong environment.
permission_denied403The key does not have permission for this action.
not_found404No object exists with that ID.
idempotency_conflict409The idempotency key was already used with a different request, or that request is still in progress.
payment_not_refundable409The payment has not succeeded or has already been fully refunded.
refund_not_supported409Refunds for this payment method are handled by Lyonga support. Open a support ticket.
rate_limited429Too many requests. Wait and retry.
amount_too_small400The amount is below the minimum for this payment.
currency_not_supported400The currency is not supported. Use ZMW.
payment_method_not_enabled400The payment method type is not approved for your account, or does not support this action.
provider_unavailable503The payment provider is temporarily unavailable. Retry later.
internal_error500An unexpected error on our side. Retry later.
Handling errorsNode.js
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 the id of the last object you received.
List responsejson
{
  "object": "list",
  "data": [
    { "id": "pay_8K2Q4M7T", "object": "payment", "status": "succeeded", "...": "..." },
    { "id": "pay_7H1P3L6R", "object": "payment", "status": "succeeded", "...": "..." }
  ],
  "has_more": true
}
Fetch every pageNode.js
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.

Rate-limited response (example values)
HTTP/1.1 429 Too Many Requests
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 12
Retry-After: 12

Retry 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 valueSimulated outcome
+260 970 000 001Payment succeeded
+260 970 000 002Payment failed with insufficient_funds
+260 970 000 003Payment stays pending, so you can test how you handle waiting
+260 970 000 004Payment failed with declined
+260 970 000 005Payment succeeded after about 30 seconds, to test late webhooks
Any other numberPayment 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_id from 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.