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
| File | Job | Standard-library modules |
|---|---|---|
easyway.py | POST /v1/invoices and return the invoice, whose payment_url you redirect the customer to. | json, urllib.request, decimal |
webhook.py | Receive 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| Detail | Why |
|---|---|
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-Key | Built 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=15 | urlopen has no timeout unless you pass one; without it a stalled connection can hang a worker for a long time. |
HTTPError | urllib raises on every 4xx and 5xx. The error object is also the response, so json.load(e) reads the JSON body. |
CLASSES | Most 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.
| Pitfall | Fix |
|---|---|
compare_digest on str | With 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 JSON | Verify the exact bytes you received, then decode them. Re-serialised JSON is not byte-identical. |
| Duplicates | Deliveries are retried until you answer 2xx; store the event id and skip repeats. |
| Slow handler | Answer 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
| Status | Code | What to do |
|---|---|---|
| 400 | validation_error | A field is missing or malformed; fields says which. |
| 401 | invalid_api_key | Wrong, revoked or test-vs-live key. |
| 409 | request_in_progress | The same Idempotency-Key is still running: retry shortly. |
| 422 | idempotency_key_reused | Same key, different body: a new order needs a new key. |
| 429 | rate_limited | Over 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.