tgpay cryptoAPI
crypto-payapireferencetokens

Merchant API reference

10 min readUpdated Nov 2, 2026

The conventions every method shares, plus the read-only catalog methods. Method-by-method pages: invoices and refunds, transfers and checks, subscriptions, webhooks.

The API follows Crypto Bot conventions for the methods documented here. Change the base URL and token, then test the methods your integration uses on testnet. Our additions are marked extension below.

A machine-readable OpenAPI 3.1 spec is also available: crypto-pay-openapi.yaml · crypto-pay-openapi.json. Feed it to code generators, API clients, or your AI tools, but check the method pages too: the schema may lag behind newer extensions.

Base URL and authentication

All methods live at https://app.tgpaycrypto.com/pay/api/<methodName>. On the testnet the base is https://testnet.tgpaycrypto.com/pay/api — separate apps and tokens, test funds with no value.

Authenticate every request with the token in the TgPayCrypto-API-Token header (TgCryptoPay-API-Token, the name before 2026-09-07, and Crypto-Pay-API-Token are accepted as compatibility aliases; if several are sent, the canonical one wins). A missing, invalid, or revoked token — or an expired restricted token, a token of a deleted app, or a token whose owner is blocked — returns 401 unauthorized.

The token has the form <app_id>:<secret> and is shown once, at creation or rotation; the server stores only its hash. See Getting started as a developer.

Tokens and scopes

Two kinds of credentials authenticate identically:

  • The main token — full access to every method, and the only key that signs webhooks.
  • Restricted tokens (extension) — up to 10 unrevoked tokens per app, including expired ones. Open More → Merchant API, choose the app, then Access and tokens. Create them under Restricted tokens, each with a label and a subset of scopes. Revoking one is instant and doesn’t touch the others; rotating the main token doesn’t touch them either. A token can also have an expiry; after that it no longer authenticates.
ScopeMethods it unlocks
(any valid token)getMe, getCurrencies, getExchangeRates
readgetBalance, getStats, getInvoices, getChecks, getTransfers, getSubscriptionPlans, getSubscriptions
invoicescreateInvoice, deleteInvoice
refundsrefundInvoice
payoutstransfer, transferBatch
checkscreateCheck, deleteCheck
subscriptionscreateSubscriptionPlan, archiveSubscriptionPlan, cancelSubscription
webhooksupdateApp

Scopes are additive and independent — invoices does not imply read, so a server that only creates invoices can use a token without access to balances or payment history. Calling a method the token doesn’t cover returns 403 scope_required. getMe reports the current token’s scopes (null for the main token) and token_name, so you can always check what you’re holding.

Payout mode

A scope says which methods a token may call; the app’s payout mode says who any of its tokens — the main one included — may pay out to. A new app pays anyone from the first minute; the owner may tighten that in More → Merchant API by opening the app, then Payout settings → Payouts through the API. Like the daily limit, this limits what a leaked token can do. Loosening the restriction asks for their PIN or biometrics; the API can only read it (getMe → payout_mode).

payout_modeWhat transfer, transferBatch and createCheck may do
anypay any user, hold a payout for an unknown @username, create bearer checks — the default, and the Crypto Bot behavior
allowlistpay only listed Telegram users. Every pinned or named check recipient must be listed. Other recipients, unknown @usernames and bearer checks return 403 recipient_not_allowed
offnothing — every payout call returns 403 payouts_disabled; invoices, subscriptions, refunds and webhooks work as usual

If a payout is rejected, the owner is notified in Telegram (at most once an hour per app) and can change the mode. The daily payout limit applies on top of the mode. What you may pay for is set by the Merchant API terms.

Requests and responses

  • Read methods are GET with query-string parameters. Money-moving methods are POST only — parameters as a JSON body, form-urlencoded, or query params (the body wins on conflict); multipart/form-data is rejected. A body with any other Content-Type (text/plain, none at all) is ignored, and only the query parameters are read. Array parameters (transferBatch.items, createCheck.recipients) work only in a JSON body.
  • Responses produced by the API handlers use a JSON envelope: success {"ok": true, "result": …}, error {"ok": false, "error": {"code": <HTTP status>, "name": "<error_name>"}}. Branch on error.name — it’s the stable machine-readable string.
  • A malformed query value on a GET method (for example, offset=abc) can be rejected before the API handler runs. Check the HTTP status and do not assume every error response has the envelope above. Send query values with the expected types.

Amounts

  • Crypto amounts are decimal strings in units of the asset ("10.5") — never JSON numbers, including in requests: {"amount": 10.5} returns 400 invalid_request; send {"amount": "10.5"} instead. The same goes for every other string parameter (asset, spend_id, comment, …).
  • An amount with more decimal places than the asset has is rounded half-up to the asset’s precision, not rejected; an amount that rounds to zero returns 400 invalid_amount.
  • Responses also carry amount_minor (extension): the integer minor-unit value as a string, because wei-scale integers can exceed the exact integer precision of a JavaScript Number.
  • Per-asset decimals come from getCurrencies — drive your amount math from there instead of hardcoding.
  • Fiat amounts have at most 2 decimal places.
  • Rates are fixed-point strings, never numbers.

Pagination

List methods (getInvoices, getChecks, getTransfers, getSubscriptions) take offset (default 0) and count (default 100, max 1000; getSubscriptions max 500) and return {"items": […]}, newest first. ID filters (invoice_ids, check_ids, transfer_ids) are comma-separated integer lists. Timestamps everywhere are ISO 8601 strings.

