By the Birinchi Shop team

Our earlier post was about the shopper side of Birinchi Shop: an AI assistant that finds a product and places a cash-on-delivery order. This one is about the other side, the people who bring those orders in.

Birinchi Shop is a CPA (cost-per-action) network in Uzbekistan. Partners (affiliates, media buyers, Telegram channel owners) pick a product, get a tracking link and promote it. A customer orders on the landing page, our call center phones them to confirm, a courier delivers, and the customer pays cash. The partner earns a fixed payout only when the order is delivered. Payouts depend on the product, typically from 30,000 to 100,000+ UZS. Income is not guaranteed; it depends entirely on the traffic.

That gives a partner a lot to watch: which offers sell, which links convert, why orders get stuck at "operator is calling", and whether a Meta ad campaign is burning money. So we gave them a public API and an MCP server. A partner can now connect Claude or ChatGPT to their account and ask "how did I do yesterday, and what should I change?".

The hard part was not the tools. It was deciding what an AI is allowed to do on its own.

One business layer, two front doors

Everything lives under /api/v1, rate-limited to 120 requests per minute per key:

  • a REST API with an OpenAPI 3.1 spec and a reference page;
  • an MCP server (/api/v1/mcp, stateless Streamable HTTP) for AI clients;
  • webhooks and postbacks for order status changes.

REST and MCP call the same service functions. An MCP tool is a thin wrapper that declares the scope it needs:

{ name: 'list_orders', scope: 'orders:read', annotations: { readOnlyHint: true, title: 'List orders' }, inputSchema: { /* status, date range, link, limit */ }, run: (ctx, a) => svc.listOrders(ctx, a), } 

That rule kept us honest. Every permission check, rate limit and response filter exists once, and the AI path cannot quietly get more access than the REST path.

What the assistant can do

18 tools, grouped by scope:

