The software, the integration, and where the payment gateway sits
Three parties. Our software raises the invoice, this site hosts the checkout, and the gateway takes the card.
This is an in-house billing service
We build and operate learning management platforms. Each customer runs their own installation of our software, on their own domain, under a signed agreement. LMS Pay is the service that bills those customers for their use of that software, and collects the fee by card.
We are the merchant, and the only people charged are our own contracted customers.
- Not a marketplace, and not a payment facilitator — we never process a payment on behalf of a third party.
- No public checkout. An invoice exists only because we issued it to a named customer.
- Students and members of the public are never charged here. They pay their own educator, inside that educator’s own platform, through a separate arrangement that does not touch this service.
- One currency, LKR. One fee type: a software fee for a stated billing period.
Six messages, drawn in the order they happen
Only one of them can move money, and it is not the one the payer's browser sends.
What happens, in order
- 1
The platform requests an invoice
A billing period ends on a customer’s installation. The platform calls our authenticated API through the SDK with its own period id, the amount and the currency. We return the invoice and a checkout link.
- 2
The operator is shown what they are about to pay
The amount, the billing period, the reference and who the charge will appear as are all on the checkout page before anything happens. Nothing is charged by arriving there.
- 3
The card is entered on the gateway’s hosted page
We hand the payer to the gateway with the amount and our reference. Card details are entered there, on the gateway’s own page, and never pass through our servers.
- 4
The gateway reports the result server to server
The gateway posts the outcome directly to us. We verify its signature, match its reference to the transaction we opened, and check the amount against the invoice before anything is marked paid.
- 5
The platform is notified, and can also ask
We send a signed invoice.paid webhook to the customer’s platform, retried on failure and inspectable in our console. The platform can also poll the invoice at any time, which is the authoritative answer.
Invoices
Last 30 days| Platform | Reference | Amount | Status |
|---|---|---|---|
| Horizon Institute | period_2026_10 | LKR 45,000.00 | PAID |
| Lakmal Physics | period_2026_10 | LKR 22,500.00 | PAID |
| Nuwara Academy | period_2026_10 | LKR 38,000.00 | OPEN |
Webhook deliveries
Retried 9 times over ~24h- DELIVERED
invoice.paid → Horizon Institute
1 attempt · HTTP 200
- PENDING
invoice.paid → Lakmal Physics
3 attempts · HTTP 502 · next 04:18
What a platform actually writes
Our platforms integrate through @lmspay/sdk, published from this service’s repository. This is the whole of the billing side — the platform asks for an invoice and sends its operator to the returned link.
import { LmsPay } from '@lmspay/sdk';
const pay = new LmsPay({
baseUrl: process.env.LMSPAY_URL, // https://lmspay.online
apiKey: process.env.LMSPAY_API_KEY, // issued per platform, stored hashed by us
});
// At the end of a billing period, the platform asks for an invoice.
const invoice = await pay.invoices.create({
externalRef: period.id, // the platform's own id for the period
periodKey: period.key, // '2026-10'
amountMinor: period.feeAmountMinor, // 4500000 = LKR 45,000.00
currency: 'LKR',
description: `Platform fee — ${period.key}`,
});
// Send the operator here. Nothing is charged until they act.
return invoice.checkoutUrl;externalRef is the platform’s own billing-period id and is unique per platform in our database. A retried billing job therefore cannot bill the same period twice — the second call returns the invoice that already exists. That guarantee is a database constraint, not caller discipline, because the caller is an automated job on a machine that reboots.
A signed notification, verified against the raw body
We notify the platform with a signed webhook, and the platform verifies it before trusting it.
import { verifyWebhook, SIGNATURE_HEADER } from '@lmspay/sdk';
export async function POST(request: Request) {
const rawBody = await request.text(); // RAW — before any JSON parsing
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 });
const { event, data } = JSON.parse(rawBody);
if (event === 'invoice.paid') await markPeriodPaid(data.invoice.externalRef);
return new Response('ok');
}The webhook is not the authority
GET /api/v1/invoices/:id is, and our documentation says so. A webhook that never arrives is silent; a poll closes that gap without depending on our own delivery.One interface, four operations, nothing more
The gateway is reached through a single seam — PaymentGateway. Adding a provider is one file behind that interface; it does not touch invoices, webhooks or the console.
startPayment()
Opens a payment attempt for one invoice and returns where to send the payer. The attempt is recorded before the payer leaves, so a payment that never comes back is still a record rather than a gap.
handleCallback()
Interprets what the gateway sends back. It verifies the gateway’s signature first and ignores anything that does not check out — while still answering 200, so a provider is never made to retry a request that will never be accepted.
fetchStatus()
Asks the gateway what actually happened. This is the reconciliation path: a callback that never arrives is otherwise silent — the payer paid, the invoice stayed open, and nothing is in an error state.
isConfigured()
Whether credentials are present. A gateway without them refuses rather than guesses, and this deployment refuses to start in a mode that could mark an invoice paid without taking money.

Only one message settles an invoice
The payer’s return proves nothing — it is a URL any browser can be pointed at.
An invoice moves to PAID in exactly one place: the gateway callback route, which
- 1verifies the gateway’s signature before anything else,
- 2resolves the transaction by the gateway’s own reference,
- 3compares the amount against the invoice, and
- 4settles under a guard that only fires while the invoice is still open.
The third step is the one most often skipped. A verified signature proves where a message came from; it does not prove the sum agreed. The fourth is what makes a callback delivered twice settle once.
What we never hold
Card details are entered on the gateway’s own hosted page and never reach our servers, our logs or our database. What we store against a payment is the invoice, the amount, the gateway’s transaction reference, the outcome and the verified callback body kept verbatim for disputes — nothing that could be used to make a further charge. Further detail on the security page.
Current integration status
The service is complete and running: invoicing, hosted checkout, callback handling, signed webhooks with retry, the operator console and the client SDK are all built and deployed on this domain.
The provider adapter is the deliberate exception. It will be written against Genie Business’s own API guideline once sandbox credentials are issued, and not before — not from a blog post, a third-party wrapper or a recollection. Until then the flow runs end to end against a built-in simulator that signs its own callbacks, so signature verification and the settlement rules above are exercised rather than assumed.