Payments.lk

Developers / Guide

Take your first payment this afternoon.

Pick how you sell, follow the steps with a sandbox key, and switch to your live key when you are approved. Card details always stay on the hosted checkout, never on your servers or in your app.

Before you start

Ten minutes in the dashboard, no approval needed.

  1. 1

    Get a sandbox key

    Dashboard, Developers, Create sandbox secret key. It starts sk_test_ and works the moment your account exists. Keep it on your server only.

  2. 2

    Try a checkout without code

    Dashboard, Developers, Try the checkout, once you have submitted your application. It opens Payable's sandbox payment page: pay with a test card from the developers page, where expiry 01/39 approves and 05/39 declines. No money moves.

  3. 3

    Watch webhooks arrive

    Point sandbox events at the test inbox on the Developers page before you have a server, then at your own endpoint.

  4. 4

    Go live

    Once you are approved, create a live key (sk_live_), add a live webhook endpoint, and swap the key. Nothing else changes.

Website

A checkout on your website

Your server creates a checkout, the customer pays on the hosted checkout page, and your server marks the order paid when the signed webhook arrives.

  1. 1

    Install the library on your server

    Node.js shown here. The PHP library works the same way, or call the REST API from any language.

    npm install @payments-lk/node
  2. 2

    Create a checkout when the customer presses Pay

    Take the amount from your own records, never from the browser. Send the customer to checkout.url.

    import { PaymentsLk } from "@payments-lk/node";
    
    const lk = new PaymentsLk(process.env.PAYMENTS_LK_SECRET_KEY); // sk_test_ while you build
    
    app.post("/checkout", async (req, res) => {
      const order = await orders.find(req.body.orderId); // price it from your own records
    
      const checkout = await lk.checkouts.create(
        {
          amountCents: order.totalCents, // Rs. 1,450.00 is 145000
          description: order.summary,
          reference: order.id,
          successUrl: "https://yourshop.lk/orders/" + order.id,
          cancelUrl: "https://yourshop.lk/cart",
        },
        { idempotencyKey: "order-" + order.id }, // a retry never makes a second checkout
      );
    
      res.redirect(303, checkout.url);
    });
  3. 3

    Mark the order paid from the webhook

    Add an endpoint in the dashboard under Developers and keep its signing secret on your server. The return to successUrl is only a way back for the browser: anyone can type a URL, so never fulfil an order from it.

    app.post("/webhooks/payments-lk", express.raw({ type: "application/json" }), async (req, res) => {
      let event;
      try {
        event = lk.webhooks.constructEvent(req.body, req.headers["payments-signature"], process.env.PAYMENTS_LK_WEBHOOK_SECRET);
      } catch {
        return res.sendStatus(400); // not signed by Payments.lk
      }
    
      if (event.type === "payment.succeeded") {
        await orders.markPaid(event.data.reference, event.data.amountCents); // safe to run twice
      }
      res.sendStatus(200);
    });
  4. 4

    Prefer a button to writing a redirect?

    The pay button posts to your /checkout endpoint and sends the customer on. Your endpoint answers with the checkout's url as JSON instead of redirecting.

    <button data-payments-lk-checkout="/checkout" data-payments-lk-body='{"orderId":"8891"}'>
      Pay Rs. 1,450
    </button>
    <script src="https://payments.lk/js/pay.js" defer></script>

Mobile app

A checkout in your mobile app

