npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

linkpay-partner

v0.1.0

Published

Typed client for the LinkPay partner API: rides, collections and webhooks

Readme

linkpay-partner

Typed Node.js client for the LinkPay partner API: rides (drivers, riders, charges, refunds, settlements), collections (deposits, payments, settlements) and webhooks.

  • One key selects the mode: lpk_test_… talks to the sandbox, lpk_live_… moves real money. Same base URL, same code.
  • Every method returns a Promise and rejects with a typed error you can branch on (err.code).
  • Retries only what is safe to retry: rate limits, and connection failures of reads or of writes that carry an idempotency key. A charge without a key is never sent twice.
  • Amounts are integers in kobo everywhere. naira(2500) gives you 250000.

Node 18 or newer. ESM and CommonJS.

npm install linkpay-partner

Quick start

import { LinkPay, naira } from "linkpay-partner";

const linkpay = new LinkPay(process.env.LINKPAY_API_KEY!); // lpk_test_… or lpk_live_…

// 1. Give a driver a LinkPay account (idempotent on driverId).
await linkpay.drivers.create({
  driverId: "drv_1042",
  firstName: "Ada",
  lastName: "Obi",
  email: "[email protected]",
  phone: "08012345678",
});

// 2. Ask a rider to link. Show them approvalUrl, or send it.
const request = await linkpay.customers.requestLink({
  customerId: "usr_88",
  phone: "08098765432",
  perChargeCapKobo: naira(5000),
  dailyCapKobo: naira(20000),
  returnUrl: "https://yourapp.example/linked",
});
console.log(request.approvalUrl);

// 3. Charge the rider when the trip ends. Use your trip id as the key so a
//    retry after a crash replays the same charge instead of taking it twice.
const charge = await linkpay.charges.create({
  customerId: "usr_88",
  driverId: "drv_1042",
  amountKobo: naira(2500),
  commissionKobo: naira(250),
  tripRef: "trip_5510",
  idempotencyKey: "trip:trip_5510",
});

switch (charge.status) {
  case "succeeded":
  case "processing":
    break; // the fare is on its way to the driver
  case "requires_approval":
    // above the rider's caps: they approve in the app, you get charge.succeeded or charge.failed
    break;
  case "failed":
    console.log(charge.failureCode, charge.failureMessage);
}

The client

const linkpay = new LinkPay("lpk_test_…");
// or
const linkpay = new LinkPay({
  apiKey: "lpk_test_…",
  timeoutMs: 30_000, // per request, default 30 s
  maxAttempts: 3,    // on a 429 or a connection failure of a repeatable request
  baseUrl: "…",      // only for a staging deployment
});

linkpay.livemode; // false with a test key

| Namespace | What it does | | --- | --- | | linkpay.me() | Your configuration: limits, caps, products, settlement account | | linkpay.drivers | create, get, remove, requestLink | | linkpay.customers | requestLink, getLink, revokeLink | | linkpay.linkRequests | get (poll a request) | | linkpay.charges | create, list, iterate, get, cancel | | linkpay.refunds | create(chargeId, …), list(chargeId), get | | linkpay.settlements | list, get(day) | | linkpay.collections | account(), deposits.*, payments.*, settlements.* | | linkpay.test | Sandbox controls, test keys only | | linkpay.webhooks | constructEvent, verify |

Rides

Linking

Riders and drivers link once. A link request gives you an approvalUrl; the person opens it in LinkPay and decides. You learn the outcome by webhook (customer_link.approved, customer_link.declined, driver.linked) or by polling:

const req = await linkpay.linkRequests.get(request.requestId);
// req.status: "pending" | "approved" | "declined" | "expired" | "cancelled"

A rider's caps decide what runs without them: a charge at or under perChargeCapKobo that keeps the day under dailyCapKobo runs immediately; anything else comes back requires_approval and waits for the rider in the app until approvalExpiresAt.

const link = await linkpay.customers.getLink("usr_88");   // the active link, else the last revoked one
await linkpay.customers.revokeLink("usr_88");             // from your side; pending approvals are cancelled

