DEV Community

David Mei
David Mei

Posted on Edited on AI-assisted

How to accept USDT (TRC20) payments in your app with Python — step by step

Accepting stablecoin payments sounds simple until you build it: one address per customer, watching the blockchain, waiting for confirmations, matching payments to users, then paying withdrawals out again — safely.

In this tutorial we'll do all of that with a payment API, in plain Python. By the end you'll have:

  • a permanent deposit address for each customer,
  • a test payment that goes from "detected" to "confirmed",
  • a webhook receiver that verifies the signature and credits the customer,
  • a payout sent through the API.

Everything runs in test mode, so no real money moves while you build.

The complete, runnable code is on GitHub:
👉 kcwpay-merchant-examples (Python, Node.js and PHP)


What you need

  • Python 3.9+ and pip install requests
  • A KCWPay account with merchant access (apply here)
  • A test API key from Merchant → Developers (mk_test_… + its secret)
export KCW_API_BASE="https://kcwpay.com/merchant/v1"
export KCW_KEY_ID="mk_test_..."
export KCW_SECRET="sk_test_..."
Enter fullscreen mode Exit fullscreen mode

Step 1 — Sign every request

Every request is signed with HMAC-SHA256, so nobody can call the API with your key id alone. The signature covers a timestamp, the method, the path (including /merchant/v1 and the query) and a hash of the exact body bytes:

signature = hex HMAC-SHA256(secret,
    "<timestamp ms>\n<METHOD>\n<path with query>\n<sha256 hex of the body>")
Enter fullscreen mode Exit fullscreen mode

Here's a small helper that does it:

import hashlib, hmac, json, os, time, uuid
from urllib.parse import urlparse

import requests

BASE = os.environ.get("KCW_API_BASE", "https://kcwpay.com/merchant/v1").rstrip("/")
KEY_ID = os.environ["KCW_KEY_ID"]
SECRET = os.environ["KCW_SECRET"]
BASE_PATH = urlparse(BASE).path            # "/merchant/v1" — part of what you sign


def kcw(method, endpoint, body=None, idempotency_key=None):
    """Send one signed request and return the JSON reply."""
    raw = json.dumps(body, separators=(",", ":")).encode() if body is not None else b""
    path = BASE_PATH + endpoint
    ts = str(int(time.time() * 1000))
    message = f"{ts}\n{method}\n{path}\n{hashlib.sha256(raw).hexdigest()}"
    headers = {
        "X-KCW-Key": KEY_ID,
        "X-KCW-Timestamp": ts,
        "X-KCW-Signature": hmac.new(SECRET.encode(), message.encode(), hashlib.sha256).hexdigest(),
    }
    if body is not None:
        headers["Content-Type"] = "application/json"
        # Same key = same request: a retry after a timeout never pays twice.
        headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
    r = requests.request(method, BASE + endpoint, data=raw or None, headers=headers, timeout=30)
    reply = r.json()
    if r.status_code >= 400:
        err = reply.get("error", {})
        raise RuntimeError(f"{r.status_code} {err.get('code')}: {err.get('message')}")
    return reply
Enter fullscreen mode Exit fullscreen mode

Two details that save hours of debugging:

  1. Sign the exact bytes you send. Serialise the JSON once, sign that, send that.
  2. Use an Idempotency-Key derived from your id (an order or withdrawal id). If the network drops and you retry, the API returns the first result instead of creating a second payment or payout.

Check that it works:

me = kcw("GET", "/ping")
print(me)   # {'ok': True, 'merchant_id': '…', 'mode': 'test'}
Enter fullscreen mode Exit fullscreen mode

Step 2 — A deposit address for each customer

Instead of generating a new address for every invoice, give each customer one permanent address. Every payment to it is matched to that customer automatically — they can top up whenever they like.

addr = kcw("POST", "/deposit-addresses", {
    "network": "USDT TRC20",
    "customer_ref": "customer-42",      # your own user id
})["data"]

print(addr["address"])   # customer-42's own TRON address
Enter fullscreen mode Exit fullscreen mode

Calling it again for the same customer returns the same address, so you can safely call it every time the user opens your "Deposit" screen.

Supported networks: Bitcoin, Ethereum, BNB Smart Chain, and USDT on TRON (TRC20), BNB Smart Chain (BEP20) and Ethereum (ERC20).