The same hosted checkout, opened in the phone's own secure browser sheet. Your app never holds a key and never sees a card number; the checkout comes back to your app through its own URL scheme.

  1. 1

    Give your app a URL scheme

    Pick something unlikely to clash, such as myshop. Expo: "scheme" in app.json. iOS with ASWebAuthenticationSession needs nothing more. Android: an intent filter for the scheme on the activity that starts the payment.

  2. 2

    Create the checkout on your server

    Your app asks your server, which prices the order and sets both return URLs to your scheme.

    app.post("/app/checkouts", requireSignedInCustomer, async (req, res) => {
      const order = await orders.forCustomer(req.user.id, req.body.orderId);
    
      const checkout = await lk.checkouts.create(
        {
          amountCents: order.totalCents,
          description: order.summary,
          reference: order.id,
          successUrl: "myshop://payments-lk/return", // your app's own scheme
          cancelUrl: "myshop://payments-lk/return",
        },
        { idempotencyKey: "order-" + order.id },
      );
    
      res.json({ id: checkout.id, url: checkout.url });
    });
  3. 3

    Open it from the app

    React Native, with expo-web-browser for the sheet:

    import * as WebBrowser from "expo-web-browser";
    import { openCheckout } from "@payments-lk/react-native";
    
    const checkout = await api.post("/app/checkouts", { orderId });
    const result = await openCheckout({ checkoutUrl: checkout.url, returnUrl: "myshop://payments-lk/return", browser: WebBrowser });
    
    if (result.status === "succeeded") showConfirming(); // then ask your server
  4. 4

    Or in Flutter

    One package for both: the authentication sheet on iOS, a Custom Tab on Android. Android apps declare the scheme for its callback activity; the SDK page shows how.

    import 'package:payments_lk/payments_lk.dart';
    
    final result = await PaymentsLkCheckout().open(checkoutUrl: created.url, callbackScheme: 'myshop');
    if (result.status == CheckoutStatus.succeeded) showConfirming(); // then ask your server
  5. 5

    Or natively on iOS

    Swift package, built on ASWebAuthenticationSession.

    import PaymentsLk
    
    let result = try await PaymentsLkCheckout().present(checkoutURL: created.url, callbackScheme: "myshop")
    if result.status == .succeeded { showConfirming() } // then ask your server
  6. 6

    Or natively on Android

    Kotlin library, built on Custom Tabs. Call launcher.onResume() in onResume to hear when the customer closes the tab.

    private val launcher = CheckoutLauncher(returnScheme = "myshop")
    
    fun pay(checkoutUrl: String) = launcher.launch(this, checkoutUrl)
    
    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        launcher.handleRedirect(intent)?.let(::show) // then ask your server
    }
  7. 7

    Confirm on your server, then show the result

    The app gets succeeded, failed, canceled, expired or dismissed. Show a confirming screen and ask your server, which trusts only the webhook or the API.

    app.get("/app/orders/:id", requireSignedInCustomer, async (req, res) => {
      const order = await orders.forCustomer(req.user.id, req.params.id);
      // Paid only once your webhook has recorded payment.succeeded, or:
      const checkout = await lk.checkouts.retrieve(order.checkoutId);
      res.json({ paid: checkout.payment.status === "succeeded" });
    });

Card on file

Cards on file: save once, charge later

