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 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
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)| Argument | Meaning |
|---|---|
merchant_id, api_key | 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 list_currencies return the testnet family. Same host, same key - see Sandbox and faucets. |
base_url | Defaults to https://api.cryptunnel.io. Point it at your own backend for local development. |
timeout | Seconds per request, 30 by default. |
app | Optional, 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.
| Method | Endpoint | Returns |
|---|---|---|
create_widget_payment(amount, currency, external_id, *, success_url=None, fail_url=None, callback_url=None, metadata=None, fee_payer=None) | POST /v1/payments/widget | id, 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/h2h | id, 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/payments | items, total, limit, offset |
list_currencies() | GET /v1/currencies | list of code, name, blockchain - exactly the values target_currency accepts |
get_merchant() | GET /v1/merchants | id, name, balance, created_at - the call that proves the credentials work |
verify_webhook(secret, headers, raw_body, tolerance=300) | none - local | True 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.
| Exception | 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 - .retry_after holds the seconds to wait, None if the API sent no header |
ApiError | 5xx and transport failures |
PaymentTimeoutError | wait_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 "", 200from 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
- Package: pypi.org/project/cryptunnel
- Source and issues: github.com/cryptunnel/cryptunnel-python
- Telegram bot recipe - the whole flow on aiogram
Updated 9 days ago