Step 3 — Simulate a payment (test mode)

In test mode you don't need real USDT. Two sandbox calls pretend the customer paid, then give the payment its network confirmations:

if me["mode"] == "test":
    pay = kcw("POST", "/sandbox/transfers", {
        "network": "USDT TRC20", "address": addr["address"],
        "amount": "25", "confirmed": False,
    })["data"]
    pay = kcw("POST", f"/sandbox/payments/{pay['id']}/confirm", {})["data"]
    print(pay["status"], pay["amount_received"], pay["net"])   # confirmed 25 …
Enter fullscreen mode Exit fullscreen mode

With a live key you skip this step: the blockchain does it, and you're told through webhooks.

Step 4 — Receive the webhook (and verify it!)

When a payment has enough confirmations, the API sends payment.confirmed to your server. Credit your customer from this event — not as soon as funds appear at an address.

The delivery is signed too:

X-KCW-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(webhook secret, "<t>." + raw body)>
Enter fullscreen mode Exit fullscreen mode

A complete receiver using only the standard library:

import hashlib, hmac, json, os, time
from http.server import BaseHTTPRequestHandler, HTTPServer

SECRET = os.environ["KCW_WEBHOOK_SECRET"]
TOLERANCE = 300          # seconds: refuse old deliveries (replay protection)
seen = set()             # use your database in production


def verify(raw_body: bytes, header: str) -> bool:
    try:
        parts = dict(p.split("=", 1) for p in header.split(","))
        ts, sig = int(parts["t"]), parts["v1"]
    except (KeyError, ValueError):
        return False
    expected = hmac.new(SECRET.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return abs(time.time() - ts) <= TOLERANCE and hmac.compare_digest(expected, sig)


class Receiver(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path != "/kcw/webhook":
            return self._reply(404)
        raw = self.rfile.read(int(self.headers.get("Content-Length", 0)))
        if not verify(raw, self.headers.get("X-KCW-Signature", "")):
            return self._reply(400)
        event = json.loads(raw)
        if event["id"] not in seen:        # a repeat delivery is acknowledged, not processed twice
            seen.add(event["id"])
            if event["type"] == "payment.confirmed":
                p = event["data"]
                print("credit", p["customer_ref"], p["amount_received"], p["network"])
        self._reply(200)

    def _reply(self, code):
        self.send_response(code)
        self.end_headers()


HTTPServer(("0.0.0.0", 8080), Receiver).serve_forever()
Enter fullscreen mode Exit fullscreen mode

Three rules that apply to any webhook integration:

  1. Verify the raw body, before parsing it — re-serialised JSON won't match.
  2. Answer 2xx quickly, and do slow work in the background.
  3. Expect repeats. Store event ids you've handled and skip them.

Step 5 — Pay out

Withdrawals go out from your balance. The amount plus the fee is reserved the moment you create the payout, and large payouts can wait for a team member's approval in the dashboard.

payout = kcw("POST", "/payouts", {
    "network": "USDT TRC20",
    "to_address": "<your customer's withdrawal address>",
    "amount": "10",
    "customer_ref": "customer-42",
}, idempotency_key="withdrawal-5001")["data"]   # your own withdrawal id

print(payout["id"], payout["status"])   # queued, or awaiting_approval above your limit
Enter fullscreen mode Exit fullscreen mode

⚠️ Always double-check the destination. Never send to a token's contract address (for USDT on TRON that's TR7NHqje…): it's a contract, not a wallet, and coins sent there can't be recovered. KCWPay refuses those addresses, but your own validation should too.

Going live

When everything works in test mode:

  1. Switch to a live key (mk_live_…). Keys that can send payouts work only from your server's IP addresses — keep the secret on the server.
  2. Turn on two-factor authentication for everyone on your merchant team.
  3. Point your live webhook URL at your server.

Fees

At the time of writing, incoming payments start from 0.5%, with no service fee on payouts — only the blockchain's network fee. Current pricing, with a fee calculator: kcwpay.com/business#pricing.

Wrap-up

You now have the full loop: an address per customer, verified webhooks, and idempotent payouts — in plain Python, no SDK.

Questions or feedback? Leave a comment below — I read them all.

Top comments (0)