The customer pays once on the hosted checkout and chooses to keep their card. You get a card id, never the card number, and charge it later without the customer present.

  1. 1

    Ask to keep the card at a checkout

    Set saveCard and send the customer's details. The checkout shows a choice to keep the card; nothing is kept unless the customer ticks it. Works from a website or a mobile app.

    const checkout = await lk.checkouts.create({
      amountCents: 250000,
      description: "First month of your coffee subscription",
      reference: "sub-" + customer.id,
      customer: { name: customer.name, email: customer.email },
      saveCard: true, // the checkout asks the customer if they want to keep the card
      successUrl: "https://yourshop.lk/account",
      cancelUrl: "https://yourshop.lk/account",
    });
  2. 2

    Store the card id from card.saved

    After the first payment succeeds you receive card.saved. Keep its id against your customer. It is not a card number and is useless outside your account. If the payment succeeds but the processor does not keep the card, you receive card.save_failed instead, with the payment: nothing is on file, so ask the customer to save the card again at a new checkout.

    if (event.type === "card.saved") {
      // event.data is the saved card:
      //   { id: "card_...", customerEmail, scheme, last4, mode, active,
      //     expiry: { month, year },      // when the card itself runs out
      //     paymentId,                    // the payment the customer made when they kept it
      //     consent: { agreedAt, locale, wording } }  // what they agreed to
      await customers.rememberCard(event.data.customerEmail, event.data.id);
    }
    if (event.type === "card.save_failed") {
      // The payment went through, but the card was not kept. event.data is the payment (cardSave: "failed").
      await customers.askToSaveCardAgain(event.data.customer?.email);
    }
  3. 3

    Keep the consent with the card

    The card carries what the customer agreed to: when they ticked the box, the language they were reading and which wording they read, such as checkoutPage.saveCard@1. It is your record of why you may charge them, so keep it beside your own note of what they signed up for. It stays readable after the card is deleted.

  4. 4

    Charge the card later

    Only charge what the customer agreed to. Use one idempotency key per billing period so a retry never charges twice; the window is 24 hours, so a job that retries later than that should read the payment first. The answer is usually processing, and the outcome arrives as payment.succeeded or payment.failed, with savedCardId on the payment.

    const payment = await lk.cards.charge(
      customer.cardId,
      { amountCents: 250000, description: "October coffee subscription", reference: "sub-" + customer.id + "-2026-10" },
      { idempotencyKey: "sub-" + customer.id + "-2026-10" }, // one charge per billing period, however often you retry
    );
    // Live charges answer by webhook: wait for payment.succeeded or payment.failed.
  5. 5

    Only the first payment asks the cardholder to authenticate

    The customer is present for the first payment and passes their bank's check there. A later charge is made without them, so nobody can be asked to authenticate: a bank that insists declines it, and the answer is payment.failed. Take the customer back to a checkout to save the card again when that happens.

  6. 6

    Remove a card, or list them

    Delete the card when the customer asks or cancels. It stops working at once and its token is deleted at the processor; deleting a card that is already deleted answers the same card, so a retry is safe. Lists take limit, cursor and customerEmail.

    await lk.cards.delete(customer.cardId);  // when the customer removes it or cancels
    
    // One page of a customer's cards, or every one of them.
    const page = await lk.cards.list({ customerEmail: customer.email, limit: 25 });
    for await (const card of lk.cards.listAll({ customerEmail: customer.email })) {
      if (card.expiry) console.log(card.id, card.scheme, card.last4, card.expiry.month + "/" + card.expiry.year);
    }
    
    // A card you deleted still reads, so you can say why a charge stopped.
    const gone = await lk.cards.retrieve(customer.cardId);  // gone.retiredAt, gone.consent
  7. 7

    Watch the expiry, and card_not_reusable

    A card carries the month it runs out: a charge after that is declined, so ask the customer for a new card before then. A charge refused with reason card_not_reusable is permanent, never a retry: the card has nothing behind it any more, so send the customer to a new checkout to save one.

  8. 8

    Saved cards need Payable's Advanced plan

    Keeping a card at checkout, and charging, listing or removing it later, use Payable's business key APIs, which only its Advanced plan opens, in the sandbox as well as live. One-time checkouts and payment links work on every plan. For live, switch saved cards on in the dashboard under Business settings; it moves your account to the Advanced plan, and the dashboard shows what that costs before you confirm. For the sandbox, ask us: a sandbox account starts on the Standard plan like a live one.

Subscriptions

Subscriptions: we bill every period

