Developer documentation

Issue an invoice, send the payer to checkout, and learn the outcome. Three endpoints and one webhook.

Authentication

Every API request carries your platform key as a bearer token. Keys are issued in the operator console and shown once; we store only a hash, so a lost key is rotated rather than recovered.

Authorization: Bearer lmspay_xxxxxxxxxxxxxxxxxxxxxxxxxxxx

Create an invoice

POST /api/v1/invoices. Idempotent on externalRef: send the same reference twice and you get the same invoice back, with 200 instead of 201. Use your own billing-period id as the reference and a retry can never bill the same period twice.

curl -X POST https://your-domain/api/v1/invoices \
  -H "Authorization: Bearer $LMSPAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "externalRef": "period_2026_10",
    "periodKey": "2026-10",
    "amountMinor": 4500000,
    "currency": "LKR",
    "description": "Platform fee — October 2026",
    "dueAt": "2026-11-08T00:00:00.000Z"
  }'

{
  "id": "inv_2f7c…",
  "status": "OPEN",
  "amountMinor": 4500000,
  "currency": "LKR",
  "checkoutUrl": "https://your-domain/i/inv_2f7c…"
}

Amounts are integer minor units — 4500000 is LKR 45,000.00. There are no floating-point amounts anywhere in this API.

Read an invoice

GET /api/v1/invoices/:id is the authoritative status. Treat it as the source of truth, not the webhook. A webhook that never arrives is silent; a scheduled poll closes that gap without depending on our delivery.

GET /api/v1/invoices/inv_2f7c…
{ "id": "inv_2f7c…", "status": "PAID", "paidAt": "2026-11-02T04:11:55.000Z" }

Void an invoice

POST /api/v1/invoices/:id/void cancels an unpaid invoice. A paid invoice is never voided — that would be a refund, which is handled by agreement rather than by API.

Webhooks

We POST invoice.paid, invoice.voided and invoice.expired to your endpoint. Answer with any 2xx. Anything else is retried nine times over about a day, then abandoned and shown in our console for manual replay.

Each request carries x-lmspay-signature. The timestamp is inside the signed material, so an old capture stops verifying — reject anything more than five minutes out.

x-lmspay-signature: v1,t=1793664715,s=<hex hmac-sha256>

signed message = "v1:" + timestamp + ":" + rawBody
secret         = the webhook secret issued with your platform

Verify against the raw body, before any JSON parsing. Re-serialising an object produces different bytes and will fail against a genuine signature.

SDK

@lmspay/sdk wraps all of the above, including constant-time signature verification. It is published from this service’s repository.

import { LmsPay, verifyWebhook, SIGNATURE_HEADER } from '@lmspay/sdk';

const pay = new LmsPay({ baseUrl: process.env.LMSPAY_URL, apiKey: process.env.LMSPAY_API_KEY });

const invoice = await pay.invoices.create({
  externalRef: period.id,
  amountMinor: period.feeAmountMinor,
  currency: 'LKR',
  description: `Platform fee — ${period.periodKey}`,
});
// -> invoice.checkoutUrl

// in your webhook route — read the RAW body, before any JSON parsing
const rawBody = await request.text();
const ok = verifyWebhook({
  rawBody,
  header: request.headers.get(SIGNATURE_HEADER),
  secret: process.env.LMSPAY_WEBHOOK_SECRET,
});

if (!ok) return new Response('bad signature', { status: 400 });

Errors

Every failure returns the same shape, with a stable machine-readable code. Branch on the code, never on the message — the wording will be improved.

{ "error": { "code": "VALIDATION_FAILED", "message": "Invalid invoice.", "details": { … } } }

UNAUTHORIZED 401 · PLATFORM_SUSPENDED 403 · NOT_FOUND 404 · INVOICE_NOT_OPEN 409
AMOUNT_MISMATCH 409 · VALIDATION_FAILED 422 · GATEWAY_UNAVAILABLE 503