Cytrongift API v1
Base URL:
https://cytrongift.com/api/v1
https://api.cytrongift.com/v1
The two are the same service; the second host serves nothing but the API.
Every path below is written without the /api/v1 prefix, because that is also the form the HMAC signature covers: GET /account/balance is signed as /account/balance and requested as https://cytrongift.com/api/v1/account/balance.
Response envelope
Success:
{
"ok": 1,
"data": {}
}
Error:
{
"ok": 0,
"error": {
"id": "5005",
"code": 404,
"desc": "Endpoint not found",
"advice": "Check the path against the API documentation.",
"extra": { "endpoint": "nope/nope" }
}
}
error.code always equals the HTTP status. desc and advice are localised by the Accept-Language header (ru anywhere in it selects Russian, everything else English) and come from api/v1/res/errors-{en,ru}.json. extra is machine-readable context: field names, limits, identifiers. Never parse desc; branch on error.id.
Authentication
Protected endpoints accept either credential unless noted:
- HMAC headers
- a Bearer JWT, issued by
POST /auth
Public endpoints need neither.
HMAC headers
X-Api-Key: {users.api_key}
X-Timestamp: {unix_time}
X-Signature: {hex hmac-sha256}
Signed payload, four parts joined by \n:
{METHOD}\n{PATH}\n{TIMESTAMP}\n{RAW_BODY}
PATHexcludes/api/v1, for example/account/balanceTIMESTAMPmust be within 300 seconds of the server clock (3002otherwise)RAW_BODYis the exact request body; formultipart/form-datait is the empty string- the secret is
users.api_secret; the digest is lowercase hex
The account must have api = 1 and an active status, otherwise the answer is 403 (3004 or 3005) rather than 401.
JWT
POST /auth returns an HS256 token signed with settings.api_jwt_secret. Send it as:
Authorization: Bearer {jwt}
Details and a worked example are in auth-jwt.md.
Rate limit
60 requests per minute, counted per X-Api-Key, or per client address when no key is sent. Over the limit the answer is 429 with error 3011 and a Retry-After header holding the seconds left in the current minute. The counter runs before authentication, so a wrong signature costs the same as a good one.
CORS
Browser origins are allowed one by one through settings.api_cors_origins, a comma-separated list. Empty (the default) sends no CORS headers at all; * opens it deliberately. A preflight OPTIONS on any path answers 204.
Content types
GETneeds noContent-TypePOSTandPATCHexpectapplication/json(4000otherwise)POST /uploads/imagesexpectsmultipart/form-data
Pagination
The list endpoints page forward on an id cursor.
Request: ?cursor=123&limit=20
Response carries items, limit, cursor, next_cursor and filters. next_cursor is null on the last page. Catalog lists walk ids ascending (ID > cursor), account and withdrawal lists walk descending (ID < cursor).
Public endpoints
GET /health
Database and cache probe. 200 when both answer, 503 when either does not.
{ "status": "ok", "service": "cytrongift-api", "version": "1.0.0",
"timestamp": 1788605154, "checks": { "database": "ok", "cache": "ok" } }
GET /config
Service metadata and the enumerations the write endpoints validate against.
{ "service": "cytrongift-api", "version": "1.0.0",
"currency_code": "USD", "currency_symbol": "$", "languages": ["en", "ru"],
"card_types": { "ALL": 0, "ECODE": 1, "PCARD": 2 },
"order_status": { "PROCESSING": 0, "COMPLETE": 1, "REJECTED": 2, "UNDER_REVIEW": 3 },
"signature_window": 300, "rate_limit_minute": 60, "timestamp": 1788605157 }
GET /bonus
The public bonus tier table from settings.bonus_definition.
{ "levels": [ { "level": 1, "threshold": 10000, "bonus_percent": 0.05 } ] }
GET /catalog/brands
Brands that still have at least one card on sale. Item: id, name_ru, name_en, icon (absolute URL, empty when unset), sort. Response: items, total.
GET /catalog/platforms
Platforms that still have at least one game on sale. Item: id, name_ru, name_en, icon, receipt_required (bool), sort. Response: items, total.
GET /catalog/payments
Payout systems, and the rate table the pricing uses. Item: id, code, currency, network, name_ru, name_en, icon, example, rate, rate_date, round, needs_fio (bool), sort. Response: items, total.
GET /catalog/cards
Query: brand_id, country_id, search, cursor, limit (default 50, max 500). Item: id, name_ru, name_en, icon, price, currency_id, country_id, brand_id, sort. Paginated.
price is the catalog price in the item's own currency_id; the payout in site currency is price / payments[currency_id].rate, which is what POST /sell/cards returns as amount.
GET /catalog/games
Query: platform_id, country_id, search, cursor, limit (default 50, max 500). Item: same as cards with platform_id instead of brand_id. Paginated.
GET /catalog/country2data
Query: platform_id, or brand_id. One of the two is required (4012); platform_id wins when both are sent. Item: country_id, title (in the negotiated language), code (ISO 3166-1 alpha-2, lowercase), flag (root-relative flag path). Response: items, total, filters.
Protected endpoints
POST /auth
HMAC only - a JWT cannot mint another JWT.
Body (optional):
{ "ttl": 3600 }
ttl is clamped to 300 - 86400 and defaults to settings.api_jwt_ttl.
{ "token_type": "Bearer", "access_token": "eyJ...", "expires_in": 3600,
"expires_at": 1788608789, "merchant_id": 8269 }
GET /account/balance
{ "balance": 250, "referrals_earned": 0, "referrals_count": 0,
"currency_code": "USD", "currency_symbol": "$" }
Read live from the row, not from the copy the authentication step loaded.
GET /account/bonus
Where the caller stands in the bonus table, by their completed payouts.
{ "user": { "id": 8269, "login": "seller", "fullname": "", "email": "[email protected]" },
"current": { "level": 0, "bonus_percent": 0, "unlock_amount": 10000,
"progress_to_next": 0, "next_level": 1 },
"levels": [ { "level": 1, "threshold": 10000, "bonus_percent": 0.05 } ] }
next_level is null on the top tier. Always the caller's own account: there is no user_id parameter.
GET /account/analytics
Order totals per state for the caller.
{ "complete": { "amount": 0, "quantity": 0, "orders": 0 },
"processing": { "amount": 0, "quantity": 0, "orders": 0 },
"rejected": { "amount": 0, "quantity": 0, "orders": 0 } }
processing covers status 0 and 3.
GET /account/transactions
The caller's own orders.
Query: id (one order, returned unwrapped), type (cards or games), cursor, limit (default 20, max 200).
Item: id, uuid, type, status (processing|complete|rejected|under_review), status_id, gift_id, brand_id, country_id, item (the catalog row frozen at creation), amount, total, net_total, quantity, cards_type, cards_list, user_images, receipt_images, user_comment, admin_comment, date_create, date_created (formatted), date_complete.
Every entry in user_images and receipt_images carries name, bytes, mime and a url pointing at GET /account/order-image, never a directly fetchable path - the folder the files live in is denied at the webserver. Every stored picture is a WebP with its longest side at most 2048 px; the upload is normalised on the way in and the original is not kept.
404 with 4007 when id names an order that is not the caller's.
GET /account/order-image
Streams one order picture as a binary response, authenticated the same way as any other protected endpoint.
Query: token - the opaque token inside the url of a picture returned by account/transactions or by a sell response. The token is the whole address: there is no order id, type or index in it, and no path to the file.
The site and the panel fetch the same picture through /file/<token>, which reads the session; this endpoint exists because an API client authenticates with a key or a bearer token instead. Both go through the same access rule - the picture belongs to the seller who owns the order - so nothing about who may see what is decided twice.
The content type is image/webp, the response carries Cache-Control: private, no-store.
404 with 4007 when the token does not name a picture of an order that belongs to the caller, or the file behind it is gone.
GET /account/settings
{ "id": 8269, "login": "seller", "email": "[email protected]", "email_verified": false,
"firstname": "", "lastname": "", "fullname": "", "country": "USA", "country_id": 9,
"city": "Berlin", "telegram": "seller", "whatsapp": "", "phone": "", "userpic": "" }
PATCH /account/settings
Any subset of phone, whatsapp, fullname, city, telegram, country_id. An empty object is 4012.
phone,whatsapp: digits are extracted; 10 to 13 of them, or empty to clearfullname,city: letters, spaces, apostrophe and hyphen, 2 to 128 characterstelegram: a leading@is stripped, then[a-zA-Z0-9_]{3,32}country_id: must exist inGET /catalog/country2data;countryis filled from it
Answer: { "updated": true, "settings": { ...as GET... } }.
POST /uploads/images
multipart/form-data. Parks photos so an order can claim them; the signature covers an empty body.
Fields:
type:cardorreceipt(defaultcard)files[]: up to 10 images (a singlefilefield is also accepted)
Accepted: JPEG, PNG, GIF, WEBP, decided by magic bytes and not by the filename or the declared content type. Maximum size per file: settings.cards_max_size bytes.
The file is normalised while it is parked: re-encoded to WebP, longest side capped at 2048 px (never upscaled), EXIF orientation applied, metadata stripped, the first frame kept for an animated GIF. The original is not stored, so mime is always image/webp and size, w and h describe the stored file, not the upload.
{ "items": [ { "upload_id": "941c...29f", "type": "card", "mime": "image/webp",
"size": 13352, "w": 2048, "h": 1365, "created_at": 1788605305 } ],
"total": 1 }
A parked file that no order claims within 24 hours is deleted by cron/api-uploads-prune.php.
POST /uploads/delete
{ "upload_id": "941c...29f" }
Answer { "upload_id": "...", "deleted": true }. A file already attached to an order is part of that order and answers 403 with 4066; another account's id answers 403 with 4008; an unknown id 404 with 4007.
POST /sell/cards
Opens a gift card order in status 0.
{ "type": 1, "country_id": 9, "brand_id": 1, "gift_id": 1, "quantity": 2,
"comment": "", "cards": "CODE-1\nCODE-2", "lang": "en",
"card_upload_ids": [], "receipt_upload_ids": [] }
type:0any form,1e-code,2physical card (see/config)gift_idmust be on sale and must belong tobrand_idandcountry_id(4063)quantity: 1 to 1000comment: up to 1000 characterscards: one code per line, each 4 to 92 characters. Required for every type except2, which requirescard_upload_idsinstead (4064)- upload ids must be the caller's own, unclaimed and of the matching type
{ "order_id": 75791, "uuid": "455f...", "status": 0, "amount": 3.75, "total": 7.5,
"net_total": 0, "quantity": 2,
"files": { "cards": [], "receipts": [] } }
amount is the unit payout in site currency, total is the whole order including the bonus, and net_total is the pre-bonus figure (0 when no bonus applies). A file entry carries upload_id, name, bytes, url (GET /account/order-image, needs the same auth as this endpoint), mime and type. The picture itself is a private file of the order; the token inside url is the only address it has.
POST /sell/games
Identical, with platform_id instead of brand_id and gift_id read from the games catalog. When the platform has receipt_required, receipt_upload_ids must be present (4012).
POST /withdrawal/create
{ "amount": 10.5, "method_id": 2, "wallet": "1A1zP1...", "fio": "John Doe",
"amount_type": 0, "request_id": "wd_01K1A8T4S7Q2M5N9P3R6" }
amount: positive, at most 2 decimals; debited from the balance immediatelyamount_type:0main balance,1referral balancemethod_id: an id fromGET /catalog/paymentswith a live ratewallet: validated against that method's mask (4053)fio: required when the method hasneeds_fio;Firstname Lastname, latin lettersrequest_id:[a-zA-Z0-9_-]{16,128}, required. TheIdempotency-Keyheader carries the same value when the field is absent
Retrying with the same key returns the original row with duplicate: true and does not debit again. The same key with a different amount, method or wallet is 409 with 4065.
{ "id": 15870, "uuid": "f28f...", "status": 0, "amount": 10.5,
"amount_converted": 0.00013169, "amount_type": 0, "method_id": 2,
"wallet": "1A1zP1...", "date_create": 1788605258,
"request_id": "wd_01K1A8T4S7Q2M5N9P3R6", "duplicate": false }
Not enough balance is 4054, whose extra names available and requested.
GET /withdrawal/transactions
The caller's own payouts.
Query: id (one row, unwrapped), type (main or referral), cursor, limit (default 20, max 200).
Item: id, uuid, type, status (processing|complete|rejected), status_id, amount, amount_converted, amount_type, wallet, fio, tx_out, date_create, date_result, method (id, currency, name_ru, name_en, taken from the snapshot frozen on the row).
POST /support/create
Opens a support ticket, the same thread the site cabinet shows.
{ "subject": "Question", "message": "At least twenty characters of text.",
"name": "John Doe", "email": "[email protected]", "lang": "en" }
name and email default to the caller's profile. subject is 3 to 64 characters, message 20 to 8192. The site's flood guard applies (3011).
{ "ticket_id": 1888, "code": "b296...", "status": 0, "name": "John Doe",
"email": "[email protected]", "subject": "Question", "date": 1788605297 }
code is the link a guest would use to reach the thread.
Error catalogue
| id | HTTP | Meaning |
|---|---|---|
| 3000 | 405 | Method not allowed on this path |
| 3001 | 401 | No credentials sent |
| 3002 | 401 | X-Timestamp outside the 300 s window |
| 3003 | 401 | Signature mismatch |
| 3004 | 403 | users.api is off for this account |
| 3005 | 403 | Account status is not active |
| 3006 | 401 | Unknown X-Api-Key |
| 3007 | 401 | Authorization header missing or malformed |
| 3008 | 401 | JWT expired |
| 3009 | 401 | JWT signature, nbf, iat or sub invalid |
| 3010 | 503 | settings.api_jwt_secret is empty |
| 3011 | 429 | Rate limit, or the support flood guard |
| 4000 | 415 | Content type not accepted |
| 4007 | 404 | Record not found for this account |
| 4008 | 403 | Record belongs to another account |
| 4010 | 403 | Credentials valid but no account behind them |
| 4012 | 400 | Required field missing (extra.need) |
| 4013 | 400 | Body missing or not a JSON object |
| 4017 | 400 | Amount not a positive 2-decimal number |
| 4019 | 404 | User not found |
| 4052 | 400 | Unknown method_id |
| 4053 | 400 | Wallet does not match the method mask |
| 4054 | 400 | Insufficient funds |
| 4059 | 400 | Invalid email |
| 4060 | 400 | Subject outside 3 - 64 characters |
| 4061 | 400 | Message outside 20 - 8192 characters |
| 4062 | 404 | gift_id not on sale |
| 4063 | 400 | gift_id does not match brand, platform or country |
| 4064 | 400 | Neither cards nor card_upload_ids usable |
| 4065 | 409 | Idempotency key reused with different content |
| 4066 | 403 | Upload already attached to an order |
| 4073 | 400 | No file in the request |
| 4074 | 400 | Image type not JPEG, PNG or WEBP |
| 4075 | 400 | File above settings.cards_max_size |
| 4076 | 500 | File could not be stored |
| 4082 | 400 | Field failed validation (extra.field) |
| 5003 | 503 | Endpoint switched off in the whitelist |
| 5004 | 400 | Path contains characters the router rejects |
| 5005 | 404 | No such endpoint |
| 5008 | 503 | Site under maintenance: every endpoint except health answers this with Retry-After |
| 5007 | 500 | Unhandled server error |
Operational notes
- One JSON line per request is appended to
logs/api/{Y-m-d}.log: timestamp, ok flag, HTTP status, method, endpoint, user id, client address, duration. Bodies, keys and signatures are never written. - The router boots the CMS the same headless way
cron.phpdoes, and turns any uncaught throwable into5007after logging it tologs/php-error.log. HTML never leaves this entry point. settings.api_jwt_secret,settings.api_jwt_ttl,settings.api_cors_originsandsettings.cards_max_sizeare the four settings the API reads directly.