Charges

const charge = await linkpay.charges.create({ … });        // see the quick start
const same = await linkpay.charges.get("trip:trip_5510");  // by chargeId or by your idempotencyKey
await linkpay.charges.cancel(charge.chargeId);              // only while requires_approval

const page = await linkpay.charges.list({ driverId: "drv_1042", status: "succeeded", limit: 50 });
// page.charges, page.nextBefore (pass back as `before`)

for await (const c of linkpay.charges.iterate({ customerId: "usr_88" })) {
  // every matching charge, newest first, across pages
}

charge.replayed is true when the API answered with an earlier charge for the same idempotency key, which is what you want after a retry.

Refunds

const refund = await linkpay.refunds.create(charge.chargeId, {
  amountKobo: naira(1000),     // at most charge.refundableKobo
  reason: "Trip cut short",
  idempotencyKey: "refund:trip_5510:1",
});
const refunds = await linkpay.refunds.list(charge.chargeId);

A refund returns money to the rider from the driver's account, and gives back the matching share of your commission. Watch refund.succeeded and refund.failed.

Commission settlements

Your commission is held from each fare and paid to your settlement account once a day (your sweepTimeLagos), one item per driver.

const recent = await linkpay.settlements.list({ limit: 7 });   // day, status, totals
const day = await linkpay.settlements.get("2026-09-21");        // with per-driver items

Collections

Customers pay into your one fixed collection account. You ask LinkPay for a deposit; it answers with an exact whole-naira amount that is unique among your open deposits. When that exact amount lands, the deposit completes and you get deposit.completed. Show the amount exactly as given: the odd naira is the match key.

const deposit = await linkpay.collections.deposits.create({
  reference: "inv_2031",          // your id; also the idempotency key
  customerId: "usr_88",
  amountKobo: naira(5000),
  customerName: "Ada Obi",        // optional, for payerNameMatch
});

deposit.amountKobo;       // e.g. 500700: tell the customer to send exactly ₦5,007
deposit.account;          // { accountNumber, bankName, accountName }
deposit.expiresAt;        // a payment up to 24 h later still matches, flagged late
deposit.instructions;     // a ready-made sentence in Lagos time

Money that matches nothing arrives as payment.unmatched. A person decides, with a hint when exactly one deposit fits:

const payment = await linkpay.collections.payments.get(paymentId);
if (payment.suggestion) {
  await linkpay.collections.payments.assign(payment.paymentId, {
    depositId: payment.suggestion.depositId,
    note: `Suggested: ${payment.suggestion.reason}`,
  });
}

Everything received in a Lagos day, minus the fee, is paid to your settlement account after the cutoff:

const account = await linkpay.collections.account();        // account, feeBps, feeCapKobo, deposit limits
const days = await linkpay.collections.settlements.list();   // newest first
const one = await linkpay.collections.settlements.get("2026-09-21");

Webhooks

LinkPay POSTs JSON to your webhook URL, signed with your webhook secret. Verify against the raw request bytes, before any JSON middleware, then act on the typed event. Answer 2xx quickly; deliveries retry for about 42 hours.

import express from "express";
import { LinkPay, LinkPayWebhookError } from "linkpay-partner";

const linkpay = new LinkPay(process.env.LINKPAY_API_KEY!);
const app = express();

app.post("/webhooks/linkpay", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = linkpay.webhooks.constructEvent(req.body, req.headers["x-linkpay-signature"], process.env.LINKPAY_WEBHOOK_SECRET!);
  } catch (err) {
    if (err instanceof LinkPayWebhookError) return res.status(400).send(err.message);
    throw err;
  }

  switch (event.type) {
    case "charge.succeeded":
      // event.data is a Charge
      break;
    case "charge.failed":
      // failed, or cancelled by the rider, an expired approval or a revoked link
      break;
    case "customer_link.approved":
      // event.data is the Link plus the requestId
      break;
    case "deposit.completed":
      // event.data is a Deposit; credit event.data.payment.amountKobo
      break;
    case "payment.unmatched":
      // someone paid an amount no deposit was waiting for
      break;
  }
  res.sendStatus(200);
});

