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 you250000.
Node 18 or newer. ESM and CommonJS.
npm install linkpay-partnerQuick 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 cancelledCharges
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 itemsCollections
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 timeMoney 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 tomaxAttempts. - Connection failures are retried only for reads, deletes, and POSTs carrying an idempotency key.
charges.createandrefunds.createalways 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.createis idempotent onreference.
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 objectsMoney 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 scriptsAnything 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.