Scope Tools
account:read get_account (balance, payout status)
offers:read search_offers, get_offer (payout, sale rate, stock, the partner's existing links)
links:read / links:write list_links, create_link
orders:read / orders:write list_orders, get_order, create_order
stats:read, events:read get_stats, list_events
insights:read get_insights (yesterday + recommendations), get_order_sale_chance
ads:read / ads:write list_campaigns, get_ads_status, get_action, pause_campaign, resume_campaign, change_campaign_budget

There are also four MCP prompts that show up in the client's / menu: Daily review, Find an offer to promote, Check my ad campaigns and Why are my orders not delivered?. Most partners are not developers; one tap is better than a well-written prompt they will never write.

The server instructions teach the model one piece of domain knowledge that matters more than any other:

sale_rate_percent is the share of finished orders that end up delivered. It matters more than payout: a 30,000 payout at 40% beats a 70,000 payout at 5%.

Without that line, models happily recommend the offer with the biggest number.

Rule 1: read-only by default

Partners connect through OAuth 2.1, no copy-pasting keys. The consent screen lists the scopes in plain language, and if the client asks for nothing specific, it gets only the read scopes:

const DEFAULT_SCOPES = ['account:read', 'offers:read', 'links:read', 'orders:read', 'stats:read', 'events:read', 'insights:read', 'ads:read']; 

Write scopes must be requested and approved explicitly. Webhook management is never available over OAuth at all; that stays with API keys created in the panel.

Implementation notes, for those building the same thing:

  • PKCE with S256 only; authorization codes are single-use and live 5 minutes.
  • Access tokens live 1 hour, refresh tokens 30 days and rotate on every use.
  • Claude.ai registers itself via a client metadata document (its client_id is a URL), so we support that in addition to dynamic registration.
  • Every connected app is stored like an API key. Revoking it in the panel, or changing the password, kills its tokens.
  • When an app connects, the partner gets a Telegram message: "New app connected: … If this wasn't you, revoke it now."

Rule 2: writes that cost other people's time are earned

create_order sends a lead to a real call center. A brand-new account with an AI attached could flood it with junk. So the scope alone is not enough:

const ORDERS_WRITE_MIN_DELIVERED = 3; // orders:write works only after the partner has 3 delivered orders, // or an admin has enabled it manually 

Creating a tracking link, on the other hand, is cheap and reversible, so links:write has no such gate.

Rule 3: stopping spend is instant, increasing it needs a human

This is the rule we like most. Meta ad campaigns spend the partner's own money, so the three ad actions are deliberately asymmetric:

  • pause_campaign runs immediately. Stopping spend is always safe, and when a campaign is clearly losing money, waiting is the expensive option.
  • resume_campaign and change_campaign_budget do not act. They create a confirmation card in the partner's Telegram bot and panel and return:
{ "id": 412, "status": "pending_confirmation", "confirmation": { "required": true, "where": ["telegram_bot", "panel"], "note": "The partner must press \"Confirm\". Poll get_action for the result." } } 

The model is told to say "please press Confirm" rather than "done", and it can check the outcome later with get_action (pending, running, done, failed, cancelled, expired).

The confirmation happens outside the AI client, in a channel the partner already trusts. A prompt injection hidden in a product description or an ad comment can at most ask for a budget increase, and that ask lands on the partner's phone with the exact amount on it.

Rule 4: the API decides what leaves the server

Two kinds of data never go out:

  1. Customer phone numbers are always masked. Partners see the last digits, enough to match an order, never the full number. The AI sees exactly the same.
  2. Internal supply-side details, such as which supplier fulfils an order or their internal error messages, are not part of the partner's world.

We enforce both with allowlist serializers. Every response object is built field by field:

function order(o) { return { id: o.id, status: STATUS_PUBLIC[o.status] || o.status, conversion_status: CONVERSION[o.status] || 'lead', // lead / approved / sale / rejected created_at: iso(o.createdAt), offer: o.offer ? { id: o.offer.id, name: o.offer.name } : { id: o.offerId, name: null }, customer: { name: o.clientFullName, phone_masked: maskCustomerPhone(o.customerPhone), region: regionLabel(o.region, null), }, payout: num(o.commissionAmount), payout_status: payoutStatus(o), // ...and nothing that wasn't written here on purpose }; } 

Add a column to the database and it does not appear in the API until someone types it here. That is slower to develop and exactly what we want.

Webhooks without surprises

Partners who track conversions in their own tools get webhooks and postbacks on order status changes. Two details save a lot of support tickets:

Events come from the database, not the code. A trigger on the orders table writes every status change into an api_events table, so it does not matter which part of the system changed the status: the call-center sync, an admin, a cron job. Webhook delivery reads that table with a cursor.

Every delivery is signed:

X-Birinchishop-Event: order.status_changed X-Birinchishop-Event-Id: evt_10293 X-Birinchishop-Signature: t=1761580800,v1=<hex HMAC-SHA256(secret, "<t>.<body>")> 

Outgoing requests go through an SSRF-safe HTTP client: the target host is resolved first and refused if any address is private, loopback or cloud metadata, and redirects are never followed. "Enter your URL" is an invitation to point us at internal services.

Bugs worth sharing

The database lives in Europe/Berlin. Our Postgres server's time zone is Berlin; Prisma reads and writes UTC. Anything written by a trigger with plain now() came out one or two hours off and broke "events since" queries. Triggers now use now() AT TIME ZONE 'utc'. If you mix ORM writes and SQL triggers, check this first.

Tool descriptions are not a place for instructions. On our store server, the first descriptions said things like "Call ONLY after…" and "Never use…". They read as rules to the model, but for a connector directory they look like a tool trying to steer the model. We rewrote them as plain facts about what each tool does, and moved behavior guidance into the server instructions.

Not every client can do OAuth headers the way you expect. One MCP directory detected our OAuth metadata and silently dropped the custom Authorization header from its health check. Plain API keys in an X-API-Key header remain supported for exactly this kind of client.

What it is for

We designed the tools around questions partners already ask our support team, not around automation. Why are my orders still at "callback"? The model can call list_orders, see which customers the call center could not reach, and use get_order_sale_chance to show which of them have a history of refusing deliveries. Which offer should I run this week? It can compare sale rates and stock with search_offers instead of just picking the biggest payout.

An autopilot was never the goal. The goal was a colleague who reads the dashboard for the partner and has to ask before spending their money.

Try it: the docs and OpenAPI spec are at birinchishop.uz/reference, and an LLM-friendly guide is at /api/v1/guide.md. The MCP server URL for Claude or ChatGPT custom connectors is:

https://birinchishop.uz/api/v1/mcp 

It needs a partner account. If you run traffic in Uzbekistan, you can sign up here. Earnings depend on your traffic and are not guaranteed.

Questions about the OAuth flow or the confirmation design are welcome in the comments.