Library for developers
@solanapaykz/core is a TypeScript package for accepting USDC/SOL payments
on the Solana network, with automatic conversion from tenge (KZT) at the
current exchange rate. It’s isomorphic code: it works in both Node.js
20.18+ and the browser, depending only on the global fetch.
Who this library is for
For those who have their own platform — not WordPress and not Tilda. If the shop runs on WooCommerce, it needs the ready-made plugin; if on Tilda, the ready-made server. This library is for a developer writing their own backend or their own integration, who wants just the arithmetic and the payment check, without the library deciding for them where to store orders or how to show a QR code on the page.
The SDK covers three steps of accepting a payment: quote, payment request, and verification. It is not responsible for storing orders, showing a QR code on the page, or handling webhooks/polling — that’s the integration’s job.
Installation
npm install @solanapaykz/core
If you need an unpublished change from main, the package also installs
straight from the repository and builds in place:
npm install github:Tatancloud/solanapaykz#<commit hash>.
The package already pins exact versions of @solana/pay and @solana/kit
— there’s no need to change them by hand (and @solana/kit@8 must not be
installed: @solana/pay requires ^6.4.0).
Quick start
The full path is four steps. orderId, saveOrder, and markOrderPaid in
the example are not part of the SDK — they’re functions from your own
integration; here they only illustrate where to pass the reference from
step 3.
import { SolanaPayKZ } from '@solanapaykz/core';
const sdk = new SolanaPayKZ({
recipient: '<YOUR_SOLANA_ADDRESS>', // the merchant's WALLET address — NOT a coin's mint address.
// A mistake here is irreversible: whoever controls the
// USDC/SOL mint address, not your wallet, gets the payments,
// and there's no getting them back.
rpcUrl: process.env.SOLANA_RPC_URL!, // your own RPC provider, see the section below
cluster: 'mainnet',
});
// Step 1. Quote: amount in KZT → amount in tokens. The rate is frozen
// for 15 minutes — see "Rate-drift risk" below.
const quote = await sdk.createQuote({ amountKzt: '10000', token: 'USDC' });
// quote.amountToken — the amount in USDC as a string, e.g. "21.758051"
// Step 2. Payment request: a Solana Pay link (solana:...) and an SVG QR code.
const request = await sdk.createPaymentRequest(quote, {
label: 'Example Shop',
message: `Order #${orderId}`,
});
// Step 3. Save request.reference together with the order — mandatory, see
// "Payment reference" below. Show the buyer request.qrSvg (or the
// request.url link, to jump straight into a wallet).
await saveOrder({
orderId,
reference: request.reference,
quoteId: quote.quoteId,
});
// Step 4. Verification — called later: on a timer, when the buyer returns
// to the site, from a cron job, and so on. Can be called repeatedly.
const status = await sdk.checkPayment({ reference: request.reference, quote });
switch (status.status) {
case 'pending':
// Payment hasn't arrived yet. Check again later.
break;
case 'expired':
// The quote expired and payment never arrived. You need to issue a new
// quote and a new payment request — the old reference can't be reused,
// see the warning about reference uniqueness.
break;
case 'confirmed':
// status.signature — the transaction's signature on Solana.
// status.amountPaid — the quoted amount, confirmed as received
// (at least this much — see "What amountPaid means" below).
await markOrderPaid(orderId, status.signature);
break;
case 'mismatch':
// A transaction with this reference was found, but failed the check —
// wrong recipient, wrong token, or an amount that's too low.
// status.reason is text for logs, not for showing the buyer. This does
// NOT mean "there's no money" — the money may already be gone. Look at
// status.signature by hand before treating the order as unpaid.
break;
}
SolanaPayKZ constructor parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
recipient |
string |
yes | — | The merchant’s Solana address that payments should arrive at. |
rpcUrl |
string |
yes | no default value | The address of the Solana RPC node. A public node won’t do, see below. |
cluster |
'mainnet' \| 'devnet' |
yes | — | The network cluster: determines the token mint addresses. |
markupPercent |
number |
no | 0 |
Merchant markup as a percentage (0–100, in steps of 0.01 pp), applied to the KZT amount before conversion. |
quoteTtlMs |
number |
no | 900000 (15 minutes) |
The quote’s lifetime in milliseconds. |
createPaymentRequest parameters
The second argument is CreatePaymentRequestOptions. recipient is not
part of it: the recipient address is already fixed in the constructor and
can’t be overridden for an individual request.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
label |
string |
no | — | The shop/recipient name — shown by the buyer’s wallet. |
message |
string |
no | — | A note about the payment (e.g. an order number) — shown by the wallet. |
memo |
string |
no | — | Written into the Solana transaction itself (a memo instruction) — part of the on-chain history, not just the wallet’s UI. |
qrSize |
number |
no | 320 |
The QR code’s size in pixels. |
Payment reference — the only thing your storage must keep
Every payment request gets a reference — a random tag that the
blockchain search uses to find the transaction.
reference together with the order. Without it there
is no way to find the payment belonging to a specific order — the
blockchain doesn't know about orders, only about transfers carrying a tag.
The reference must be unique for every payment attempt. The search returns
the oldest transaction bearing a given reference. If the buyer's first
attempt fails and you reuse the same reference for a retry, verification
will forever find that first, failed transaction — a new, valid payment
will stay invisible to checkPayment. The practical
consequence: a new createPaymentRequest for every payment
attempt, even for the same order and the same quote.
Mandatory requirements for the Solana node
rpcUrl has no default value and is required in the constructor. A public
node (api.mainnet-beta.solana.com and similar) is hard rate-limited and
doesn’t keep enough transaction history for a search by reference —
checkPayment will be unreliable or unavailable on it. Production needs
your own RPC provider (Helius, QuickNode, Triton, and the like); for
development, a public devnet node will do.
Quote format and rate-drift risk
A quote freezes the rate for quoteTtlMs (15 minutes by default) — exactly
how long the amount in the QR code stays valid. Until the buyer pays, the
market rate can drift slightly from the one that was locked in, and the
merchant bears that difference.
A measurement of the USDT/KZT pair on Binance (288 five-minute candles
over a day) shows: the typical move in the rate within five minutes is
close to zero (median 0.000%), the 95th percentile is 0.065%, and the
maximum over a day is 0.92%. The risk over the span of a single payment
attempt is usually negligible, but not zero — factor this in when choosing
quoteTtlMs and the markup.
The SDK queries rate sources in order and takes the first successful answer:
| Priority | Source | How it’s computed |
|---|---|---|
| Primary | Binance | USDTKZT × USDCUSDT (or × SOLUSDT for SOL) |
| Fallback | synthetic | the USD/KZT rate (open.er-api.com) × the token’s USD price (CoinGecko) |
CoinGecko doesn’t support KZT at all: the request answers with HTTP 200 and an empty object, meaning the failure happens silently rather than as an explicit error. Because of this, the KZT value for the fallback path is fetched separately from a currency-rate provider, and the SDK treats such an empty response as a source failure, not as a zero rate. The fallback rate refreshes once a day and, at the moment of a Binance outage, can differ from the exchange rate by roughly a percent.
The order of sources is fixed inside the SDK and is not configurable
through the public API — RateProvider, BinanceRateSource,
SyntheticRateSource, and the RateSource type are deliberately not
exported from the package, so that no one can assemble a rate provider with
a different source order that bypasses SolanaPayKZ.
Verifying a payment
checkPayment returns one of four statuses:
| Status | Fields | When |
|---|---|---|
pending |
— | No transaction with this reference has been found yet, and the quote hasn’t expired. |
expired |
— | No transaction found, and the quote has already expired. |
confirmed |
signature, amountPaid |
A transaction was found and passed the check for recipient, token, and amount. |
mismatch |
signature, reason |
A transaction was found, but failed the check — wrong recipient, wrong token, or an amount that’s too low. |
Confirmation is checked at the finalized commitment level — the most
reliable one available on Solana; the faster but less reliable levels
(confirmed, processed) are not used by the SDK.
A payment that arrives after the quote has already expired (expired at
the time of the check) can still be found and confirmed on a later call —
a blockchain transaction is irreversible. Whether to accept such a payment
for the order or not is a decision for your integration; the SDK doesn’t
make it.
mismatch status is not the same thing as "the buyer didn't
pay." A transaction with this reference was found on the blockchain — the
money may already have left the buyer's account; the check simply didn't
match on a formal criterion. On seeing mismatch, don't
automatically treat the order as unpaid — look at the transaction via
status.signature by hand and decide what to do with it before
telling the buyer about a payment error or creating a new payment request.
What amountPaid means
On a confirmed status, the amountPaid field is the quoted amount,
confirmed as received at least in that amount — not the exact amount
actually transferred. The check (via validateTransfer from
@solana/pay) verifies the condition “at least the expected amount was
transferred” — an overpayment also passes. If the actual amount received
matters to you, look it up in the transaction itself via
status.signature, not in amountPaid.
Error handling
Besides the regular PaymentStatus statuses, SDK methods can throw
exceptions. All of them inherit from SolanaPayKzError.
| Method | What it can throw | When |
|---|---|---|
createQuote |
ConfigError |
Invalid input (amount, token/cluster, TTL) — before any network request. |
createQuote |
RateUnavailableError |
No rate source responded. |
createPaymentRequest |
ConfigError |
The quote is malformed, or recipient is invalid. |
createPaymentRequest |
QuoteExpiredError |
The quote has already expired — a new one needs to be issued. |
checkPayment |
ConfigError |
The quote is malformed, recipient/reference are invalid, or the quote’s cluster doesn’t match the client’s cluster. |
checkPayment |
RPC-client / network errors | Propagated as-is, unwrapped. An RPC node failure is not a mismatch and not a pending — it’s an exception: a temporary network failure shouldn’t look like the outcome of a payment check. Wrap the call in try/catch and plan for retries. |
The new SolanaPayKZ(...) constructor also throws ConfigError
synchronously for an invalid recipient or rpcUrl — it’s worth checking
for this right when the client is created, not only on the first call.
Private keys
The SDK never creates, stores, or requests private keys, for either the
merchant or the buyer. The payment reference (reference) is 32 random
bytes formatted as a Solana address; no key pair is ever generated or
exists for it. Verifying a payment only reads public blockchain data (via
RPC); the SDK cannot and does not attempt to sign or send transactions on
anyone’s behalf.
For more on exactly what’s checked in a transaction, and what happens on an amount mismatch or a network failure, see the “Security” page.
Compatibility
Node.js 20.18+ and the browser. The only runtime dependency that needs the
network is the global fetch; older Node environments without built-in
fetch will need a polyfill.
QR generation (createPaymentRequest) only asks the qrcode library for
SVG. Bundlers (webpack, Vite/Rollup, esbuild) targeting the browser
respect that library’s "browser" field in package.json by default and
use its browser build, which doesn’t reference fs — no special
configuration is needed on your side. The issue could, in theory, only
show up with a non-standard build configuration that explicitly disables
respecting the browser field.