How it works

The software, the integration, and where the payment gateway sits.

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.

The whole chain

Three parties. Our software raises the invoice, this site hosts the checkout, and the gateway takes the card and reports the result.

Sequence of a payment: the customer’s platform creates an invoice through the LMS Pay API and receives a checkout link; the payer is redirected to the gateway’s hosted card page; the gateway posts a signed callback to LMS Pay, which is the only message that marks the invoice paid; LMS Pay then sends a signed webhook back to the platform, which can also poll the authoritative invoice status.Customer’s platformour software, their serverLMS Paylmspay.onlinePayment gatewayhosted card page1 · create invoicePOST /api/v1/invoices2 · checkoutUrlthe operator is sent here3 · payer redirectedcard entered on their page4 · signed callbackthe only thing that settles5 · signed webhookinvoice.paid6 · poll (authoritative)GET /api/v1/invoices/:id
Steps 1, 3 and 4 are the payment itself. Step 4 — the gateway’s signed server-to-server callback — is the only message that can mark an invoice paid; the payer’s own return to this site never does.

Step by step

  1. 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. 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. 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. 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. 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.

The integration, in the software

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.

And how it learns the outcome

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.

Where the gateway sits

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.

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.

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.

Only one message settles an invoice

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

  1. verifies the gateway’s signature before anything else,
  2. resolves the transaction by the gateway’s own reference,
  3. compares the amount against the invoice, and
  4. settles 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.

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.