Deliveries can repeat. Key your handling on event.id, or on the object's own id and status, so a repeat is a no-op.

The 16 event types are exported as EVENT_TYPES, and LinkPayEvent is the union, so a switch on event.type narrows event.data for you. Test-mode events arrive with livemode: false at your test webhook URL.

Errors

Every failure is one of four classes. Branch on code, never on the message.

import { LinkPayError, LinkPayValidationError, LinkPayConnectionError, LinkPayUsageError } from "linkpay-partner";

try {
  await linkpay.charges.create({ … });
} catch (err) {
  if (err instanceof LinkPayValidationError) {
    err.errors;            // [{ path: "amountKobo", message: "…" }]
  } else if (err instanceof LinkPayError) {
    err.code;              // "CUSTOMER_NOT_LINKED", "INSUFFICIENT_FUNDS", "RATE_LIMITED", …
    err.status;            // HTTP status
    err.retryAfter;        // seconds, on RATE_LIMITED
    err.requestId;         // quote this when you write to support
  } else if (err instanceof LinkPayConnectionError) {
    // no answer at all (DNS, timeout). A charge WITH an idempotency key was retried already;
    // one without a key was not, so read it back by key before trying again.
  } else if (err instanceof LinkPayUsageError) {
    // a bad key, a test-only call with a live key, an id the API would refuse
  }
}

A charge that fails at the bank does not throw: it comes back with status: "failed" and a failureCode. Failure codes are typed as the known values plus string, since new ones appear without notice.

Retries and idempotency

  • 429 answers are retried after Retry-After, up to maxAttempts.
  • Connection failures are retried only for reads, deletes, and POSTs carrying an idempotency key.
  • charges.create and refunds.create always send one: yours if given, else a fresh UUID for that call. Give your own, built from your trip or order id, so a retry from your side after a crash replays instead of charging twice.
  • collections.deposits.create is idempotent on reference.

Idempotency keys must be 8 to 100 characters. Ids you choose (driverId, customerId, reference) use letters, digits and _ . : -, up to 64 characters; the SDK checks this before sending.

Test mode

With an lpk_test_ key nothing real moves, and linkpay.test lets you play the other side. Each of these refuses a live key before sending anything.

const req = await linkpay.customers.requestLink({ customerId: "rider_1", phone: "08000000001" });
await linkpay.test.approveLinkRequest(req.requestId, { perChargeCapKobo: naira(3000) });

await linkpay.test.setBalance({ customerId: "rider_1" }, naira(100));
const charge = await linkpay.charges.create({ customerId: "rider_1", driverId: "drv_1", amountKobo: naira(500), commissionKobo: naira(50) });
// charge.status === "failed", charge.failureCode === "INSUFFICIENT_FUNDS"

const big = await linkpay.charges.create({ customerId: "rider_1", driverId: "drv_1", amountKobo: naira(4000), commissionKobo: naira(400) });
await linkpay.test.approveCharge(big.chargeId);          // or declineCharge
await linkpay.test.runSettlement();                      // today's commission sweep, now

await linkpay.test.collections.simulatePayment({ amountKobo: deposit.amountKobo, payerName: "Ada Obi" });
await linkpay.test.collections.runSettlement();

await linkpay.test.deleteAllData();                      // wipe this partner's test objects

Money helpers

import { naira, toNaira, formatKobo } from "linkpay-partner";
naira(2500);          // 250000
toNaira(250000);      // 2500
formatKobo(250050);   // "₦2,500.50"

Types

Every API object is exported: Partner, Driver, Link, LinkRequest, Charge, Refund, Settlement, SettlementItem, CollectionsAccount, Deposit, Payment, CollectionsSettlement, the event payloads, and the input type of each method. They are generated from the API contract in scripts/contract.json; regenerate with:

python3 scripts/gen-types.py scripts

Anything the SDK does not cover

linkpay.http.request({ method, path, query, body, idempotencyKey }) calls any partner route with the same auth, retries and error handling.