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.
The software, the integration, and where the payment gateway sits.
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.
Three parties. Our software raises the invoice, this site hosts the checkout, and the gateway takes the card and reports the result.
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.
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.
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.
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.
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.
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.
We notify the platform with a signed webhook, and the platform verifies it against the raw body 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 timestamp is inside the signed material, so a captured request stops verifying once it falls outside the tolerance window. And the webhook is deliberately 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.
The gateway is reached through one interface — PaymentGateway — with four operations and nothing more. Adding a provider is one file behind that interface; it does not touch invoices, webhooks or the console.
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.
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.
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.
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.
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.
The payer’s return to our result page proves nothing — it is a URL any browser can be pointed at — so that page reads the record and never asserts it. An invoice moves to PAID in exactly one place: the gateway callback route, which
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.
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.