You make the prices and start the subscription. We keep the card, renew every period, retry a decline, email the customer, and send you the events. Customers manage their own plan in the customer portal.

  1. 1

    Make your prices

    A price is an amount every day, week, month or year on a product. Make them once, from a script or on the dashboard's Subscriptions page, which also takes tiered, per package and metered prices.

    // Once, from a script or on the dashboard's Subscriptions page. Keep the ids in your config.
    const monthly = await lk.prices.create({ product: { name: "Coffee club" }, unitAmountCents: 250000, interval: "month" });
    const yearly = await lk.prices.create({ productId: monthly.product.id, unitAmountCents: 2500000, interval: "year" });
  2. 2

    Start a subscription when the customer presses the button

    Your endpoint makes the subscription and answers its checkoutUrl, where the customer pays the first period and agrees to keep the card for the renewals. An answer that is not ok shows its message instead. For a free trial, use a subscription link (two steps on): a trial started from the API has no checkout, and its first invoice is emailed to the customer when it ends. To bill everyone on the 1st, send billingCycleAnchor first_of_month (or make it your default in the billing settings): the first payment is for the part month up to the 1st.

    app.post("/subscribe", requireSignedInCustomer, async (req, res) => {
      const member = await members.find(req.user.id);
      if (member.subscriptionId) return res.status(409).json({ message: "You are already in the club." });
    
      // Found by email if you made them before, made otherwise.
      const customer = await lk.customers.create({ email: req.user.email, name: req.user.name });
      const subscription = await lk.subscriptions.create(
        {
          customerId: customer.id,
          priceId: req.body.plan === "yearly" ? config.yearlyPriceId : config.monthlyPriceId,
          successUrl: "https://yourshop.lk/account",
          cancelUrl: "https://yourshop.lk/club",
        },
        { idempotencyKey: "club-" + req.user.id }, // a double click never starts two
      );
      await members.remember(req.user.id, customer.id, subscription.id);
    
      // A customer with a card already on file is charged at once, with nowhere to send them.
      if (!subscription.checkoutUrl) return res.status(409).json({ message: "You are subscribed. See your account." });
      res.json({ url: subscription.checkoutUrl });
    });
  3. 3

    Put the pay button on your page

    The same pay button as a one-time checkout: it posts to your endpoint and sends the customer to the url it answers. data-payments-lk-error-target="#club-error" shows a refusal where you want it.

    <button data-payments-lk-checkout="/subscribe" data-payments-lk-body='{"plan":"monthly"}'>
      Join the club, Rs. 2,500 a month
    </button>
    <button data-payments-lk-checkout="/subscribe" data-payments-lk-body='{"plan":"yearly"}'>
      Or Rs. 25,000 a year
    </button>
    <p id="club-error" role="alert"></p>
    <script src="https://payments.lk/js/pay.js" defer></script>
  4. 4

    Or share a subscription link, with no server at all

    A subscription link is a page on payments.lk where anyone chooses one of its prices, gives their email and subscribes. With a trial, the customer's card is checked and kept, and nothing is charged until the trial ends. Make one here or on the dashboard, and share its url. You learn about each new subscriber from the webhooks.

    // Monthly and yearly side by side, a 14 day trial, and a box for coupon codes.
    const link = await lk.subscriptionLinks.create({
      name: "Join the coffee club",
      priceIds: [config.monthlyPriceId, config.yearlyPriceId],
      trialDays: 14,
      allowPromotionCodes: true,
      successUrl: "https://yourshop.lk/welcome",
    });
    // link.url is https://payments.lk/s/...: put it on a page, in an email or behind a QR code.
  5. 5

    Put the plans on your own page, still with no server

    The same link, framed on your page: pay.js turns a data-payments-lk-plans element into the link's plan chooser and sizes it to fit, and a data-payments-lk-subscribe button opens the link with one plan chosen first (data-payments-lk-email fills the customer's email in). The customer pays the first period on the hosted checkout in the whole window, never inside the frame. Add data-payments-lk-lang="si" or "ta" for the language. The dashboard's Integrate panel on each plan writes both snippets, with the link's QR image beside them.

    <!-- The link's whole plan chooser, on your page, sized to fit -->
    <div data-payments-lk-plans="LINKCODE"></div>
    
    <!-- Or a button that opens the link with one plan chosen first -->
    <button data-payments-lk-subscribe="LINKCODE" data-payments-lk-plan="price_0123456789abcdefghij">Join the club</button>
    
    <script src="https://payments.lk/js/pay.js" defer></script>
  6. 6

    Keep your records from the webhooks

    Give access from the subscription's status, not from the return to successUrl. Each event is signed and may arrive more than once, so make every handler safe to run twice. If the first payment succeeds but the card is not kept, you receive card.save_failed and the subscription shows cardMissingSince: it stays active, and its renewals are emailed to the customer to pay, with a link to add a card, until one is kept.

    switch (event.type) {
      case "customer.subscription.created":
      case "customer.subscription.updated":
      case "customer.subscription.deleted":
        // event.data is the subscription: status is trialing, active, past_due, unpaid, paused or canceled.
        await members.syncStatus(event.data.customerId, event.data.id, event.data.status, event.data.currentPeriodEnd);
        break;
      case "invoice.paid":
        await receipts.record(event.data.customerId, event.data.number, event.data.amountPaidCents);
        break;
      case "invoice.payment_failed":
        // We retry on days 1, 3, 7 and 14 and email the customer a page to pay with a new card.
        await members.flagPaymentProblem(event.data.customerId);
        break;
      case "entitlements.active_entitlement_summary.updated":
        // Every feature the customer has now, whenever that changes.
        await members.setFeatures(event.data.customerId, event.data.entitlements.map((e) => e.lookupKey));
        break;
    }
  7. 7

    Let customers manage it themselves

    The customer portal shows their subscriptions and invoices and takes a new card. As your billing settings allow, customers also switch plan, change the quantity, add a promotion code, pause payments for a few months, change their name, mobile and language, or cancel, with a coupon you choose offered once before they do. Open it from a Manage subscription button on your account page.

    // The portal's address is on payments.lk, so the same pay button opens it.
    app.post("/manage-subscription", requireSignedInCustomer, async (req, res) => {
      const member = await members.find(req.user.id);
      const session = await lk.billingPortal.sessions.create({ customerId: member.customerId, returnUrl: "https://yourshop.lk/account" });
      res.json({ url: session.url }); // it works for an hour: make a new one each time
    });
  8. 8

    Try renewals, trials and retries in the sandbox

    A test clock runs one sandbox subscription's time ahead, so you see a renewal, a trial ending or a retry in minutes. Test cards from the developers page approve or decline.

    // Sandbox only: run a test subscription 31 days ahead and watch the renewal happen.
    await lk.testClocks.advance({ subscriptionId: subscription.id, seconds: 31 * 86400 });
    const next = await lk.invoices.upcoming({ subscriptionId: subscription.id });
  9. 9

    Live subscriptions need saved cards

    Renewals charge the card the customer kept, so live subscriptions need saved cards switched on, on Payable's Advanced plan, as the card on file path does. A subscription sent as an invoice to pay by a due date (collectionMethod send_invoice) charges no saved card.