Idempotency: spend_id

Methods that move money take a caller-generated spend_id key (1–64 characters): required on transfer and each transferBatch item, optional on createCheck and refundInvoice (extension — use it anyway). transfer and transferBatch items share one key space. Retrying with the same key and the same parameters replays the original result instead of moving funds twice, so a timed-out request is always safe to retry. The same key with different parameters returns 409 idempotency_conflict; a retry while the original is still executing returns 409 idempotency_in_progress. A request that failed with a 4xx error doesn’t use up its key: retrying it with the same key runs it again.

Rate limits

Limits apply per app and are shared across all its tokens, with a separate counter for each method. Exceeding a limit returns 429 rate_limited:

MethodLimit
createInvoice, createCheck60 per minute
refundInvoice30 per minute
transfer30 per minute
transferBatch10 per minute
updateApp10 per minute
createSubscriptionPlan20 per minute

Two more limits protect against guessing:

  • Failed authentication: after 30 requests with a missing or invalid token from one IP address within a minute, every request from that address gets 429 rate_limited for the rest of the minute — valid tokens included. Don’t let a misconfigured job share an IP with production traffic.
  • Unknown recipients: after 10 lookups of a nonexistent user_id or @username in one app within a minute (transfer, transferBatch items, createCheck with pin_to_username), every recipient lookup in that app returns 429 rate_limited for the rest of the minute.

Additional request limits may apply. On 429 rate_limited, increase the delay before retrying. During platform maintenance, write methods return 503 maintenance while reads keep working.

Catalog and account methods

getMe

GET /pay/api/getMe — no parameters. Returns the app’s identity: app_id, name, payment_processing_bot_username, webhook_url, webhook_events (the extended webhook types the app opted into), the token introspection fields scopes / token_name described above, and payout_mode (off / allowlist / any), daily_payout_max_usd_cents (0 = no owner-set limit) and daily_payout_spent_usd_cents (spend in the rolling 24-hour window).

updateApp

POST /pay/api/updateApp — scope webhooks. Optional parameters: name (1–64 characters), webhook_url (a public HTTPS URL of up to 256 characters; an empty string removes it), and webhook_events (an array or comma-separated list of supported extended event names). An empty event list leaves only invoice_paid. Returns the same app fields as getMe.

Errors: 400 invalid_request (a name outside 1–64 characters, or a webhook_url that is not a public HTTPS address or is longer than 256 characters), 400 invalid_name (a name that looks like a link or an @username), 400 app_name_reserved (a name that uses the TgPay Crypto name) and 400 invalid_webhook_events (an unknown event name — invoice_paid included, it needs no opt-in).

getBalance

GET /pay/api/getBalance — no parameters. Returns an array with one row per supported asset, even at zero (an asset that is switched off appears only while the app still holds it): currency_code, available (spendable balance), onhold (unclaimed check funds and pending @username payouts), and amount_minor.

getCurrencies

GET /pay/api/getCurrencies — no parameters. The authoritative list of what the API supports: crypto rows (is_blockchain: true) and the fiat currencies invoices can be priced in (is_fiat: true). Each row carries code, name, decimals, and the is_stablecoin flag.

getExchangeRates

GET /pay/api/getExchangeRates — no parameters. Crypto-to-fiat quotes: source, target, rate (a fixed-point string), is_valid, is_crypto and is_fiat. Treat these rates as indicative. is_valid: true does not guarantee that a quote is current. If rates cannot be obtained, the result may be an empty array. Use the invoice’s recorded amounts and rates to reconcile an actual payment.

getStats

GET /pay/api/getStats — optional start_at / end_at (ISO 8601; the default window is the last 24 hours). Returns volume (USD value of invoices paid in the window, estimated at current rates; unpriced assets count as zero), conversion (paid/created, percent), unique_users_count, created_invoice_count, paid_invoice_count, and the effective window bounds. Both volume and conversion are JSON numbers, not strings. An unparsable date returns 400 invalid_date.

Errors every method can return

HTTPerror.nameWhen
401unauthorizedmissing, invalid, or revoked token
403scope_requiredthe token lacks the method’s scope
403payouts_disabledthe app’s payout mode is off (payout methods only)
403recipient_not_allowedthe recipient is not on the app’s recipient list (payout methods only)
400invalid_requestunparsable or invalid parameters (POST)
403daily_cap_exceededthe app’s daily payout limit would be exceeded
403verification_requiredthe owner’s outgoing operations are restricted; affects transfers, checks and refunds
503merchant_payouts_pausedapp-balance debits are temporarily paused
429rate_limitedrate limit exceeded
503maintenanceplatform maintenance (write methods)
500internal_errorunexpected server error

Method-specific errors are listed on each method’s page.

Errors without the JSON envelope

A plain-text or HTML error alone does not identify which layer returned it. Check the HTTP status, response body and response headers.

If Cloudflare returns error code: 1010, it has rejected the client based on its browser signature before the request reaches the API. Token scopes are not the cause of this Cloudflare error. A missing or nonstandard User-Agent can trigger network protection. Try a descriptive User-Agent header, such as MyShop/1.0, and retry; this is not a guaranteed fix. If the error persists, send support the HTTP status, response body and response headers, with tokens and cookies removed.