Node SDK
cryptunnel on npm wraps the merchant API for Node 20+. Zero runtime dependencies - native fetch and node:crypto - and TypeScript types generated from the published API spec, so request and response shapes cannot drift from the API.
npm install cryptunnelEverything the package does is a plain HTTP call you can also make yourself - see the API reference. The package saves you the request building, the error mapping and the two parts people get wrong: webhook verification and polling.
Construction
import { Cryptunnel } from 'cryptunnel'
const cryptunnel = new Cryptunnel({
merchantId: '<merchant id>',
apiKey: '<api key>',
sandbox: true,
})| Option | Meaning |
|---|---|
merchantId, apiKey | From the merchant page in the cabinet. Sent as x-merchant-id and x-api-key on every request. |
sandbox | true marks every payment the client creates as a test payment and makes listCurrencies return the testnet family. Same host, same key - see Sandbox and faucets. |
baseUrl | Defaults to https://api.cryptunnel.io. Point it at your own backend for local development. |
timeoutMs | Per request, 30 000 by default. |
app | Optional, e.g. 'my-shop/2.0' - appended to the User-Agent the client sends (cryptunnel-node/1.1.0 node/22.3.0 (linux x64)), so your own integration shows up in Cryptunnel's usage statistics next to the SDK version. |
Methods
Every method returns a promise of the decoded JSON of its endpoint, typed.
| Method | Endpoint | Returns |
|---|---|---|
createWidgetPayment({ amount, currency, externalId, successUrl?, failUrl?, callbackUrl?, metadata?, feePayer? }) | POST /v1/payments/widget | WidgetPayment - id, external_id, status, url (send the buyer here), created_at, is_test |
createH2hPayment({ amount, currency, externalId, targetCurrency, autoTrace?, callbackUrl?, metadata?, feePayer? }) | POST /v1/payments/h2h | H2hPayment - adds wallet_address, amount (crypto, decimal string), currency |
getPayment(paymentId) | GET /v1/payments/{id} | Payment - a union, see below |
listPayments({ limit?, offset? }) | GET /v1/payments | PaymentList - items, total, limit, offset |
listCurrencies() | GET /v1/currencies | Currency[] - exactly the values targetCurrency accepts |
getMerchant() | GET /v1/merchants | Merchant - id, name, balance, created_at; the call that proves the credentials work |
verifyWebhook(secret, headers, rawBody, toleranceSeconds?) | none - local | true or false, never throws |
waitForPayment(paymentId, { timeoutMs?, firstDelayMs?, maxDelayMs? }) | polls GET /v1/payments/{id} | the payment once it reaches a terminal status |
createWidgetPayment and createH2hPayment are idempotent on externalId: a retry after a timeout returns the payment you already created. Repeating an externalId with a different amount or currency throws ValidationError with code PAYMENT_ALREADY_EXISTS.
Payment is a union of AwaitingCurrencyPayment (created, expired - no crypto amount, no wallet) and AssignedCurrencyPayment (every other status). Narrow with in:
const payment = await cryptunnel.getPayment(id)
if ('amount' in payment) {
console.log(payment.amount, payment.currency, payment.wallet_address)
}A negative check on status (!== 'created' && !== 'expired') does not narrow, because one member holds both statuses - use in or a positive status ===. The statuses themselves are explained on Payment statuses.
Errors
Every failure is an instance of CryptunnelError; branch with instanceof and read the raw API code from .code.
| Class | When |
|---|---|
AuthenticationError | 401 - wrong merchant id, wrong or rotated key, suspended merchant |
NotFoundError | 404 - PAYMENT_NOT_FOUND, CURRENCY_NOT_FOUND |
ValidationError | 400 - PAYMENT_ALREADY_EXISTS, WALLET_NOT_FOUND, VALIDATION_ERROR |
RateLimitError | 429 - .retryAfter holds the seconds to wait, undefined if the API sent no header |
ApiError | 5xx and transport failures |
PaymentTimeoutError | waitForPayment gave up |
import { RateLimitError, ValidationError } from 'cryptunnel'
try {
await cryptunnel.createH2hPayment({ amount: 10, currency: 'USD', externalId: 'order-3', targetCurrency: 'DOGE' })
} catch (error) {
if (error instanceof ValidationError) console.log(error.code) // WALLET_NOT_FOUND
else if (error instanceof RateLimitError) console.log(error.retryAfter)
}Verifying a webhook
Pass the raw body bytes exactly as received - parsing and re-serialising the JSON changes the signature. The helper accepts a Headers object or a plain header record, in any casing.
import express from 'express'
import { verifyWebhook } from 'cryptunnel'
// express.raw keeps the exact bytes
app.post('/cryptunnel', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyWebhook(WEBHOOK_SECRET, req.headers, req.body)) return res.sendStatus(401)
const payment = JSON.parse(req.body.toString())
...
res.sendStatus(200)
})import { verifyWebhook } from 'cryptunnel'
app.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => done(null, body))
app.post('/cryptunnel', async (request, reply) => {
if (!verifyWebhook(WEBHOOK_SECRET, request.headers, request.body as Buffer)) return reply.code(401).send()
const payment = JSON.parse((request.body as Buffer).toString())
...
return reply.code(200).send()
})The full contract - payload fields, retries, deduplication - is on the webhooks page.
Waiting for a payment
waitForPayment polls every 5 seconds, backing off to 30, honours Retry-After on a 429, and throws PaymentTimeoutError after 30 minutes. It exists for scripts and development. In production the webhook is the guarantee: a buyer who closes the page still produces a callback, a polling process that dies does not.
Links
- Package: npmjs.com/package/cryptunnel
- Source and issues: github.com/cryptunnel/cryptunnel-node
- Telegram bot recipe - the whole flow on telegraf
Updated 9 days ago
