Wholesale Telegram Stars, Telegram Premium and other digital goods over a REST API. The third product type is Telegram NFT gifts, see the section below. Payment is up front: you top up a prepaid balance, the partner price of each order is debited from it, and you sell to your end customer at your own price. About the program — partner program; news and case studies — blog.
currency field:
"13.50", not 13.5. For Stars and Premium that is US dollars (USDT), currency: "USD", two decimals. For NFT gifts it is
Toncoin, currency: "TON", three decimals. A string rather than a number is deliberate: amounts are computed with decimal
arithmetic, and converting them to float would distort the cents.POST /orders with a mandatory idempotency_key.
At that moment the partner price is debited from your balance.You take payment from the end customer yourself, in your own system. Your margin is the difference between your price and the partner price.
The price is the same for every partner. There are no tiers, no volume thresholds and no personal discounts, and nothing to negotiate: one number for everyone who connects.
Stars and Premium prices are denominated in USDT; no local-currency exchange rate enters the calculation. The value is recalculated in step with the market, so it is worth re-reading it from the catalog before you order.
NFT gifts are priced in TON. Each gift has its own price, valid while it is on sale; it already includes the transfer
to the recipient and the network fee. No TON/USD exchange rate enters the calculation: you pay TON from your NFT balance, and the TON
price does not move with the rate. The dollar equivalent (price_usd_ref) is for reporting only, not for payment.
Current prices — GET /products. The exact order amount is fixed in the response to
POST /orders; the price_per_star field in the catalog is for reference (6 decimals),
you do not need to multiply it yourself.
Balances are prepaid purchasing balances: they can only be spent on orders and cannot be withdrawn. The balance for Stars and Premium is kept in US dollars (USDT). The balance for NFT gifts is separate and kept in Toncoin (TON): NFT gifts are bought with TON only, so this balance is topped up with TON only. Funds are not moved or converted between the two: USDT is never spent on NFT gifts, TON is never spent on Stars or Premium.
How to top up. In the dashboard, choose the balance (USDT or TON), the network and an amount — the service will give you an exact amount down to the cent and an address. Send exactly that: the payment is identified by its amount; for TON you may additionally put the code from the request into the transfer comment — optional, it only speeds up review if the amount does not match. USDT is accepted on TON, TRC-20 and BEP-20; TON — Toncoin only, on the TON network, to a separate address shown in the dashboard; the minimum top-up is 10 TON. Crediting is automatic, usually within a minute of network confirmation.
Do not mix up the addresses. The USDT address credits USDT only, the TON address credits Toncoin only. Any other tokens or coins sent to these addresses are not credited and cannot be returned.
Activation. The first top-up starts at $10.00 in USDT or 10 TON. Until it lands,
no API key can be issued and API requests are rejected with 403 partner_inactive; the catalog and prices in the dashboard are visible right after sign-up.
Your remaining balances — GET /me: the balance field (USD) and nft_balance (TON), both also in the balances object.
If the required balance is not enough, the order is rejected straight away with 402 insufficient_balance (the response carries the
currency and balance of that account): no money is reserved and the order does not hang waiting.
Base: https://pay.tgsuperstars.com/api/partner/v1. Authentication:
Authorization: Bearer pk_live_... (the key is issued in the dashboard; optionally
an IP whitelist per key). Machine-readable spec:
openapi.json.
Idempotency: the same idempotency_key in POST /orders →
the same order and exactly one debit. Retrying a request after a timeout is safe — this is the main way
to avoid losing or duplicating an order.
curl -H "Authorization: Bearer $KEY" https://pay.tgsuperstars.com/api/partner/v1/me
{
"partner_id": 100200300,
"status": "active",
"balance": "42.50",
"currency": "USD",
"balances": {"USD": "42.50", "TON": "12.370"},
"nft_balance": "12.370",
"nft_currency": "TON",
"pricing": {"note": "current prices — GET /products"},
"limits": {"api_rate_limit": {"per_minute": 120, "burst": 30},
"stars_min_per_order": 50,
"stars_max_per_order": 50000,
"nft_max_order_ton": null, "nft_catalog_rate_limit": {"per_minute": 30},
"nft_orders_rate_limit": {"per_minute": 60}}
}
balance is the USD balance for Stars and Premium; nft_balance is the TON balance for NFT gifts. The balance/currency fields are kept for compatibility and always refer to the dollar balance.
curl -H "Authorization: Bearer $KEY" https://pay.tgsuperstars.com/api/partner/v1/products
{
"currency": "USD",
"stars": {"price_per_1000": "…", "price_per_star": "…",
"currency": "USD", "max_per_order": 50000},
"premium": {"3": {"partner_price": "…", "currency": "USD"},
"6": {"partner_price": "…", "currency": "USD"},
"12": {"partner_price": "…", "currency": "USD"}},
"nft": {"available": true, "pricing_model": "per_gift", "currency": "TON", "balance": "nft_balance",
"catalog": "/nft/gifts", "quote": "/nft/gifts/{gift}", "max_order_ton": null}
}
The example shows no prices: they move with the market, and the current ones come in this response. Amounts are strings with two decimal places; price_per_star has six.
NFT gifts are paid from the TON NFT balance (currency: "TON"); catalog prices and quotes are in TON.
If a price is temporarily unavailable, you get
{"unavailable": true, "reason": "cost_unavailable"} instead of prices — this is not an HTTP error
but a straight signal that "we are not selling right now". An order will not be accepted at that moment either.
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"type":"stars","username":"@your_customer"}' https://pay.tgsuperstars.com/api/partner/v1/validate/recipient
For premium, the response additionally contains already_premium: true
when the recipient currently has an active subscription. This is not a rejection: an order
for such an account is accepted, but delivery will be deferred — see
Deferred Premium delivery.
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"type":"stars","idempotency_key":"ord-1","params":{"quantity":100,"recipient":"@your_customer"}}' https://pay.tgsuperstars.com/api/partner/v1/orders
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"type":"premium","idempotency_key":"ord-2","params":{"months":3,"recipient":"@your_customer"}}' https://pay.tgsuperstars.com/api/partner/v1/orders
If the recipient already has an active Telegram Premium subscription, the order is still accepted. A subscription cannot be granted on top of an active one, so delivery is deferred and happens on its own as soon as the recipient's current subscription ends. Nothing has to be enabled for this — it applies to every key.
Funds are debited from your balance immediately, when the order is created.
The order stays in processing and automatically retries delivery once a day.
The waiting time is not known in advance: it equals whatever is left of the recipient's
current subscription and may be months.
failed and the debited amount is
automatically returned to your balance.409 deferred_order_exists. Wait for the first one: delivering it extends the
recipient's subscription, so the second order would only wait longer.If deferral does not suit you, call validate/recipient before ordering and
skip the order when the response contains already_premium: true.
Valid Premium terms are 3, 6 and 12 months. The third order type is nft —
Telegram collectible gifts, see the section below. There are no other types:
type accepts stars, premium and nft.
{
"order_id": "PA-12345",
"type": "stars",
"status": "processing",
"partner_price": "…",
"currency": "USD",
"retail": null,
"quantity": 100,
"recipient": "@your_customer",
"created_at": "2026-08-10T18:20:31.000000",
"refunded": false
}
partner_price — the amount debited from your balance, as a string. On archived orders
created before 2026-08-10 it is a number in rubles with currency: "RUB";
go by currency, not by the type of the value.retail — always null: the website's retail price is in rubles,
which has no place in a dollar response. An explicit null rather than zero, so that nobody
reads it as "free".quantity — the number of stars for stars and the number of months
for premium. The minimum star order is 50; anything less
is rejected with bad_quantity before any debit.GET /orders/{id} and
GET /orders?status=&type=&from=&to=&cursor=&limit=.
Statuses: processing | completed | partial | canceled | failed. Outgoing webhooks
(order.completed | order.failed | order.refunded) are signed with the header
X-Signature: hex(hmac_sha256(body, your webhook_secret)) — verify the signature
and handle them idempotently (delivery is "at least once"). Method, headers, body examples and
signature verification are in the Webhooks section.
The delivery address (webhook_url in the order body, or the one set in the
cabinet) is validated twice. On input — instantly and without DNS: scheme, host, port, no
credentials, literal address; a bad address returns 400 bad_webhook_url with a
machine-readable reason and the order is not created. Before EVERY delivery attempt
the address is validated again, this time with resolution: if the host resolves to a loopback,
private or internal address, no request is made at all (the delivery record gets
status=failed and rejected_reason), and the connection goes exactly to
the verified address — DNS is not re-read between the check and the request. A domain move or a
DNS failure does not burn the webhook: it is retried on the 1m/5m/30m/2h/6h schedule. The
label field in responses is always in Russian.
We send the order outcome to your address ourselves, so polling GET /orders/{id} is optional.
The address and secret are set in the cabinet: the "API" tab, "Webhook" block — the URL field and the
"Save" button. The first save creates a secret; it is shown once, so store it on your side. To issue a
new one, turn on "Regenerate secret" and click "Save": the old secret stops working immediately, including
for retries of webhooks already sent. For a single order the address can be overridden with
webhook_url in POST /orders, but the secret still comes from the cabinet: if the
"Webhook" block has never been saved, click "Save" once, even with an empty URL. Until both an address and
a secret are set, no webhooks are sent, and events from that period are not delivered later — check their
outcome via GET /orders/{id}.
POST to your address, Content-Type: application/json, the body is UTF-8 JSON.
We wait up to 10 seconds for a response. Redirects are not followed: a 3xx response counts as a failure.
| Header | Contents |
|---|---|
X-Signature | hex(hmac_sha256(body, webhook_secret)) — body signature |
X-Timestamp | Send time in unix seconds, different for every attempt |
X-Signature-V2 | hex(hmac_sha256(X-Timestamp + "." + body, webhook_secret)) —
signature that includes the time, so a captured request can't be replayed later |
X-Signature-Version | 2 |
X-Webhook-Event | order.completed, order.failed or
order.refunded — same as event in the body |
X-Webhook-Id | Delivery ID: a 24-character hex string, the same across all retries of one webhook |
X-Webhook-Attempt | Attempt number: 1, 2, … |
The signature is computed over the raw body bytes exactly as received. Verify it
before parsing the JSON and never rebuild the string: the body has spaces after : and
,, and non-ASCII characters (…, ⭐, the Russian text in label) are sent as
UTF-8 without escaping. Re-serialized JSON gives a different signature.
Always {"event": …, "order_id": …, "data": {…}}. For order.completed and
order.failed, data is the order object in the same format as the
GET /orders/{id} response, minus the retail field. It is a snapshot taken at the
moment of the event. Stars, order completed:
{
"event": "order.completed",
"order_id": "pa_6f1c2a9e-1b7d-4c3e-9a51-2f0d8e4b7c10",
"data": {
"order_id": "pa_6f1c2a9e-1b7d-4c3e-9a51-2f0d8e4b7c10",
"type": "stars",
"status": "completed",
"partner_price": "…",
"currency": "USD",
"quantity": 250,
"recipient": "@your_customer",
"created_at": "2026-10-04T12:59:07.329000",
"completed_at": "2026-10-04T12:59:15.616000+00:00",
"label": "…",
"refunded": false
}
}
Premium is the same object with "type": "premium"; quantity is the number of
months.
partner_price — the amount debited from your balance, as a string in currency.created_at — UTC; today it has no offset but may arrive with +00:00, so
accept both. completed_at — with +00:00, null until the order is
completed (it can also be null for an order closed manually). Fractional seconds may be
missing: use an ISO parser rather than a pattern with fixed fractional seconds.label — a ready-made order summary, always in Russian.Order not fulfilled: the money is returned to your balance automatically and two events arrive —
order.refunded and order.failed. Their order of arrival is not guaranteed.
order.failed carries the order object with status failed or
canceled and refunded: true once the money is back on your balance. The refund is
also confirmed by a separate order.refunded event, whose body is different:
{
"event": "order.refunded",
"order_id": "pa_6f1c2a9e-1b7d-4c3e-9a51-2f0d8e4b7c10",
"data": {
"order_id": "pa_6f1c2a9e-1b7d-4c3e-9a51-2f0d8e4b7c10",
"refund_amount": 1.23,
"reason": "stars_failed",
"currency": "USD"
}
}
refund_amount is a number, not a string: an exception to the money-as-strings rule,
kept for compatibility. For NFT refunds (currency: "TON") the body also carries
refund_amount_ton, the same amount as a string with three decimals; reconcile money against
it. reason is a machine-readable cause such as stars_failed or
premium_canceled.
If the transfer was already sent but delivery was not confirmed, there is no automatic refund: the order
goes to manual review and no webhooks are sent until it ends. Meanwhile GET /orders/{id} shows
status: "failed" and refunded: false. This is not the final outcome, so do not
place a replacement order. If the goods arrived, order.completed follows and the status changes
to completed; otherwise a refund follows.
Any 2xx response (200–299) received within 10 seconds counts as delivered. We don't read the response body; it can be empty. Anything else — 3xx, 4xx, 5xx, a timeout, a connection error — is a failure: we retry after 1 min, 5 min, 30 min, 2 h and 6 h, up to 6 attempts in total, then stop. Respond right after verifying the signature and do any long processing after the response.
Delivery is "at least once": the same webhook may arrive more than once. Drop duplicates by
X-Webhook-Id or by the order_id + event pair. If a webhook never
arrives, the order status is always available via GET /orders/{id}.
Python:
import hmac, hashlib, time
def verify(raw_body: bytes, headers, secret: str) -> bool:
key = secret.encode()
sig = hmac.new(key, raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, headers.get("X-Signature", "")):
return False
# X-Signature-V2 includes the time and rejects old requests
ts = headers.get("X-Timestamp", "0")
sig2 = hmac.new(key, ts.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return (hmac.compare_digest(sig2, headers.get("X-Signature-V2", ""))
and abs(time.time() - int(ts)) < 300)
PHP:
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_TIMESTAMP'] ?? '0';
$sig = hash_hmac('sha256', $raw, $secret);
$sig2 = hash_hmac('sha256', $ts . '.' . $raw, $secret);
if (!hash_equals($sig, $_SERVER['HTTP_X_SIGNATURE'] ?? '')
|| !hash_equals($sig2, $_SERVER['HTTP_X_SIGNATURE_V2'] ?? '')
|| abs(time() - (int)$ts) > 300) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
http_response_code(200);
Node.js (Express, the body must stay raw: express.raw({ type: 'application/json' })):
const crypto = require('crypto');
function verify(raw, headers, secret) {
const hex = (data) => crypto.createHmac('sha256', secret).update(data).digest('hex');
const eq = (a, b) => a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
const ts = headers['x-timestamp'] || '0';
return eq(hex(raw), headers['x-signature'] || '')
&& eq(hex(Buffer.concat([Buffer.from(ts + '.'), raw])), headers['x-signature-v2'] || '')
&& Math.abs(Date.now() / 1000 - Number(ts)) < 300;
}
Use this example to check your implementation. Secret test_secret_123, the body is one
line, exactly these bytes:
{"event": "order.completed", "order_id": "pa_6f1c2a9e-1b7d-4c3e-9a51-2f0d8e4b7c10", "data": {"order_id": "pa_6f1c2a9e-1b7d-4c3e-9a51-2f0d8e4b7c10", "type": "stars", "status": "completed", "partner_price": "…", "currency": "USD", "quantity": 250, "recipient": "@your_customer", "created_at": "2026-10-04T12:59:07.329000", "completed_at": "2026-10-04T12:59:15.616000+00:00", "label": "…", "refunded": false}}
Expected signatures: X-Signature = 894ddfb9144d18d41b2965146364acc36b03295b8318d82951f672ce319bc20e; with X-Timestamp: 1791118800
X-Signature-V2 = dab0cc24bb63dd061ad60abeb7313d909be63541748287f3e2154d48e14c9c3f. Turn off the X-Timestamp age check for this
example: its time is in the past.
An NFT gift is a Telegram collectible: a single item with its own number, model, backdrop and symbol, living on the TON network and transferable between accounts. We buy it on the collectible gift market and transfer it to the recipient by username; the wallet and purchase queue are on our side. The gift is delivered anonymously: the sender name is hidden, and neither your brand nor our service is shown to the recipient. You pay from your TON NFT balance — a separate balance topped up with Toncoin only (see Balance and top-up). The key is the same one you use for Stars and Premium.
Every gift is a single item. Each has its own price, valid while the gift is
listed. Between the catalog and your order the gift may go to another buyer — then the order is
normally rejected before any debit with gift_not_for_sale; if the sale happened
after the debit, the order becomes failed and the amount is refunded automatically.
curl -H "Authorization: Bearer $KEY" https://pay.tgsuperstars.com/api/partner/v1/nft/collections
curl -H "Authorization: Bearer $KEY" "https://pay.tgsuperstars.com/api/partner/v1/nft/gifts?collection=plushpepe&sort=price_asc"
{
"currency": "TON",
"items": [
{"gift": "plushpepe-1234", "name": "Plush Pepe", "num": 1234, "collection": "plushpepe",
"image": "https://…/plushpepe-1234.webp", "price_ton": "…", "partner_price": "5.366", "currency": "TON",
"price_usd_ref": "7.19"}
],
"count": 60, "next_cursor": "60", "source_status": "live"
}
partner_price is your price in TON — that is what gets debited from the NFT balance. price_usd_ref
is a dollar equivalent at the current rate, for reference only; it may be null when the rate is temporarily
unavailable — this does not affect ordering.gift is the gift identifier — the same one you see in the public link
t.me/nft/plushpepe-1234. An order accepts either the link or the identifier.collection is required. A page holds up to 60 gifts; request the next one with
cursor=next_cursor. Order is sort=price_asc (default) or price_desc.applied_filters in the response. It holds what was actually applied:
collection, sort and attr. An unknown parameter name
comes back in ignored_params; an unknown attribute value is passed upstream
as is, and if it is not recognised there the feed comes back UNfiltered. A typo in a filter does
not mean «no matching gifts», it means «there was no filter»: compare what you sent with what was
applied before you order.attr[Model]=…, attr[Backdrop]=…, attr[Symbol]=…
(repeatable). Attributes are always present in the card GET /nft/gifts/{gift}.partner_price in the catalog is indicative as of the response. The exact amount is
fixed only by POST /orders. source_status: "stale" means cached catalog
data — check the card before ordering; "unavailable" means the catalog is temporarily
down, items is empty and the response carries reason: "upstream_unavailable"
plus retry_after (seconds) — wait that long.next_cursor: null — no more pages.curl -H "Authorization: Bearer $KEY" https://pay.tgsuperstars.com/api/partner/v1/nft/gifts/plushpepe-1234
{
"gift": "plushpepe-1234", "name": "Plush Pepe #1234", "num": 1234, "collection": "plushpepe",
"image": "https://…", "attributes": {"model": "Frog Prince", "backdrop": "Emerald", "symbol": "Crown"},
"available": true, "price_ton": "…", "partner_price": "5.366", "currency": "TON", "price_usd_ref": "7.19",
"quoted_at": "2026-09-17T12:00:00+03:00", "max_order_price": null
}
The gift price: partner_price is the final amount in TON, the same for every partner;
the transfer to the recipient and the network fee are already included, nothing is added on top. Rounded up to 0.001 TON.
No TON/USD exchange rate enters the calculation: you pay TON from your NFT balance, and the TON price
does not move with the rate. The dollar equivalent (price_usd_ref) is for reporting only, not for payment.
available: false means the gift is sold or delisted; an order for it is not accepted.
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"type":"nft","username":"@your_customer"}' https://pay.tgsuperstars.com/api/partner/v1/validate/recipient
The recipient must exist and be reachable. Every example on this page uses
the placeholder @your_customer — substitute a real username. A username that does not
resolve (a typo, a deleted or hidden account) returns 400 recipient_not_found, and this
check runs BEFORE price (only max_price syntax is parsed earlier) — so it masks any other error in
the same request. Verify the recipient with the call below before you debug the rest.
Same resolver as for Stars: the recipient is a regular Telegram account, no wallet needed.
This is a preliminary check: whether the gift can be transferred to this exact account is confirmed
while the order is processed. In the rare case the account exists but cannot receive the gift, the order
ends as failed with stage: "create", reason: "invalid_recipient" and
a full automatic refund — no money is lost, but double-check the username.
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"type":"nft","idempotency_key":"ord-3","params":{"gift":"plushpepe-1234","recipient":"@your_customer","max_price":"5.400"}}' https://pay.tgsuperstars.com/api/partner/v1/orders
{
"order_id": "PA-12345",
"type": "nft",
"status": "processing",
"partner_price": "5.366",
"currency": "TON",
"price_usd_ref": "7.19",
"retail": null,
"quantity": 1,
"recipient": "@your_customer",
"label": "NFT Plush Pepe #1234 for @your_customer",
"created_at": "2026-09-17T12:00:05.120000",
"completed_at": null,
"refunded": false,
"gift": {"gift": "plushpepe-1234", "name": "Plush Pepe #1234", "num": 1234, "collection": "plushpepe",
"image": "https://…", "price_ton": "…",
"attributes": {"model": "Frog Prince", "backdrop": "Emerald", "symbol": "Crown"}},
"delivery": null,
"failure": null
}
label is a ready-made description of the order for display. Do not concatenate
gift.name with gift.num yourself: the number is already part of
name, and such a concatenation prints it twice.created_at is a legacy string without an offset, the value
is UTC; the top-level completed_at and delivery.completed_at always
carry an offset. Do not subtract one from the other without normalising to UTC — the difference
would be off by exactly your time zone.idempotency_key returns the same order and the header
Idempotent-Replay: true. Branch on that header, not on the status code: side effects
on your side (charging your customer, writing to your database, sending an email) must NOT be
repeated for such a response.402 insufficient_balance with currency: "TON";
the dollar balance is never used for NFT gifts.params.max_price is an optional guard in TON: if the recalculated price is higher, the
order is rejected before any debit with price_changed and the current price in
current_price. Pass the price from your latest quote here. If the gift got cheaper,
the lower current price is debited.quantity is always 1 for nft. One active order per gift:
a second order for the same gift before the first completes gets 409 gift_reserved.POST /orders with
type=nft has its own counter — 60 requests per minute per partner
(all your keys together, sliding 60-second window). It is separate from the catalog and quote
counter (30 per minute per key) and applies on top of the general 120/min per key.
Every NFT order request counts, including rejected ones, except an idempotent replay: a
request with an idempotency_key that has already created an order does not consume
the limit, so a retry after a timeout always gets through. The check runs before we reach the
market and before the gift is reserved. On exhaustion you get 429 rate_limited with
scope: "nft_orders", limit, window_seconds,
retry_after and a Retry-After header (seconds until a slot frees up in the
window); no order is created, nothing is debited and the gift is not reserved — retry the same
request with the same idempotency_key after the pause. Stars and Premium orders are
not affected by this counter.
Statuses. processing — buying and transferring (usually about a minute; if the
market or the network stalls, the order waits in the queue — in rare cases up to an hour);
completed — the gift is with the recipient and the transfer is confirmed on-chain; the
order object gets a delivery block:
"delivery": {"nft_public_url": "https://t.me/nft/plushpepe-1234",
"ton_explorer_url": "https://tonviewer.com/transaction/…",
"transfer_hash": "…", "completed_at": "2026-09-17T12:04:40+03:00"}
failed — the gift was not bought, the amount is refunded to your TON NFT balance automatically
(order.refunded event, reason: "nft_failed", refund_amount in TON, currency: "TON") and the order object carries a failure block with
stage and reason. stage ∈ create | confirm | queue | pricing | balance;
reason is one of gift_not_for_sale (the listing is gone), gift_reserved,
price_above_cap (the listing got more expensive than the fixed price — we never overpay),
amount_above_cap, invalid_price, invalid_upstream_price, pricing_failed (the listing price could not be confirmed),
gift_unavailable (the market did not respond), invalid_recipient,
insufficient_funds (temporarily insufficient purchase funds on the service side — your balance is not involved),
nft_disabled, hub_rejected, queue_rejected, delivery_failed (anything else).
They all mean the same: no purchase happened, the TON is back on your NFT balance.
If the purchase went through but the transfer to the
recipient did not, the order stays in processing and goes to manual review: we either
deliver the gift or refund; there is no automatic refund in that case, so that a gift already bought
is not refunded by mistake. The only exception to "failed means no purchase" is the outcome
of such a review with a refund: the order becomes failed with stage ∈
create | purchase | transfer and reason: "manual_refund", and the money comes back with an
order.refunded event carrying reason: "nft_manual_refund".
Contact support at @tg_super_support_bot if an order has been pending for
more than an hour.
Webhooks are the same order.completed, order.failed,
order.refunded; data carries the order object with the gift,
delivery and failure blocks.
The format depends on where the error came from. Order processing errors arrive flat:
{"code": "insufficient_balance", "detail": "Insufficient balance"}
Request-level errors — authentication, rate limit, unknown type,
a missing idempotency_key — are the same object, but nested in the
detail field:
{"detail": {"code": "bad_type", "detail": "type ∈ stars|premium|nft"}}
The difference is historical and kept on purpose, so as not to break existing
clients. A reliable parse: take detail, and if it is an object, read
code from it, otherwise from the root.
| HTTP | code | What it means |
|---|---|---|
| 401 | unauthorized | Missing or invalid Bearer key |
| 403 | ip_not_allowed | IP not in the key's whitelist |
| 403 | partner_inactive | Dashboard not activated — a first top-up starting at $10.00 is required |
| 429 | rate_limited | Rate limit exceeded — reduce your request rate |
| 400 | idempotency_required | No idempotency_key passed in POST /orders |
| 403 | partner_program_unavailable | The program is temporarily unavailable |
| 400 | bad_type | type is not stars, premium or nft |
| 400 | bad_quantity / bad_months | Invalid number of stars or subscription term |
| 400 | bad_gift | Gift identifier not recognised — take gift from the catalog |
| 400 | collection_required | /nft/gifts called without collection |
| 400 | bad_cursor | Invalid cursor in /nft/gifts — pass next_cursor from the previous response |
| 400 | bad_max_price | max_price could not be parsed — a positive TON amount as a string, e.g. «5.400» (the number of decimals is not limited) |
| 400 | bad_sort | Unknown sort in /nft/gifts — only price_asc or price_desc |
| 400 | bad_attr / bad_param | Unknown filter or query parameter name (compare applied_filters and ignored_params in the response) |
| 409 | gift_not_for_sale | Gift already sold or delisted — nothing was debited |
| 409 | gift_reserved | There is already an unfinished order for this gift |
| 409 | price_changed | Price is above the max_price you passed — see current_price (TON) |
| 400 | recipient_required / recipient_not_found | Recipient not specified or not found. The recipient must exist and be reachable in Telegram; it runs before the price checks (only max_price syntax is parsed earlier), so verify the username with /validate/recipient first |
| 400 | bad_webhook_url | The webhook_url in the order body is not a valid delivery target — the order was NOT created and the address was not stored. The body carries reason: bad_url (not a string, empty, whitespace or control character, non-ASCII — submit IDN hostnames in punycode), bad_scheme (http/https only; rejecting http is a setting), bad_host (no host, non-ASCII host, percent-encoding in the host), bad_port (port not a number or outside 1..65535; restricting it to 80/443 is a setting), credentials_in_url (credentials in the URL), private_host (the address points to localhost, a private network, an internal zone or our own infrastructure), too_long (a length limit is a setting). The check on input never asks DNS: it is instant and deterministic. If the address is syntactically valid but its DOMAIN resolves into a private network, the order is created and no delivery is ever sent — the delivery record gets status=failed and rejected_reason=private_host, and the next order with that address is rejected with 400 right away |
| 409 | idempotency_key_reuse | Same idempotency_key with different parameters (including the case where a debit is already armed for another order under this key) |
| 409 | order_in_progress | An order with this key is still being created — retry in a second |
| 409 | deferred_order_exists | This account already has an unfinished Premium order — wait for it to be delivered |
| 402 | insufficient_balance | Not enough funds on the balance for this order type: USD for Stars/Premium, TON for NFT — the response carries currency and balance — top up that balance |
| 404 | not_found | Order or gift not found (or the order belongs to another partner) |
| 502 | resolver_error | Recipient resolution failed — try again later |
| 503 | product_unavailable | Product temporarily unavailable. The body carries reason: product_disabled — switched off on our side, retrying will not help, contact support; upstream_unavailable — the upstream is down, the body and the Retry-After header carry the number of seconds before a retry makes sense |
| 503 | ledger_unavailable | The idempotency-key reuse check is temporarily unavailable (POST /orders only). The order was NOT created, the key was not claimed and nothing was debited — retry THE SAME request with THE SAME idempotency_key in a few seconds. We will not let the request through unchecked: that check is what stops a reused key for a different lot from being charged the price of the previous order |
| 429 | rate_limited | Rate limit reached. The body carries scope — which one (key for the general limit, validate_recipient, nft_catalog, nft_orders for NFT order creation, per partner) — plus limit, window_seconds, retry_after; the same value is in the Retry-After header |
/validate/* — 30 per minute;
/nft/* (catalog and quote) — 30 per minute per key;
POST /orders with type=nft — 60 per minute per partner
(all keys together, its own counter; an idempotent replay does not count).
On exhaustion — 429 with scope and a Retry-After header.429 with scope: "nft_orders" and Retry-After).What happens to Stars if delivery fails? We treat it the same way as retail: when the delivery network is down, orders queue up and are delivered once it is back — no money is lost.
I retried after a timeout — will I be charged twice? No, not if you passed the same
idempotency_key: you get the same order back and there is still only one debit.
I sent an amount that was not exact to the cent — what now? The payment is not identified automatically and goes to manual review. Contact support at @tg_super_support_bot with the transaction hash.
The gift was sold while I was placing the order? Normally the order is rejected before any debit (gift_not_for_sale).
If the sale happened after the debit, the order becomes failed and the TON is refunded to your NFT balance automatically.
Does the recipient need a wallet or Premium? No: the gift is transferred to a regular Telegram account by username.
Who does the gift come from? Anonymously: the sender is hidden. Neither your brand nor our service is shown to the recipient.
Why does the catalog price differ from the card? The catalog is cached for up to a minute and the listing price moves;
the amount from the order response is debited, never more than your max_price.
Why a separate TON balance for NFT gifts? NFT gifts are bought and transferred for TON only. If we debited dollars, the rate could move between your order and the purchase and one side would lose the difference. A TON balance removes that risk: the quoted amount is exactly what is debited.
Can I move USDT to the NFT balance or back, or withdraw TON? No. The balances are independent and prepaid: USDT is topped up and spent only on Stars and Premium, TON only on NFT gifts; neither balance can be withdrawn.
An NFT order failed — what about the money? The gift price is returned to your TON NFT balance automatically, just like with Stars. If the purchase went through but the transfer to the recipient did not, the order goes to manual review and we will contact you.
I sent TON to the USDT address (or USDT to the TON address)? Such a transfer is not credited automatically. Contact support at @tg_super_support_bot with the transaction hash.
Support: @tg_super_support_bot — for integration and payment questions. You support your own end customers.