Saltar al contenido principal

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.

EP-0EP-4EP-7EP-8EP-13EP-15EP-33EP-34

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:

auth.py
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&timestamp={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.

Semantic note: 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.
market_data.py
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.

ad_manager.py
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.

order_handler.py
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.

chat.py
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&timestamp={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&timestamp={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 / -1022Timestamp

Call /api/v3/time, compute offset, rebuild query with fresh timestamp, retry

187049surplusAmount

Remove surplusAmount from EP-7 body. Only send advNo + price

187055Price range

Binary search within allowed range — find first valid price that doesn't overlap

429 / -9000Rate limit

Exponential backoff. Back off to 100ms min; double on each consecutive hit

83229 / 83230Ad offline

Mark ad as paused. Inspect ad status before next reprice cycle

error_handler.py
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