@tap2pay/server
v0.1.3
Published
Tap2Pay server SDK — Tap2Access (entitlements, credentials, access events) and Tap2Pay (plans, subscriptions, payments, wallets) for merchant backends.
Maintainers
Readme
@tap2pay/server
The official Tap2Pay server SDK for merchant backends (Node 18+, TypeScript, zero runtime dependencies). It wraps the Tap2Pay Platform API — the API is the platform; this SDK just makes it pleasant to consume.
Two product domains, one client:
- Tap2Access — people, credentials (cards/phones), entitlements, access events. "May this credential act here, now?"
- Tap2Pay — plans, subscriptions, payments, wallets, settlements. "Can this transaction settle?"
Conventions: money is integer kobo (1_000_000 = ₦10,000). People are
addressable by your own IDs — anywhere a person_id/personRef appears,
your external_ref works.
Install & initialize
npm install @tap2pay/serverimport { Tap2Pay } from "@tap2pay/server";
const t2p = new Tap2Pay({
apiKey: process.env.TAP2PAY_API_KEY!, // t2p_test_… or t2p_live_…
baseUrl: "https://api.tap2pay.example", // omit for local dev (:8090)
});CommonJS works too — the package ships both builds:
const { Tap2Pay } = require("@tap2pay/server");Test vs live: signup gives you a t2p_test_… key immediately — a full
sandbox where payments use the sandbox processor. t2p_live_… keys unlock
after KYC approval. The key decides the mode; the client is identical, so
going live is swapping one env var.
Quickstart: subscriptions on the cards you issued
Tap2Pay issues NFC cards (or app credentials) to your customers. You define a plan once; subscribing a customer charges them through the payment processor and keeps their card working — renewals, expiry, and access all handled by the platform. (This is the Learn to Earn model.)
// One-time: register the customer and bind their issued card
await t2p.people.create({ full_name: "Amina Yusuf", external_ref: "STU-2026-0700" });
await t2p.credentials.createCard("STU-2026-0700", "04:C9:33:E2");
// One-time: define the plan (₦10,000/month for the Hub Access service)
const plan = await t2p.plans.create({
name: "Hub Monthly",
service_id: "SVC_…",
amount: 1_000_000,
});
// Subscribe — first charge now, entitlement live immediately, auto-renews
const { payment, effects } = await t2p.subscriptions.create({
person_id: "STU-2026-0700",
plan_id: plan.id,
});
// payment.status === "APPROVED"
// effects.entitlement.valid_until → one month out
// The card now opens every door the service covers.
// Later: stop renewals (access runs out at period end on its own)
await t2p.subscriptions.cancel(effects.subscription!.id);Managing entitlements directly (customer-managed billing)
If money settles outside Tap2Pay (e.g. payroll deduction), sync entitlement facts instead — PERIOD grants upsert in place, so re-sending state is safe:
await t2p.entitlements.create({
person_id: "STU-2026-0700",
service_id: "SVC_…",
valid_until: "2026-09-30T23:00:00Z",
source: "API",
source_ref: "YOUR-SETTLEMENT-REF",
});
// Monthly cycle, up to 1000 at once:
await t2p.entitlements.bulk(students.map((s) => ({
person_id: s.id, service_id: HUB, valid_until: monthEnd, source: "API",
})));Webhooks
import { constructEvent, isAccessEvent, WebhookVerificationError } from "@tap2pay/server";
// One-time: subscribe (the secret is returned once — store it)
const endpoint = await t2p.webhookEndpoints.create({
url: "https://your-backend/webhooks/tap2pay",
event_types: ["access.granted", "access.denied", "entitlement.expired"],
});
// In your webhook route (use the RAW request body):
app.post("/webhooks/tap2pay", (req, res) => {
try {
const event = constructEvent(process.env.TAP2PAY_WEBHOOK_SECRET!, req.rawBody, {
signature: req.headers["x-tap2pay-signature"] as string,
timestamp: req.headers["x-tap2pay-timestamp"] as string,
});
// isAccessEvent types event.data as AccessEventPayload and covers both
// decisions — a refused tap is worth recording too.
if (isAccessEvent(event)) {
// person_ref is YOUR identifier for this person, so you never need a
// person_id -> your-id table. service_name is set on denies as well.
await recordTap(event.data.person_ref, event.data.decision, event.data.service_name);
}
res.sendStatus(204);
} catch (e) {
if (e instanceof WebhookVerificationError) return res.sendStatus(401);
throw e;
}
});Deliveries retry with backoff and may arrive more than once — key on event.id.
Answer 2xx first and do the work after, or a slow handler turns into a retry
storm of duplicates.
Live event stream
For a dashboard or a local tail -f of what the platform is doing, iterate the
event feed directly — no public URL to expose, no endpoint to register:
const ctl = new AbortController();
for await (const ev of t2p.events.stream({ signal: ctl.signal })) {
console.log(ev.occurred_at, ev.type, ev.data);
if (ev.type === "access.denied") alertTheFrontDesk(ev.data);
}
// ctl.abort() — or `break` — closes the connection.The stream has no cursor: anything published while you were disconnected is gone. It is a tail, not a delivery guarantee — keep webhooks for anything you bill, reconcile or audit on.
Everything else
t2p.people // create · get · list · suspend · activate
t2p.credentials // createCard · list · setStatus · createMobileActivation
t2p.services // create · list · setResources
t2p.resources // create · list
t2p.devices // enroll (secret shown once) · list
t2p.entitlements // create · bulk · list · revoke
t2p.accessEvents // list (since / person / limit)
t2p.plans // create · list · archive
t2p.subscriptions // create · list · cancel
t2p.payments // create · list · refund
t2p.wallets // get · credit (counter top-up) · customers · org
t2p.charges // refund (closed-loop terminal charges)
t2p.settlements // create (payout) · list · getSchedule · setSchedule · setPayoutDestination
t2p.reports // reconciliation · dashboard
t2p.setup // get (activation state, derived from live data)
t2p.pricing // get (your plan) · fees (what you were charged)
t2p.disputes // list · summary · get · submitEvidence
t2p.notifications // list · unreadCount · markRead · markAllRead · channels.*
t2p.apiKeys // list · create · revoke
t2p.staff // list · create · setRole · remove
t2p.webhookEndpoints// create · list
t2p.events // stream (live SSE tail)t2p.credentials.listAll() is the whole card fleet with balances and last-used
times; t2p.wallets.customers({ below }) finds customers about to be turned
away at a gate for an empty balance.
Notification preferences are deliberately absent — they are per-user email settings, and an API key has no user.
t2p.staff.create() provisions a member outright, password and all — for
seeding an operator or a reporting account from a script. A human joining the
team should come in through the dashboard's invite flow instead, so they set
their own password and enrol their own 2FA.
The two flows
A card does one of two things. Both run on the same card and the same terminal.
Access by subscription — the customer holds a standing entitlement and a tap just opens:
const svc = await t2p.services.create({ name: "Gym", entitlement_type: "PERIOD" });
const plan = await t2p.plans.create({ name: "Gym Monthly", service_id: svc.id, amount: 500000 });
await t2p.subscriptions.create({ person_id: "MEM-0042", plan_id: plan.id });Pay on the go — the customer holds a balance and each tap costs money:
const svc = await t2p.services.create({ name: "Canteen", entitlement_type: "METERED", fee: 25000 });
await t2p.wallets.credit({ person_id: "MEM-0042", amount: 500000, reference: `topup-${Date.now()}` });reference is the idempotency key and must be unique — that is what stops a
retry crediting twice. Customers can also top up themselves in the Tap2Pay app.
t2p.setup.get() returns what is built so far and what to do next, derived
from live counts — useful for driving your own onboarding UI.
Money you hold vs money you have earned
wallets.customers() reports float: money customers loaded and have not yet
spent. It is a liability, not revenue — it becomes yours as they tap.
wallets.org() and reports.dashboard() report settleable revenue. The two are
different numbers and the SDK never mixes them.
Errors
All failures throw Tap2PayError with status, code, and message:
try {
await t2p.subscriptions.create({ person_id: ref, plan_id });
} catch (e) {
if (e instanceof Tap2PayError && e.status === 402) {
// processor declined the first charge
}
}Runnable example
examples/l2e-flow.mjs runs the whole quickstart against a live platform, and
examples/sdk-smoke.mjs exercises every read namespace at once:
npm run build
TAP2PAY_API_KEY=t2p_live_… node examples/l2e-flow.mjs
T2P_KEY=t2p_test_… node examples/sdk-smoke.mjsThe full REST reference (device protocol, mobile enrollment, admin plane)
lives at /docs on any platform instance.
