Auto-delivery in a Telegram shop bot: an order flow that never double-charges
A Telegram shop bot has one job after the buyer pays: create the order, get the code, put it in the chat. The hard part is money — a retry that charges you twice, a poll loop that burns the rate limit, a refund that lands on your balance while the buyer waits. Here is the flow, with the real statuses and error strings of the RSC API v1.
What you automate, and where it breaks
Three hops: the buyer pays your bot, the bot creates an order over REST, the bot returns a code or a confirmation. Two hops move different money: the buyer pays you; the order is paid from your prepaid RSC balance in USDT (TRC-20, BEP-20, TON, Aptos).
- The request times out and the bot retries. There is no idempotency key, so the retry is a second paid order: charged twice, one code never sold.
- The bot polls once a second from the chat handler. A few buyers at once exhaust the per-minute budget, and 429 hits the call that would have fetched the code.
- The order cannot be filled and the platform refunds it — to you, not to your buyer. Nothing moved in the bot, and a minute of silence becomes a review.
Four states, and what the buyer sees in each
All of it rests on a row in your own database, written before the API call and outliving it. The buyer's message follows the state, not the last response.
- new — the buyer paid you. The row holds your own order id, the product and your price. Bot: create the order.
- placing — the request is in flight, or you do not know whether it landed. Bot: wait, then reconcile — never retry from here.
- waiting — you have a platform order number, status created or processing. Bot: poll on a schedule. Buyer: “order #… in progress, the code appears in this chat”.
- done / refunded — terminal. Either the codes are stored and sent, or the charge is back on your balance and you owe your buyer a refund.
Tip
The rule that prevents double charging: write the row in state placing BEFORE the request goes out, and let only a platform order number move it forward — never an HTTP error.
Step 1 — the key and the first request
The key is issued with the account, in the dashboard's API section: rsc_live_ plus 48 hex characters, sent as Authorization: Bearer <key>. It spends your balance, so it lives in the server environment only. If it leaks, regenerate it on the Profile page; the old key dies instantly.
GET /api/v1/me is the liveness check — account status, balance_usd, total_orders. Without a valid key it answers 401 with {"error":{"type":"unauthorized","message":"…"}}, the envelope every error uses.
Step 2 — catalogue and prices
Catalogue prices are already yours: price_usd is what the balance is charged, times quantity. Compute retail from that number, not from face value. Money is strings with four decimals ("2.8100") — parse as decimal, never float.
Cache the catalogue for minutes, not seconds, and invalidate it when an order returns unavailable or out_of_stock. Gift-card entries carry min_quantity and max_quantity — 1 to 100 codes per order; a top-up is one offer per order.
Step 3 — creating the order
One endpoint per family: POST /api/v1/gift-cards/order (category_id, card_id, quantity), /api/v1/top-ups/order (category_id, offer_id, fields matching the category's fields[].key) and /api/v1/steam-topup/order (steam_login, currency, amount). The balance is charged on creation; the response carries number, status, charged_usd and channel. Save number in the write that moves the row to waiting.
HTTP 200 does not mean delivered, or even processing: if the order could not be placed, the charge is returned on the spot and the first response can carry status refund. Read status from the body.
On a timeout, do not resend. Call GET /api/v1/orders?limit=20 — newest first — and look for one created around your request: right type, same card_id or offer_id and fields, channel api. Found: adopt its number; not found with the balance unchanged: order again.
Step 4 — polling the status
GET /api/v1/orders/{number} returns the order with its full status_history. Poll while status is created or processing; terminal statuses are completed, failed and refund — lowercase, exactly so.
- Read the creation response first: fast items often come back completed with the codes already filled in.
- Then every 5 seconds for a minute, every 20 seconds to five minutes, once a minute after — the interval widens with the order's age.
- Cap the chat at about fifteen minutes: switch to “still working, the code lands here” and hand the row to a slow background poller.
- Never poll a terminal order, or one you are not delivering now. Edit one message in place instead of sending one per poll.
Step 5 — delivery, and the products with no codes
Gift-card codes arrive in the codes array and game keys in keys, filled only once status is completed; before that the array is empty. Empty is not an error — it means “not yet”.
Game top-ups have no codes at all: the value goes to the player's account by the identifier you submitted, a Steam top-up to the login — completion is the delivery. So the chat text differs: a code order sends the code and how to redeem it, a top-up sends “delivered to ID …”.
Tip
Store the codes in your own database before you send the Telegram message, and mark the row done only after the send succeeds.
Money, when it goes wrong
If an order cannot be fulfilled — out of stock, the account or region rejected, a timeout — the platform credits the full charge back to your balance by itself: status refund, a credit line naming the order in your balance history, the money spendable immediately. No ticket.
Nothing goes back to your buyer, though: they paid you, in a rail the platform never sees. So the refunded branch has two actions — refund the buyer, and say so in the chat.
failed means it could not be delivered; refund means the charge came back, and only refund makes your balance whole. A failed order that never became a refund is worth a ticket in the dashboard or @supportresellcodes on Telegram — there is no email.
Error strings, line by line
Every error is {"error":{"type":"…","message":"…"}} with a matching status: 400 invalid_request, 401 unauthorized, 403 forbidden, 404 not_found, 429 rate_limited. Branch on type and the endpoint, never on the message text.
- insufficient_balance — 400. Nothing created, nothing charged. Do not retry: refund or queue the buyer and top up. A floor check on balance_usd makes it a warning, not an outage.
- provider_busy — 429 with a capacity message and no Retry-After. Not your rate limit: capacity for that item is full, nothing was charged. Retry once after a minute, then refund the buyer.
- missing_field_<key> — 400, “Field player_id is required”. Your bot did not collect an input the category declares — a form bug, not a transient failure.
- invalid_field_<key> — 400. The value arrived but is not acceptable (empty after trimming, or over 200 characters). Ask the buyer to re-enter that field.
- 401 unauthorized — key missing, malformed or regenerated; 403 forbidden — account suspended or address blocked. Stop the worker and open a ticket.
- 429 rate_limited with a Retry-After header — that one is your budget. Sleep exactly that many seconds.
- server_error (category_unavailable, not_configured, network) — retry a read, never blind-retry an order that may already be charged.
Rate limits and how not to burn them
Every response carries the budget in X-RateLimit-Limit-Minute, X-RateLimit-Remaining-Minute and the matching daily pair. A new key starts at 30 requests a minute and 5,000 a day; limits are per account and can be raised, so read the headers rather than hard-code them.
Thirty a minute goes fast when spent badly: one order polled every three seconds is twenty requests a minute, and two in flight exceed the default.
- One process, one queue: a worker walks the rows in state waiting by next-poll time, not a loop per chat handler.
- Keep a floor: when X-RateLimit-Remaining-Minute drops below about five, park the queue until the next minute. Order creation should never be the refused call.
- Do not poll the order list to find work — your database knows what is open.
What we do not have, and how to live with it
- No webhooks. Delivery is discovered by polling GET /orders/{number}; there is no callback URL to register.
- No idempotency key on order creation. The substitute is your own order id, written before the request, plus reconciliation instead of a blind retry.
- No sandbox and no test key: the first run is the live API with real balance — deliberately cheap, since the minimum top-up is $3, there is no minimum order, and an entry-level top-up pack (cents) or a $0.15 Steam transfer covers the whole cycle.
Bot orders and hand-placed orders share one account, balance and history; every order carries a channel field, api or panel.
Checklist before the bot goes public
- Every order row, with your own order id, is written before the request leaves.
- status is read from the creation body, and refund on creation is handled.
- Timeouts reconcile through GET /orders; no code path retries an order request.
- An empty codes array means “not yet”; top-up messages never promise a code.
- The refund branch refunds your buyer and writes to the chat by itself.
Statuses, error types, headers and limits above are the API as documented on the day of publication: the guide is at /docs and the reference at /docs/reference, both outside the language prefixes (/docs, not /en/docs).
Build the integration this week
Create an account, copy the API key from the dashboard and run the whole cycle on a $3 balance before your first buyer ever sees the bot.
