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 cryptunnel

Everything 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,
})
OptionMeaning
merchantId, apiKeyFrom the merchant page in the cabinet. Sent as x-merchant-id and x-api-key on every request.
sandboxtrue 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.
baseUrlDefaults to https://api.cryptunnel.io. Point it at your own backend for local development.
timeoutMsPer request, 30 000 by default.
appOptional, 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.

MethodEndpointReturns
createWidgetPayment({ amount, currency, externalId, successUrl?, failUrl?, callbackUrl?, metadata?, feePayer? })POST /v1/payments/widgetWidgetPayment - 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/h2hH2hPayment - adds wallet_address, amount (crypto, decimal string), currency
getPayment(paymentId)GET /v1/payments/{id}Payment - a union, see below
listPayments({ limit?, offset? })GET /v1/paymentsPaymentList - items, total, limit, offset
listCurrencies()GET /v1/currenciesCurrency[] - exactly the values targetCurrency accepts
getMerchant()GET /v1/merchantsMerchant - id, name, balance, created_at; the call that proves the credentials work
verifyWebhook(secret, headers, rawBody, toleranceSeconds?)none - localtrue 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.

ClassWhen
AuthenticationError401 - wrong merchant id, wrong or rotated key, suspended merchant
NotFoundError404 - PAYMENT_NOT_FOUND, CURRENCY_NOT_FOUND
ValidationError400 - PAYMENT_ALREADY_EXISTS, WALLET_NOT_FOUND, VALIDATION_ERROR
RateLimitError429 - .retryAfter holds the seconds to wait, undefined if the API sent no header
ApiError5xx and transport failures
PaymentTimeoutErrorwaitForPayment 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


Did this page help you?