Retries and idempotency keys

Every write takes an Idempotency-Key header. Make it from your own record, such as order- and your order id, so a retry after a timeout sends the same key. Within 24 hours, the same key with the same body gets the first answer back, with the header Idempotent-Replayed: true, and nothing is created twice. The same key with a different body is refused, with the reason idempotency_key_reused.

Keys are kept apart for test and live keys, so the key an order used in the sandbox makes a new live object when you go live. After 24 hours a key counts as new, so never reuse one for a different order.

// A timeout does not tell you whether the checkout was made. Send the same request again,
// with the same key: within 24 hours you get the first answer back, never a second checkout.
const checkout = await lk.checkouts.create(
  { amountCents: order.totalCents, description: order.summary, reference: order.id },
  { idempotencyKey: "order-" + order.id },
);

When a request is refused

Every error has a code, a reason when there is a more precise one, a sentence you can show, a docUrl that explains it, and a traceId to quote to us. Match on the code and the reason, never on the sentence. Retry only what time can change: a 429, a 500, a 503 and idempotency_key_in_progress, each with the same Idempotency-Key. Anything else needs a change to the request first.

Every error and how to fix it
{
  "code": "CONFLICT",
  "reason": "merchant_not_live",
  "message": "Live payments open once the account is approved and activated.",
  "docUrl": "https://payments.lk/developers/errors#merchant_not_live",
  "traceId": "4f1c2d7e-9a0b-4c3d-8e5f-6a7b8c9d0e1f"
}

No SDK for your language?

Everything above is plain HTTPS and JSON. Send your secret key as a bearer token, the header Content-Type: application/json, and an Idempotency-Key on every write. The full list of endpoints and events is in the API reference.

curl https://api.payments.lk/v1/checkouts \
  -H "Authorization: Bearer sk_test_..." \
  -H "Idempotency-Key: order-8891" \
  -H "Content-Type: application/json" \
  -d '{"amountCents": 145000, "description": "Chicken kottu", "successUrl": "https://yourshop.lk/thanks"}'
Developer guide · Payments.lk