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_..."
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>")
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
Two details that save hours of debugging:
- Sign the exact bytes you send. Serialise the JSON once, sign that, send that.
-
Use an
Idempotency-Keyderived 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'}
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
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 …
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)>
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()
Three rules that apply to any webhook integration:
- Verify the raw body, before parsing it — re-serialised JSON won't match.
- Answer 2xx quickly, and do slow work in the background.
- 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
⚠️ 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:
- 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. - Turn on two-factor authentication for everyone on your merchant team.
- 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.
- 💻 Full examples (Python, Node.js, PHP): GitHub
- 📚 API reference: kcwpay.com/merchant/docs
- 🏢 KCWPay for Business: kcwpay.com/business
Questions or feedback? Leave a comment below — I read them all.
Top comments (0)