Building a P2P Trading Bot with the Binance C2C API
A complete tutorial for building an automated P2P trading bot on Binance C2C from scratch. This guide covers the full loop: monitoring the order book, managing your ads, handling incoming orders, and integrating the chat system.
1. Architecture Overview
A production P2P bot needs five core components working together in an async event loop:
┌─────────────────────────────────────────────────────────────────┐ │ P2P Bot Architecture │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────┐ ┌───────────────┐ ┌────────────────┐ │ │ │ Market Data │ │ Ad Manager │ │ Order Handler │ │ │ │ (EP-0) │───▶│ (EP-4,7,8) │ │ (EP-13,15) │ │ │ │ Poll loop │ │ Reprice ads │ │ New orders │ │ │ └──────────────┘ └───────────────┘ └────────────────┘ │ │ │ │ │ ┌──────────────┐ ┌───────────────┐ │ │ │ │ Chat System │◀───│ WebSocket │◀──────────┘ │ │ │ (EP-33,34) │ │ (EP-33 cred) │ │ │ │ Auto-reply │ │ Real-time │ │ │ └──────────────┘ └───────────────┘ │ │ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ Rate Limiter + Circuit Breaker (all API calls) │ │ │ └──────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘
The market data loop runs every few seconds and feeds the ad manager. The order handler processes new orders and triggers the chat system. All Binance calls go through a shared rate limiter to avoid triggering soft limits or circuit breakers.
2. Authentication Setup
Most endpoints require HMAC-SHA256 signatures. See the Getting Started guide for the full signing setup. Here's the reusable signing helper you'll use throughout this tutorial:
import hmac
import hashlib
import time
import httpx
BASE_URL = "https://api.binance.com"
def sign(api_secret: str, query: str) -> str:
return hmac.new(
api_secret.encode(),
query.encode(),
hashlib.sha256,
).hexdigest()
async def signed_post(
client: httpx.AsyncClient,
api_key: str,
api_secret: str,
path: str,
body: dict,
) -> dict:
timestamp = int(time.time() * 1000)
query = f"recvWindow=60000×tamp={timestamp}"
signature = sign(api_secret, query)
resp = await client.post(
f"{BASE_URL}{path}?{query}&signature={signature}",
json=body,
headers={"X-MBX-APIKEY": api_key, "clientType": "web"},
)
return resp.json()3. Monitoring the Order Book (EP-0)
EP-0 is the public BAPI search endpoint — no authentication required. It's the primary data source for understanding the competitive landscape. Poll it every 5–30 seconds depending on market volatility.
tradeType=BUY means "I want to buy" — it returns SELL ads (sellers). Use SELL to get buyer ads. Always pass publisherType: "merchant" to avoid misclassifying merchants as regular users.import httpx
import asyncio
BAPI_URL = "https://p2p.binance.com/bapi/c2c/v2/friendly/c2c/adv/search"
async def fetch_order_book(
asset: str,
fiat: str,
trade_type: str, # "BUY" = I want to buy → returns sellers
pages: int = 3,
) -> list[dict]:
"""Fetch up to pages * 20 ads from the public order book."""
all_ads: list[dict] = []
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Content-Type": "application/json",
}
async with httpx.AsyncClient() as client:
for page in range(1, pages + 1):
resp = await client.post(
BAPI_URL,
json={
"asset": asset,
"fiat": fiat,
"tradeType": trade_type,
"page": page,
"rows": 20,
"publisherType": "merchant",
},
headers=headers,
)
data = resp.json().get("data", [])
if not data:
break # Empty = no more pages or soft rate limit
all_ads.extend(data)
if page < pages:
await asyncio.sleep(0.1) # 100ms inter-page delay
return all_ads
async def poll_order_book(asset: str, fiat: str, interval_sec: float = 10.0):
"""Continuous polling loop."""
while True:
try:
sellers = await fetch_order_book(asset, fiat, "BUY")
buyers = await fetch_order_book(asset, fiat, "SELL")
yield {"sellers": sellers, "buyers": buyers}
except Exception as e:
print(f"Order book error: {e}")
await asyncio.sleep(interval_sec)4. Managing Your Ads (EP-4, EP-7, EP-8)
EP-4 lists your active ads. EP-7 updates price (and optionally status). EP-8 batch-updates ad status (online/offline). The critical rule for EP-7: send only advNo + price — including surplusAmount causes error 187049.
import httpx
from auth import signed_post
async def list_my_ads(
client: httpx.AsyncClient,
api_key: str,
api_secret: str,
) -> list[dict]:
"""EP-4: List all active ads with pagination."""
result = await signed_post(client, api_key, api_secret,
"/sapi/v1/c2c/ads/listWithPagination",
{"page": 1, "rows": 100},
)
return result.get("data", [])
async def update_ad_price(
client: httpx.AsyncClient,
api_key: str,
api_secret: str,
adv_no: str,
price: str,
) -> bool:
"""EP-7: Price-only update. NEVER include surplusAmount."""
result = await signed_post(client, api_key, api_secret,
"/sapi/v1/c2c/ads/update",
# CRITICAL: only advNo + price
{"advNo": adv_no, "price": price},
)
return result.get("success", False)
async def set_ads_online(
client: httpx.AsyncClient,
api_key: str,
api_secret: str,
adv_nos: list[str],
) -> bool:
"""EP-8: Batch set ads to online status (advStatus=1)."""
result = await signed_post(client, api_key, api_secret,
"/sapi/v1/c2c/ads/updateStatus",
{"advNos": adv_nos, "advStatus": 1},
)
if result.get("code") == "-1102":
# Fallback: some environments need CSV string
result = await signed_post(client, api_key, api_secret,
"/sapi/v1/c2c/ads/updateStatus",
{"advNos": ",".join(adv_nos), "advStatus": 1},
)
return result.get("success", False)5. Handling Orders (EP-13, EP-15)
EP-15 lists your open orders. EP-13 fetches a specific order's detail. EP-13 uses adaptive auth: it first tries with API key only (no HMAC), then falls back to full HMAC if rejected.
import httpx
from auth import signed_post, BASE_URL
async def list_open_orders(
client: httpx.AsyncClient,
api_key: str,
api_secret: str,
) -> list[dict]:
"""EP-15: List all orders with pagination."""
result = await signed_post(client, api_key, api_secret,
"/sapi/v1/c2c/orderMatch/listOrders",
{"page": 1, "rows": 20},
)
return result.get("data", [])
async def get_order_detail(
client: httpx.AsyncClient,
api_key: str,
api_secret: str,
order_no: str,
) -> dict:
"""EP-13: Adaptive auth — unsigned first, HMAC fallback."""
url = f"{BASE_URL}/sapi/v1/c2c/orderMatch/getUserOrderDetail"
# Attempt 1: API key only (no HMAC)
resp = await client.post(
url,
json={"adOrderNo": order_no},
headers={"X-MBX-APIKEY": api_key, "clientType": "web"},
)
data = resp.json()
if resp.status_code in (401, 403) or data.get("code") in ("-1021", "-1022"):
# Attempt 2: full HMAC
data = await signed_post(client, api_key, api_secret,
"/sapi/v1/c2c/orderMatch/getUserOrderDetail",
{"adOrderNo": order_no},
)
return data.get("data", {})6. Chat Integration (EP-33, EP-34)
EP-33 returns a short-lived WebSocket credential. Connect to the WebSocket URL and authenticate with the listenKey. EP-34 retrieves existing messages with pagination.
import json
import websockets
import httpx
from auth import signed_post
async def get_chat_credential(
client: httpx.AsyncClient,
api_key: str,
api_secret: str,
) -> dict:
"""EP-33: Get WebSocket credential for real-time chat."""
import hmac, hashlib, time
from auth import sign, BASE_URL
timestamp = int(time.time() * 1000)
query = f"recvWindow=60000×tamp={timestamp}"
signature = sign(api_secret, query)
resp = await client.get(
f"{BASE_URL}/sapi/v1/c2c/chat/retrieveChatCredential?{query}&signature={signature}",
headers={"X-MBX-APIKEY": api_key, "clientType": "web"},
)
return resp.json().get("data", {})
async def stream_chat(api_key: str, api_secret: str):
"""Connect to real-time chat WebSocket and handle messages."""
async with httpx.AsyncClient() as client:
cred = await get_chat_credential(client, api_key, api_secret)
wss_url = cred["wssUrl"]
listen_key = cred["listenKey"]
async with websockets.connect(wss_url) as ws:
# Authenticate with listenKey
await ws.send(json.dumps({"listenKey": listen_key}))
async for message in ws:
event = json.loads(message)
order_no = event.get("orderNo")
content = event.get("content")
print(f"New chat message for order {order_no}: {content}")
# Process message, send auto-reply, mark as read...
async def get_chat_messages(
client: httpx.AsyncClient,
api_key: str,
api_secret: str,
order_no: str,
) -> list[dict]:
"""EP-34: Retrieve paginated chat messages for an order."""
import hmac, hashlib, time
from auth import sign, BASE_URL
timestamp = int(time.time() * 1000)
query = f"page=1&rows=20&orderNo={order_no}&recvWindow=60000×tamp={timestamp}"
from auth import sign
signature = sign(api_secret, query)
resp = await client.get(
f"{BASE_URL}/sapi/v1/c2c/chat/retrieveChatMessagesWithPagination?{query}&signature={signature}",
headers={"X-MBX-APIKEY": api_key, "clientType": "web"},
)
return resp.json().get("data", [])7. Error Handling Patterns
Binance C2C errors fall into predictable categories with well-defined recovery patterns. See the full Error Codes reference. Here are the ones you'll encounter most in a bot:
-1021 / -1022TimestampCall /api/v3/time, compute offset, rebuild query with fresh timestamp, retry
187049surplusAmountRemove surplusAmount from EP-7 body. Only send advNo + price
187055Price rangeBinary search within allowed range — find first valid price that doesn't overlap
429 / -9000Rate limitExponential backoff. Back off to 100ms min; double on each consecutive hit
83229 / 83230Ad offlineMark ad as paused. Inspect ad status before next reprice cycle
import asyncio
RETRY_WITH_SYNC = {"-1021", "-1022"}
RETRIABLE = {"-1000", "429", "-9000"}
VOLUME_SYNC = {"187049", "187040", "187031"}
AD_OFFLINE = {"83229", "83230"}
async def handle_binance_error(code: str, context: dict) -> str:
"""Return action string for the caller."""
if code in RETRY_WITH_SYNC:
return "sync_and_retry"
if code in RETRIABLE:
await asyncio.sleep(context.get("backoff", 1.0))
return "retry"
if code in VOLUME_SYNC:
await asyncio.sleep(0.3)
return "refetch_and_retry"
if code in AD_OFFLINE:
return "pause_ad"
if code == "187055":
return "find_valid_price"
return "abort"8. Rate Limiting Best Practices
Binance C2C applies three rate limiting layers: per-endpoint weight, global RPS, and a soft limit on the public BAPI. See the full Rate Limiting guide. Key practices:
5 RPS global ceiling
Never exceed 5 requests per second across all Binance C2C endpoints combined. Use a shared token bucket.
100ms between EP-0 pages
When paginating the public order book, add a 100ms delay between pages to avoid triggering empty-response soft limits.
Rotate User-Agent on EP-0
The BAPI endpoint detects default library headers earlier. Rotate browser-like User-Agent strings to extend soft limit threshold.
Circuit breaker on consecutive failures
After 15 consecutive Binance errors, open a circuit breaker for 45 seconds. Do not attempt new API calls in open state.
Or skip the months of development and use AutoP2P
Everything described in this guide — market data polling, price-only updates, adaptive auth, rate limiting, circuit breakers, error recovery, chat integration — is already built into AutoP2P and running in production across multiple VPS nodes. Repricing happens in under 1 second.
View Pricing