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:

  1. HMAC headers
  2. 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}

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

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.

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:

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": [] }
{ "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" }

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

idHTTPMeaning
3000405Method not allowed on this path
3001401No credentials sent
3002401X-Timestamp outside the 300 s window
3003401Signature mismatch
3004403users.api is off for this account
3005403Account status is not active
3006401Unknown X-Api-Key
3007401Authorization header missing or malformed
3008401JWT expired
3009401JWT signature, nbf, iat or sub invalid
3010503settings.api_jwt_secret is empty
3011429Rate limit, or the support flood guard
4000415Content type not accepted
4007404Record not found for this account
4008403Record belongs to another account
4010403Credentials valid but no account behind them
4012400Required field missing (extra.need)
4013400Body missing or not a JSON object
4017400Amount not a positive 2-decimal number
4019404User not found
4052400Unknown method_id
4053400Wallet does not match the method mask
4054400Insufficient funds
4059400Invalid email
4060400Subject outside 3 - 64 characters
4061400Message outside 20 - 8192 characters
4062404gift_id not on sale
4063400gift_id does not match brand, platform or country
4064400Neither cards nor card_upload_ids usable
4065409Idempotency key reused with different content
4066403Upload already attached to an order
4073400No file in the request
4074400Image type not JPEG, PNG or WEBP
4075400File above settings.cards_max_size
4076500File could not be stored
4082400Field failed validation (extra.field)
5003503Endpoint switched off in the whitelist
5004400Path contains characters the router rejects
5005404No such endpoint
5008503Site under maintenance: every endpoint except health answers this with Retry-After
5007500Unhandled server error

Operational notes