2026-09-16
Node.js SDK for Crypto Payments: What to Look For

From the browser, a crypto checkout looks almost boring: show an amount, show an address, wait for a green checkmark. All the interesting failure modes live on the backend, and that's exactly where a mediocre SDK will let you down without ever throwing an error — it'll just quietly hand you a paid: true and let you find out six months later, during a support escalation, that "paid" meant five different things depending on the day.
The question worth asking before you install anything isn't "does this SDK have TypeScript types." It's "who actually holds the money while my customer's transaction is in flight, and does this thing tell me the truth about what happened on-chain." Everything else — recipient splits, webhooks, sandbox tooling — is downstream of that one decision.
Start with custody, because it decides everything downstream
A surprising number of "crypto payment" SDKs are custodial processors wearing a developer-friendly API. The customer's funds land in an address the provider controls, the provider updates an internal ledger, and your application eventually gets told it can have its money — on the provider's schedule, sometimes with a review step in between. That's not a criticism of the model in the abstract; it's just worth naming, because it means your reconciliation, your payouts, and your support tooling are all built on top of someone else's balance sheet, not the chain itself.
A non-custodial model looks different from the first line of code. Charges resolve to deterministic addresses your recipients actually control, computed before the charge is even created. The SDK's job shrinks to something more honest: watch the chain, classify what it sees, tell you. It never needs to touch the money to do that job. Your backend authorizes business actions once it has evidence it can independently check — not because a dashboard says so.
The second thing worth checking, and the one people skip because it's less exciting than custody, is whether the SDK models a full payment lifecycle or just a boolean. A real charge rarely moves from "created" to "paid" in one clean step. Customers abandon checkout, send the wrong amount, pick an unsupported token, or show up two minutes after the expiry window closed. If an SDK's answer to all of that is charge.paid, you're going to end up writing your own state machine anyway — just without the vendor's help.
Look at what the lifecycle actually models
Here's the real shape, from @klappay/node's own types, not a simplified version for a blog post: a charge's status is one of exactly five values — pending, partially_paid, confirmed, expired, underpaid. pending and partially_paid are still open for payment; the other three are terminal. That's it. No invented in-between states to reverse-engineer from a webhook payload.
What trips people up is treating "confirmed" and "paid out to me" as the same moment. They're not, and the SDK is explicit about the difference: confirmed means a qualifying transfer landed on-chain and matched the charge. A separate field, settlementStatus (pending / completed / failed), tracks whether the split payout actually reached the recipients — that's the charge.settled webhook event, and it's a genuinely later, separately monitored step. For a $12 digital download, charge.confirmed is probably all the certainty you need to fulfill. For a five-figure invoice, you might wait for charge.settled instead. The point is the SDK gives you both signals instead of collapsing them into one.
There's a third layer underneath both of those: a charge's timeline. klap.charges.getTimeline(chargeId) returns the actual sequence of what happened — charge.created, transaction.detected, split.distributed, webhook.delivered, and so on. That's the thing to pull up when a support ticket says "I paid three hours ago and nothing happened" and you need to see, in order, exactly where the money is.
Recipient splits that don't require a second payout system
If you're building a marketplace, a creator platform, or anything with more than one party getting paid from a single charge, check whether splits are a first-class part of charge creation or something you're expected to bolt on afterward with a second set of transactions.
const seller = await klap.recipients.create({address: sellerAddress,label: 'seller',})const platform = await klap.recipients.create({address: platformAddress,label: 'platform',})const charge = await klap.charges.create({amount: 49.0,expiresIn: 3600,idempotencyKey: `charge_${order.id}`,externalRef: order.id,acceptedPayments: [{ token: 'USDC', network: 'base' }],splitRecipients: [{ recipientId: seller.id, percent: 90, label: 'seller' },{ recipientId: platform.id, percent: 10, label: 'platform' },],})await orders.save({ id: order.id, chargeId: charge.id, status: 'awaiting_payment' })
Two small details worth adopting as habits, not just copying: pass your own idempotencyKey derived from something stable on your side (the SDK will generate one for you if you skip it, but a freshly generated key on every retry means a page refresh can quietly create a second charge). And use externalRef for your own order ID rather than inventing a side channel for it — it's a plain field on the charge, and you'll want it back later in exactly the shape you put it in.
Fewer transactions to initiate, fewer balances to reconcile, and — the part that's easy to undersell — fewer moments where your platform is legally holding someone else's money because the payment API had no better way to express "90% to them, 10% to me."
Server-side events, not a loop that polls a block explorer
Writing your own chain indexer to notice when an address receives a transfer is a real option. It's also a second product you now maintain, with its own reorg handling, its own rate-limit backoff, its own bugs. A payments SDK worth using gives you the event, not the raw material to build the event yourself.
That means server-sent events for a live checkout screen, and webhooks for durable, retry-safe fulfillment — and it means the SDK ships correct, tested signature verification instead of leaving crypto.createHmac as an exercise for you:
app.post('/webhooks/klappay',express.raw({ type: 'application/json' }),async (req, res) => {let eventtry {event = klap.webhooks.constructEvent(req.body,req.headers['x-klappay-signature'],process.env.KLAP_WEBHOOK_SECRET,)} catch {return res.sendStatus(400)}if (event.event === 'charge.confirmed') {const alreadyHandled = await db.paymentEvents.exists(event.id)if (alreadyHandled) return res.sendStatus(200)const charge = event.data // the full Charge, typedawait db.transaction(async (tx) => {await tx.paymentEvents.insert({ id: event.id })await tx.orders.markPaid(charge.externalRef, {transactionHash: charge.txHash,})await tx.outbox.enqueue('fulfill-order', { orderId: charge.externalRef })})}res.sendStatus(200)},)
Notice the field is event.event, not event.type — an easy typo to carry around if you're used to Stripe's naming. And the transaction reference lives right on the charge as txHash, no nested payment object to dig through. Small things, but they're exactly the kind of small things that compile fine, look plausible in a code review, and then throw undefined at 2am.
The browser should never see any of this. It gets payment instructions — amount, address, network, status. It doesn't get an API key, and a client-reported transaction hash is a claim, never proof.
Types that hold up at the boundary, not just in your editor
Crypto integrations tend to break at the seams between systems: a network name gets typed with different casing somewhere, an amount gets parsed as a float and loses precision three decimals in, a webhook adds a field nobody expected. Ordinary software bugs — except here they cost real money, which changes how much slack you can afford to give them.
TypeScript types catch a decent chunk of this at the point you write the call. They don't help once a payload crosses a network boundary — an incoming webhook, a request from a frontend you don't fully trust. That's where Zod-compatible runtime schemas earn their keep, validating the actual bytes that arrived instead of trusting that they match what the type declaration promised. And keep token amounts as decimal values the SDK validates, not floating-point arithmetic you did yourself — 0.1 + 0.2 is not a number you want anywhere near a charge amount.
Actually test the failure paths — with real triggers, not imagination
"Use a sandbox" is easy advice to nod along to and then not really act on, because writing a fake underpayment by hand is annoying enough that most people just... don't. The bar for a sandbox worth using is that it removes that excuse — one call per outcome, no chain interaction required:
await klap.sandbox.confirm(charge.id)await klap.sandbox.partiallyPay(charge.id, 20.0)await klap.sandbox.overpay(charge.id, 55.0)await klap.sandbox.underpay(charge.id)await klap.sandbox.expire(charge.id)await klap.sandbox.settle(charge.id)
Each of those should exercise a real code path in your application, not just return a green checkmark in a demo. Does your UI correctly show "we received less than expected" instead of a generic error? Does a webhook that arrives twice — normal, expected behavior for any retrying delivery system — still only fulfill the order once? Does a customer who reopens the checkout tab after the charge already expired see something better than a form asking them to pay an address that no longer matters?
If you can answer those from your own test suite, not from a support ticket after launch, the SDK's sandbox did its job.
The package is not the whole decision
Good ergonomics don't erase a bad business model underneath them. Before committing, read the actual terms: when does settlement happen, what happens to a recipient split if the provider decides it doesn't like one of the parties, can access to a balance be frozen because the provider is the one holding it. A clean npm install doesn't answer any of those questions.
Klappay's own answer is the direct-settlement model this whole post has been describing: charges resolve on-chain to addresses you control, splits happen as part of that resolution rather than as a second payout run, and the SDK's job is to tell you the truth about what happened — never to stand between you and the money. Start here if that's the model you're building toward.