easyway
Sign inCreate free account

A crypto payment API in Python with only the standard library

The usual Python examples for easyway use requests and Flask. You don't need either. A cron job, a serverless function or a small Django shop can create invoices with urllib.request and check webhooks with hmac, both built into Python. This post shows two files that do the whole job with no pip install. We ran both on Python 3.11, and the error path against the live API.

What the two files do

FileJobStandard-library modules
easyway.pyPOST /v1/invoices and return the invoice, whose payment_url you redirect the customer to.json, urllib.request, decimal
webhook.pyReceive the signed invoice.paid delivery, check it, and mark the order paid.hmac, hashlib, http.server

Between the two, the customer pays on the hosted checkout: they pick a coin and network, see the amount and a QR code, and the page follows the payment by itself. Create the API key in Dashboard → Developers. An sk_test_… key only touches sandbox data, so everything below can be tried before real money moves.

1. Create the invoice with urllib

# easyway.py: one function, no pip install. Python 3.8+.
import json
import os
import urllib.error
import urllib.parse
import urllib.request
from decimal import Decimal

API = os.environ.get("EASYWAY_API", "https://api.easyway.cash")
CLASSES = {"bad_request", "unauthorized", "forbidden", "not_found", "conflict"}


class EasywayError(Exception):
    def __init__(self, status, code):
        super().__init__(f"easyway {status}: {code}")
        self.status, self.code = status, code


def create_invoice(order_id: str, amount: Decimal) -> dict:
    body = json.dumps({
        "amount": f"{amount:.2f}",  # a decimal string, never a float
        "currency": "USD",
        "order_id": order_id,
        "description": f"Order #{order_id}",
        "success_url": "https://shop.example.com/thanks?order=" + urllib.parse.quote(order_id),
        "cancel_url": "https://shop.example.com/cart",
    }).encode()
    req = urllib.request.Request(f"{API}/v1/invoices", data=body, method="POST", headers={
        "Authorization": f"Bearer {os.environ['EASYWAY_API_KEY']}",
        "Idempotency-Key": f"order-{order_id}",  # a retry returns the same invoice
        "Content-Type": "application/json",
    })
    try:
        with urllib.request.urlopen(req, timeout=15) as res:
            return json.load(res)  # id, status "pending", payment_url, ...
    except urllib.error.HTTPError as e:  # 4xx and 5xx land here, with a JSON body
        try:
            err = json.load(e)
        except ValueError:
            err = {}
        code = err.get("message") if err.get("error") in CLASSES else err.get("error")
        raise EasywayError(e.code, code or "unknown_error") from None
DetailWhy
Decimal → "149.00"The API takes decimal strings. A float such as 0.1 + 0.2 is 0.30000000000000004; keep money in Decimal (or integer cents) and format it at the edge.
Idempotency-KeyBuilt from the order id, so a retry after a timeout returns the same invoice instead of a second one. 8–128 characters: letters, digits, _ - : .
timeout=15urlopen has no timeout unless you pass one; without it a stalled connection can hang a worker for a long time.
HTTPErrorurllib raises on every 4xx and 5xx. The error object is also the response, so json.load(e) reads the JSON body.
CLASSESMost errors put a general class in error and the specific code in message, e.g. {"error": "unauthorized", "message": "invalid_api_key"}. The helper returns the specific one; the errors section of the docs has the rule.
from decimal import Decimal
import easyway

invoice = easyway.create_invoice("2048", Decimal("149"))   # order id and total from your database
# save invoice["id"] with the order, then redirect the customer (303) to:
print(invoice["payment_url"])

If urlopen raises URLError or times out, call create_invoice again with the same order id; the Idempotency-Key makes that safe. To bill in a coin instead of USD, set currency to an asset id such as "USDT_TRC20" (see pricing in USD or in the coin); to show only some coins, add "allowed_assets" (see limiting the checkout).

2. Verify the webhook with hmac

Each delivery carries X-Webhook-Signature: t=…,v1=…, where v1 is the lower-case hex HMAC-SHA256 of t + "." + raw body under the endpoint's signing secret.

# webhook.py: Dashboard → Developers → Webhooks points here.
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

SECRET = os.environ["EASYWAY_WEBHOOK_SECRET"].encode()


def verify(header: str, raw: bytes) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    t, v1 = parts.get("t", ""), parts.get("v1", "")
    if not (t.isascii() and t.isdigit()) or abs(time.time() - int(t)) > 300:
        return False  # missing, malformed or more than 5 minutes off
    expected = hmac.new(SECRET, t.encode() + b"." + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), v1.encode())


class Webhook(BaseHTTPRequestHandler):
    def do_POST(self):
        size = int(self.headers.get("Content-Length") or 0)
        raw = self.rfile.read(min(size, 1_000_000))  # the RAW body is what was signed
        if not verify(self.headers.get("X-Webhook-Signature", ""), raw):
            self.send_response(400)
            self.end_headers()
            return
        event = json.loads(raw)
        # Store event["id"] and skip repeats: deliveries are retried until you answer 2xx.
        if event["type"] == "invoice.paid":
            inv = event["data"]["invoice"]
            print("order", inv["order_id"], "paid:", inv["net_amount"], inv["pay_asset"])
            # mark_order_paid(inv["order_id"], inv["id"], inv["net_amount"], inv["pay_asset"])
        self.send_response(200)
        self.end_headers()


if __name__ == "__main__":
    ThreadingHTTPServer(("127.0.0.1", 8000), Webhook).serve_forever()

We sent deliveries signed that way to this server. The genuine one got 200 and printed the order. A changed body, a timestamp 400 seconds old, another secret, a missing or malformed header, a non-ASCII v1 and a non-ASCII digit in t all got 400.

PitfallFix
compare_digest on strWith two str arguments it raises TypeError on non-ASCII input, so a crafted header would crash the handler. Compare bytes.
str.isdigit()It accepts characters such as ², which int() rejects. Check isascii() first.
Parsed JSONVerify the exact bytes you received, then decode them. Re-serialised JSON is not byte-identical.
DuplicatesDeliveries are retried until you answer 2xx; store the event id and skip repeats.
Slow handlerAnswer within 10 seconds. Send e-mails and licence keys from a queue, not before the response.

http.server is fine for a test or a small internal service behind a reverse proxy with HTTPS. In a framework, keep verify() and feed it the raw body: request.body in Django (exempt the view from CSRF), await request.body() in FastAPI. The webhook signature guide has the Flask version and a way to sign a test request with openssl.

Test it end to end

Set EASYWAY_API_KEY to a test key and EASYWAY_WEBHOOK_SECRET to the endpoint's secret, start python3 webhook.py behind a tunnel, and add the tunnel URL in Dashboard → Developers → Webhooks. Then create an invoice, open its payment_url and press Simulate payment: the real crediting code runs and a real invoice.paid delivery reaches your server. The sandbox guide covers underpaid and expired cases too.

Errors worth a branch

StatusCodeWhat to do
400validation_errorA field is missing or malformed; fields says which.
401invalid_api_keyWrong, revoked or test-vs-live key.
409request_in_progressThe same Idempotency-Key is still running: retry shortly.
422idempotency_key_reusedSame key, different body: a new order needs a new key.
429rate_limitedOver 300 requests per minute on the key: back off and retry.

Branch on e.code, never on other text. The fee is a flat 0.5% of each confirmed payment, see pricing. The same integration in other languages: PHP, Go and Java; every endpoint is in the API docs.

Create free accountRead the API docsAlready have an account? Sign in
Crypto payment API in Python (standard library) | easyway