Python SDK

cryptunnel on PyPI wraps the merchant API for Python 3.10+. It is async-first - built for bots on aiogram and for async web frameworks - with a blocking mirror for scripts. httpx is its only dependency.

pip 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

from cryptunnel import Cryptunnel, CryptunnelSync

# async - inside a bot or a web framework
cryptunnel = Cryptunnel("<merchant id>", "<api key>", sandbox=True)

# blocking - scripts and one-off calls
cryptunnel = CryptunnelSync("<merchant id>", "<api key>", sandbox=True)
ArgumentMeaning
merchant_id, api_keyFrom 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 list_currencies return the testnet family. Same host, same key - see Sandbox and faucets.
base_urlDefaults to https://api.cryptunnel.io. Point it at your own backend for local development.
timeoutSeconds per request, 30 by default.
appOptional, e.g. "my-shop/2.0" - appended to the User-Agent the client sends (cryptunnel-python/1.1.0 python/3.12 httpx/0.28 (Darwin arm64)), so your own integration shows up in Cryptunnel's usage statistics next to the SDK version.

The async client is a context manager (async with Cryptunnel(...) as cryptunnel:) and has an explicit await cryptunnel.close(); the sync one mirrors both.

Methods

Every method exists on both clients with the same name, arguments and return value - await it on Cryptunnel, call it on CryptunnelSync. Each returns the decoded JSON of its endpoint as a dict.

MethodEndpointReturns
create_widget_payment(amount, currency, external_id, *, success_url=None, fail_url=None, callback_url=None, metadata=None, fee_payer=None)POST /v1/payments/widgetid, external_id, status, url (send the buyer here), created_at, is_test
create_h2h_payment(amount, currency, external_id, target_currency, *, auto_trace=False, callback_url=None, metadata=None, fee_payer=None)POST /v1/payments/h2hid, external_id, status, wallet_address, amount (crypto, decimal string), currency, created_at, is_test
get_payment(payment_id)GET /v1/payments/{id}the payment - two shapes, see below
list_payments(limit=20, offset=0)GET /v1/paymentsitems, total, limit, offset
list_currencies()GET /v1/currencieslist of code, name, blockchain - exactly the values target_currency accepts
get_merchant()GET /v1/merchantsid, name, balance, created_at - the call that proves the credentials work
verify_webhook(secret, headers, raw_body, tolerance=300)none - localTrue or False, never raises
wait_for_payment(payment_id, *, timeout=1800, first_delay=5, max_delay=30)polls GET /v1/payments/{id}the payment once it reaches a terminal status

create_widget_payment and create_h2h_payment are idempotent on external_id: a retry after a timeout returns the payment you already created. Repeating an external_id with a different amount or currency raises ValidationError with code PAYMENT_ALREADY_EXISTS.

get_payment returns two shapes and the status tells them apart: a created or expired payment has no amount, currency or wallet_address, because nobody has picked a currency for it yet. Read them with payment.get("amount") unless you know the status - see Payment statuses.

Errors

Every failure is a subclass of CryptunnelError; branch on the class and read the raw API code from .code.

ExceptionWhen
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 - .retry_after holds the seconds to wait, None if the API sent no header
ApiError5xx and transport failures
PaymentTimeoutErrorwait_for_payment gave up
from cryptunnel import RateLimitError, ValidationError

try:
    cryptunnel.create_h2h_payment(10, "USD", "order-3", "DOGE")
except ValidationError as error:
    print(error.code)  # WALLET_NOT_FOUND - no active DOGE wallet on this merchant
except RateLimitError as error:
    print(error.retry_after)

Verifying a webhook

Pass the raw body bytes exactly as received - parsing and re-serialising the JSON changes the signature. The helper reads x-webhook-timestamp and x-webhook-signature from any header mapping, in any casing.

from flask import request
from cryptunnel import verify_webhook

@app.post("/cryptunnel")
def callback():
    if not verify_webhook(WEBHOOK_SECRET, request.headers, request.get_data()):
        return "", 401
    payment = request.get_json()
    ...
    return "", 200
from aiohttp import web
from cryptunnel import verify_webhook

async def callback(request: web.Request):
    raw_body = await request.read()
    if not verify_webhook(WEBHOOK_SECRET, request.headers, raw_body):
        return web.Response(status=401)
    payment = await request.json()
    ...
    return web.Response(status=200)

The full contract - payload fields, retries, deduplication - is on the webhooks page.

Waiting for a payment

wait_for_payment polls every 5 seconds, backing off to 30, honours Retry-After on a 429, and raises 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?