# AccKit API documentation — full bundle

Generated on 2026-10-31 from https://acckit.com. Three APIs, one `api_key` (Account page → section API):

1. Reseller API — `https://acckit.com/api/v1` — accounts & digital goods
2. OTP API — `https://acckit.com/api/v1/otp` — rent phone numbers, receive SMS codes
3. SMM API — `https://acckit.com/api/v1/smm` — likes, followers, views

Plus a build guide: **Storefront integration** — how to mirror the catalogue and resell it from your own website.

All amounts are VND. Send `Accept: application/json` on every request. Each document below is self-contained.

---

# AccKit Reseller API v1 — accounts & digital goods

> **Tiếng Việt (tóm tắt nhanh):** API bán tài khoản cho đại lý và phần mềm tự động. Địa chỉ gốc `https://acckit.com/api/v1`, xác thực bằng `api_key` lấy ở trang **Tài khoản** hoặc **Cài đặt API** (dùng chung cho API OTP và API Buff, chỉ dùng được trên chính website này). Tiền tệ **VND**. Luồng: `GET /categories` → lấy `id_product` → `POST /buy` → **lưu tài khoản ngay** (dữ liệu tài khoản tự xoá sau 15 ngày) → `POST /order` để xem lại. Giá trong danh mục là giá niêm yết; lúc mua hệ thống lấy giá bậc theo số lượng rồi trừ chiết khấu của tài khoản (không thấp hơn giá sàn) — số tiền thật nằm ở `price`/`total_price` của `/order` và `/orders`. `/buy` có thể trả `pending: true` (đã trừ tiền, tài khoản về sau) hoặc giao ít hơn số đặt. **Hết thời gian chờ hoặc lỗi 5xx khi gọi `/buy` thì KHÔNG gọi lại** — đợi khoảng 2 phút rồi tìm đơn trong `/orders` theo `id_product`. Luôn gửi header `Accept: application/json`. Có thể đưa nguyên tệp này cho AI/agent để tích hợp.

*Document revision 2026-09-30 · this copy was generated on 2026-10-31 for https://acckit.com*

## 1. Quick facts

| Item | Value |
|---|---|
| Base URL | `https://acckit.com/api/v1` |
| Authentication | `api_key` on every account-bound call: a form or JSON field for POST, a query parameter for GET. POST calls also accept it in the query string, but avoid that (URLs end up in logs) |
| Getting the key | Log in → **Account** page (`https://acckit.com/account`) → **API key** → *Generate* / *Regenerate*, or the **API settings** page (`https://acckit.com/setting_api`). One key per account; regenerating it invalidates the old key immediately. The same key works for the OTP API and the SMM API |
| Scope of a key | This website only (`https://acckit.com`). A key created on another website answers `Invalid api_key` here |
| Request encoding | `application/x-www-form-urlencoded` (recommended), or a JSON body sent with `Content-Type: application/json` (same field names) |
| Response encoding | JSON, UTF-8. Non-ASCII characters may arrive escaped (for example `\u00e1`); always use a JSON parser |
| Success and failure | Business failures are HTTP 200 `{"status": false, "msg": "<English message>"}`. Success shapes differ per endpoint (section 2). Detect failure with `status === false`; a missing `status` key is not a failure |
| Validation errors | HTTP 422 `{"message": "The given data was invalid.", "errors": {...}}` on `/buy`, `/order` and `/orders` when the request carries `Accept: application/json`. Without that header a script gets an HTTP 302 redirect (normally to the home page) instead. **Always send the header** |
| Currency | **VND**. Money values are JSON numbers; amounts charged are whole đồng. The website may display prices in another currency; the API always uses VND |
| Your price | Listings show list prices. `/buy` applies the volume tier, then your account discount, never below a floor price (section 3). The price actually charged is in `/order` and `/orders` |
| Delivery | Usually inside the `/buy` response. Some supplier-fulfilled orders answer `pending: true` and are filled in later, and a purchase can deliver fewer accounts than requested (section 6) |
| Retention | Delivered account data is deleted 15 days after the purchase. Save it as soon as you receive it (section 6.4) |
| Timeouts | A purchase can take up to about 2 minutes. Never repeat `/buy` after a timeout or a 5xx: reconcile through `/orders` (section 7) |
| Rate limit | 60 requests per minute per client IP, shared with the SMM API and the other non-OTP `/api/v1` endpoints (section 8.2) |
| Language | Names and descriptions in `vi`, `en`, `id`, `pt`, `zh` or `th`, chosen once on the API settings page (section 4) |
| Timestamps | `created_at` only (no endpoint returns `updated_at`): ISO-8601 in UTC, e.g. `2026-09-30T02:11:00.000000Z` |

## 2. Endpoint index

| Method | Path | `api_key` | Purpose |
|---|---|---|---|
| GET | `/categories` | optional (language only) | Every category currently for sale, with its products, list prices and stock |
| GET | `/category/{id}` | optional (language only) | The products of one category (`id` from `/categories`) |
| POST | `/balance` | required | Your wallet balance in VND |
| POST | `/buy` | required | Buy `quantity` units of one product |
| POST | `/order` | required | One of your orders, with its delivered accounts |
| POST | `/orders` | required | Your order history, newest first, 10 per page |
| POST | `/import` | administrator key | Shop administrators only: add or update one stock line |

Terminology: a **category** (`id`) groups **products** (`id_product`). `/buy` takes the **product** id. Never send a category id: the same number can be the id of an unrelated product.

Historical naming: in the `/buy` result, `data[].type` is the product **id**. In `/order` and `/orders`, `type` is the product **name** and the product id is in `id_product`.

Response shapes (all HTTP 200):

| Endpoint | Success | Failure |
|---|---|---|
| `/categories`, `/category/{id}` | `{"data": [...]}`, no `status` key | None. An unknown or hidden category gives `{"data": []}` |
| `/balance` | `{"status": true, "balance": 1250000}` | `{"status": false, "msg": "Invalid api_key"}` |
| `/buy` | `{"status": true, "order_id": 12345, "data": [...]}`, plus `"pending": true` and `msg` for a pending order | `{"status": false, "msg": "..."}` |
| `/order` | The order object itself, not wrapped: `{"id": 12345, ..., "data": [...], "id_product": 124}`, no `status` key | `{"status": false, "msg": "..."}` |
| `/orders` | `{"data": [...], "links": {...}, "meta": {...}}`, no `status` key | `{"status": false, "msg": "Invalid api_key"}` |
| `/import` | `{"status": true, "msg": "..."}` | `{"status": false, "msg": "..."}` |

Stability: the catalogue objects of `/categories` and `/category/{id}` are kept stable on purpose (no key is renamed or added). Order objects can gain keys at the end (`id_product` was added in September 2026), so ignore keys you do not recognise.

## 3. Pricing and your discount

`/categories` and `/category/{id}` return each product's **list price** (`price`), the same number for every customer. They never include volume tiers or your discount. Your unit price is computed at `/buy`, for your account and the `quantity` you send:

1. **Volume tier.** If the product has volume tiers, the lowest tier price among the tiers your `quantity` reaches replaces the list price (with normally ordered tiers, that is the tier with the highest threshold reached). Buying more is therefore never dearer per unit. A tier is never dearer than the list price (a tier priced above it counts as the list price), and a tier without a positive price is ignored.
2. **Your account discount.** Your account's discount percentage is taken off that price. The shop sets it for your account; you cannot change it. Without a discount you pay the tier or list price.
3. **Floor.** The discount stops at the product's floor price, if it has one (the shop's cost for that product; not published). On low-margin products your effective discount can therefore be smaller than your percentage, or zero. The floor never raises a price above the tier or list price.

`/buy` requires your balance to cover unit price × `quantity`, then charges unit price × the units billed (normally the accounts delivered, section 6.2).

Worked example: list price 10,000 VND, one volume tier "100 units or more: 8,000", your discount 10%. The first two rows assume the product's floor is lower than the discounted price.

| Quantity | Price before discount | After 10% | Unit price charged | Total charged |
|---|---|---|---|---|
| 99 | 10,000 (no tier reached) | 9,000 | 9,000 | 891,000 |
| 100 | 8,000 (tier 100+) | 7,200 | 7,200 | 720,000 |
| 100, product floor 7,500 | 8,000 (tier 100+) | 7,200, below the floor | 7,500 | 750,000 |

- The same calculation is used on the website and in the API, for the shop's own stock and for supplier-fulfilled products.
- To see your prices before buying, log in on the website with the same account: product pages show your unit price and the volume-tier table with your discount applied (in the website's display currency).
- After buying, `/order` and `/orders` return `price` (unit price charged) and `total_price` (amount charged). The `/buy` response itself carries no price.
- Your unit price is rounded **up** to a whole đồng (10,001 × 90% = 9,000.9 → 9,001), so an order's `total_price` is exactly `price` × `quantity`.
- Your discount also applies to purchases through the OTP API and the SMM API.

## 4. Language of names and descriptions

| Code | Language |
|---|---|
| `vi` | Vietnamese (the original text) |
| `en` | English |
| `id` | Bahasa Indonesia |
| `pt` | Portuguese (Brazil) |
| `zh` | Simplified Chinese |
| `th` | Thai |

- Choose it once on the **API settings** page (`https://acckit.com/setting_api`, logged in). It is stored on your account; there is no per-request language parameter.
- It applies whenever the request carries your `api_key`: category and product names and descriptions in `/categories` and `/category/{id}`, and product names (`type`) in `/order` and `/orders`.
- Without a key, or if you never chose a language, the shop's default API language is used (Vietnamese unless the shop has changed it).
- A missing translation falls back to English, then to the Vietnamese original.
- `msg` texts and validation messages are always English. Ids, prices, stock and delivered account data are never translated.

## 5. Endpoints

### 5.1 GET /categories

Public. Send `api_key` only to get names and descriptions in your API language; an unknown key is ignored.

```bash
curl "https://acckit.com/api/v1/categories?api_key=YOUR_API_KEY" -H "Accept: application/json"
```

Response (abbreviated to one category and one product):

```json
{
  "data": [
    {
      "id": 211,
      "name": "Social media accounts",
      "icon": null,
      "list_product": [
        {
          "id_product": 124,
          "name": "Account with 2FA and recovery mail",
          "desc": "Format: uid|pass|2fa|mail|mailpass",
          "price": 10000,
          "quantity": 89
        }
      ]
    }
  ]
}
```

| Field | Type | Meaning |
|---|---|---|
| `id` | integer | Category id. Use it only with `/category/{id}`, never with `/buy` |
| `name` | string | Category name, in your API language |
| `icon` | string or null | Absolute URL of the category image; `null` when the category has no image file |
| `list_product[].id_product` | integer | **Product id: the value to send as `id_product` to `/buy`** |
| `list_product[].name` | string | Product name, in your API language |
| `list_product[].desc` | string or null | Short description, in your API language (often the field layout of a delivered account, plus notes) |
| `list_product[].price` | number | List price per unit in VND, not your price (section 3). Parse it as a number: it may be written with a decimal point, e.g. `10000.0` |
| `list_product[].quantity` | integer | Units available. Exact for the shop's own stock. For supplier-fulfilled products it is refreshed about every 5 minutes (and lowered at once when units sell here), so treat it as approximate |

Only visible categories that contain at least one product for sale are returned, each with only its products for sale. This is exactly the set that `/buy` accepts. Categories and products come in the website's display order. A product whose stock reaches 0 is hidden automatically, usually within a minute.

### 5.2 GET /category/{id}

The products of one category. `{id}` is a category `id` from `/categories`. Same optional `api_key` for the language.

```bash
curl "https://acckit.com/api/v1/category/211?api_key=YOUR_API_KEY" -H "Accept: application/json"
```

```json
{"data": [{"id_product": 124, "name": "Account with 2FA and recovery mail", "desc": "Format: uid|pass|2fa|mail|mailpass", "price": 10000, "quantity": 89}]}
```

The product objects are the same as `list_product[]` above. An unknown, hidden or empty category returns `{"data": []}`.

### 5.3 POST /balance

| Param | Type | Required | Description |
|---|---|---|---|
| `api_key` | string | yes | Your API key |

```bash
curl -X POST "https://acckit.com/api/v1/balance" -H "Accept: application/json" -d "api_key=YOUR_API_KEY"
```

```json
{"status": true, "balance": 1250000}
```

`balance` is a number, in VND. A wrong, missing or blank key gives `{"status": false, "msg": "Invalid api_key"}` (this endpoint never answers 422).

### 5.4 POST /buy

Buys `quantity` units of one product. There is no idempotency key: every call that reaches the server is a new purchase (read section 7 before writing retry logic).

| Param | Type | Required | Description |
|---|---|---|---|
| `api_key` | string | yes | Your API key |
| `id_product` | integer | yes | Product id from `list_product[].id_product` |
| `quantity` | integer | yes | Units to buy, at least 1. Some products have a higher minimum that the listings do not show; `/buy` then answers `Minimum quantity is N` |

What happens, in this order:

1. The fields are validated: HTTP 422 if `api_key`, `id_product` or `quantity` is missing or malformed (`quantity` must be an integer).
2. The key is checked: `Invalid api_key`.
3. The product is checked: `Product is not for sale right now` if it is hidden (anything `/categories` does not list, including products withdrawn automatically while their supplier is failing), `Product ID does not exist` if the id is unknown.
4. The quantity is checked: `Minimum quantity must be at least 1`, `Minimum quantity is N`, `Quantity exceeds available stock`.
5. The unit price is computed (section 3): `Insufficient balance!` if your balance is below unit price × `quantity`.
6. The accounts are taken from the shop's own stock or ordered from a supplier. Your balance is charged, and the order created, in the same step: no order, no charge.

```bash
curl -X POST "https://acckit.com/api/v1/buy" -H "Accept: application/json" --max-time 120 \
  -d "api_key=YOUR_API_KEY" -d "id_product=124" -d "quantity=2"
```

Success:

```json
{
  "status": true,
  "order_id": 12345,
  "data": [
    {
      "buy_id": 12345,
      "uid": "100012345678901",
      "full_info": "100012345678901|Password123|ABCD EFGH IJKL MNOP|mail@example.com|MailPass",
      "type": 124
    },
    {
      "buy_id": 12345,
      "uid": "100012345678902",
      "full_info": "100012345678902|Password456|QRST UVWX YZAB CDEF|mail2@example.com|MailPass2",
      "type": 124
    }
  ]
}
```

| Field | Type | Meaning |
|---|---|---|
| `order_id` | integer | Your order number. Store it with the accounts |
| `data` | array | One element per delivered account. It can hold fewer elements than `quantity` (section 6.2), and is empty for a pending order |
| `data[].buy_id` | integer | Same as `order_id` |
| `data[].uid` | string | Account identifier, normally the first field of `full_info` |
| `data[].full_info` | string | The complete account line, fields separated by the pipe character. The field layout is described in the product's `desc` |
| `data[].type` | integer | The product id (historical name; see section 2) |
| `pending` | boolean | Only present, as `true`, for a pending order |
| `msg` | string | Only present with `pending` |

Pending order (a supplier accepted the order but has not handed over the accounts yet). You have been charged for the full `quantity`; see section 6.1 for what to do next:

```json
{
  "status": true,
  "order_id": 12346,
  "pending": true,
  "data": [],
  "msg": "Order placed. The supplier is still preparing the accounts — call /order with this order_id in a few minutes."
}
```

Failures (HTTP 200). None of these charge you or create an order:

| `msg` | Meaning and what to do |
|---|---|
| `Invalid api_key` | The key is wrong, or belongs to another website. (A missing key gives HTTP 422.) |
| `Product is not for sale right now` | The product or its category is hidden. Re-read `/categories` and buy only what it lists |
| `Product ID does not exist` | Unknown `id_product`. Check that you are not sending a category id |
| `Minimum quantity must be at least 1` | `quantity` is 0 or negative |
| `Minimum quantity is N` | This product requires at least N units per order |
| `Quantity exceeds available stock` | Not enough stock right now. Re-read `/categories`, then buy less or choose another product |
| `Insufficient balance!` | Top up your wallet |
| `Product temporarily unavailable, please try again later or contact support.` | The supplier refused the order or had no stock. The product is withdrawn from the catalogue until the next stock refresh (about 5 minutes) finds stock again. Retry only once `/categories` lists it again |
| `System error!` | Unexpected failure while delivering. Some products are also withdrawn from the catalogue until the shop has checked them. Re-read `/categories` before trying again |

### 5.5 POST /order

Returns one of your orders with its delivered accounts. The order object is the whole response body: it is **not** wrapped in a `data` object, and its own `data` key is the array of accounts.

| Param | Type | Required | Description |
|---|---|---|---|
| `api_key` | string | yes | Your API key |
| `order_id` | integer | yes | `order_id` from `/buy`, or `id` from `/orders` |

```bash
curl -X POST "https://acckit.com/api/v1/order" -H "Accept: application/json" \
  -d "api_key=YOUR_API_KEY" -d "order_id=12345"
```

```json
{
  "id": 12345,
  "quantity": 2,
  "price": 9000,
  "total_price": 18000,
  "type": "Account with 2FA and recovery mail",
  "created_at": "2026-09-30T02:11:00.000000Z",
  "data": [
    {
      "uid": "100012345678901",
      "full_info": "100012345678901|Password123|ABCD EFGH IJKL MNOP|mail@example.com|MailPass",
      "link_backup": "https://acckit.com/download_backup/12345/100012345678901"
    },
    {
      "uid": "100012345678902",
      "full_info": "100012345678902|Password456|QRST UVWX YZAB CDEF|mail2@example.com|MailPass2",
      "link_backup": "https://acckit.com/download_backup/12345/100012345678902"
    }
  ],
  "id_product": 124
}
```

| Field | Type | Meaning |
|---|---|---|
| `id` | integer | Order id |
| `quantity` | integer | Units billed (section 6.2) |
| `price` | number | Unit price charged, VND (section 3) |
| `total_price` | number | Amount charged, VND |
| `type` | string or null | Product **name** in your API language; `null` if the product no longer exists |
| `created_at` | string | Purchase time, ISO-8601 UTC |
| `data` | array | The delivered accounts. Empty while the order is pending (section 6.1) and after the retention period (section 6.4) |
| `data[].uid`, `data[].full_info` | string | As in `/buy` |
| `data[].link_backup` | string | Always present. A browser link to the account's backup file (an .html file), not an API endpoint: it works only in a browser logged in to the website with the same account (otherwise you are sent to the login page), and answers 404 when the product ships no backup file or after the retention period |
| `id_product` | integer | Product id, the same value as `id_product` in `/categories` |

Success is recognised by the presence of `id`. Failures (HTTP 200):

- `{"status": false, "msg": "Order not found"}`: unknown id, an order of another account, or a refunded order (section 6.3).
- `{"status": false, "msg": "Invalid api_key"}`: wrong key. A missing `api_key` or `order_id` gives HTTP 422.

### 5.6 POST /orders

Your order history, newest first, 10 orders per page. It includes the purchases made on the website with the same account. Refunded orders are not listed (section 6.3). OTP rentals and SMM orders are not part of this list.

| Param | Type | Required | Description |
|---|---|---|---|
| `api_key` | string | yes | Your API key |
| `page` | integer | no | Page number, default 1. Send it in the query string (`/orders?page=2`) or as a form field |

```bash
curl -X POST "https://acckit.com/api/v1/orders?page=1" -H "Accept: application/json" -d "api_key=YOUR_API_KEY"
```

Response (`data` abbreviated to one order; a full page holds up to 10):

```json
{
  "data": [
    {
      "id": 12345,
      "quantity": 2,
      "price": 9000,
      "total_price": 18000,
      "type": "Account with 2FA and recovery mail",
      "created_at": "2026-09-30T02:11:00.000000Z",
      "id_product": 124
    }
  ],
  "links": {
    "first": "https://acckit.com/api/v1/orders?page=1",
    "last": "https://acckit.com/api/v1/orders?page=2",
    "prev": null,
    "next": "https://acckit.com/api/v1/orders?page=2"
  },
  "meta": {
    "current_page": 1,
    "from": 1,
    "last_page": 2,
    "links": [
      {"url": null, "label": "&laquo; Previous", "active": false},
      {"url": "https://acckit.com/api/v1/orders?page=1", "label": 1, "active": true},
      {"url": "https://acckit.com/api/v1/orders?page=2", "label": 2, "active": false},
      {"url": "https://acckit.com/api/v1/orders?page=2", "label": "Next &raquo;", "active": false}
    ],
    "path": "https://acckit.com/api/v1/orders",
    "per_page": 10,
    "to": 10,
    "total": 14
  }
}
```

- Each item has exactly the fields of `/order` except the account list: `id`, `quantity`, `price`, `total_price`, `type` (product **name**), `created_at`, `id_product` (product **id**). Fetch the accounts with `/order`.
- The URLs in `links` and `meta.links` are plain page URLs. Call them with POST and your `api_key`; a GET answers HTTP 405. `meta.links` is a list of page buttons that you can ignore.
- A wrong key gives `{"status": false, "msg": "Invalid api_key"}`; a missing key gives HTTP 422.

### 5.7 POST /import (shop administrators only)

Not for customers. It requires the API key of a shop administrator account. It adds one account line to a product's own stock, or replaces the line whose first field (`uid`) already exists in that product.

| Param | Type | Required | Description |
|---|---|---|---|
| `api_key` | string | yes | Administrator's API key |
| `id_product` | integer | yes | Product id |
| `text` | string | yes | One account line. Everything before the first pipe character is its `uid` |

| Response (HTTP 200) | Meaning |
|---|---|
| `{"status": true, "msg": "Added successfully"}` | New line added. If the product was hidden, it is shown again |
| `{"status": true, "msg": "Updated successfully"}` | A line with the same `uid` existed in this product and was replaced |
| `{"status": false, "msg": "Invalid api_key or missing admin permission"}` | Key missing, wrong, or not an administrator's |
| `{"status": false, "msg": "Please provide the text field"}` | `text` is empty |
| `{"status": false, "msg": "Please provide the id_product field"}` | `id_product` is empty |
| `{"status": false, "msg": "id_product does not exist"}` | Unknown product |

## 6. After the purchase: pending orders, partial delivery, refunds, retention

### 6.1 Pending orders

`/buy` answers `status: true` with `pending: true` and an empty `data` when a supplier accepted the order but has not handed over the accounts yet.

- You have been charged for the full `quantity`, and the order already exists in `/order` and `/orders`.
- The shop asks the supplier again every minute and adds the accounts to the order as soon as they arrive. `/order` has no pending flag: an empty `data` on a recent order means it is still pending.
- Poll `/order` with the `order_id` every 1 to 2 minutes. Once `data` is not empty, store the accounts.
- Still empty after 30 minutes: contact support with the `order_id`. Automatic retrieval stops 24 hours after the purchase; after that the shop resolves the order by hand (delivery or refund).

### 6.2 Fewer accounts than requested

A purchase can deliver fewer accounts than `quantity`, typically when stock runs out during the purchase. The order always records what you paid for: `quantity` is the number of units billed and `total_price` the amount charged.

- Normally you are billed only for the accounts delivered: the order's `quantity` equals the number of accounts you received, and `total_price` is lower than for the full amount.
- You are never billed for more than you ordered. In the rare case a supplier sends extra accounts, you receive them too, but `quantity` and `total_price` stay at what you ordered.
- Supplier orders that were accepted as a whole (the kind that can answer `pending`) are billed for the full requested quantity when they are placed. If such an order ends up with fewer accounts in `data` than its `quantity`, contact support with the `order_id`.
- If nothing at all can be delivered, `/buy` fails (`status: false`) and nothing is charged. A pending order (section 6.1) is the only case where you are charged before receiving accounts.

Rule for your code: after each purchase, compare the number of accounts with `quantity` in `/order`. Fewer accounts than `quantity`, on an order that is neither pending nor past the retention period, means you should contact support.

### 6.3 Refunds

- The shop can refund a whole order (for example one that was never delivered). The full `total_price` goes back to your balance and the order is removed: it disappears from `/orders`, and `/order` answers `Order not found`. Keep your own record of `order_id`s so that a vanished order is not mistaken for an error.
- Warranty claims are handled on the website. A warranty refund for a single account credits that account's unit price to your balance and leaves the order unchanged in the API. Replacement accounts are shown on the website; they are not added to the order's `data`.

### 6.4 Delivered accounts are deleted after 15 days

Delivered account data is deleted from the server 15 days after the purchase (the shop's retention setting; the order page on the website states the current number of days). A daily clean-up removes it. After that:

- `/order` still returns the order, but `data` is `[]`;
- the order stays in `/orders`;
- `link_backup` links answer 404.

Store the accounts from the `/buy` response (or from your first successful `/order` call) immediately. "Download again later" only works within the retention period.

## 7. Timeouts, retries and reconciliation

`/buy` has no idempotency key, and a purchase that involves a supplier can take up to about 2 minutes. An intermediate proxy may give up earlier (HTTP 502 or 504, for example) while the server is still finishing, and the purchase can then complete and be charged without you receiving the answer.

Rules:

1. Use a client timeout of at least **120 seconds** for `/buy`.
2. **Never repeat a `/buy` call automatically** after a timeout, a network error, an HTTP 5xx, or a body that is empty or not JSON. The outcome is unknown: reconcile first.
3. Safe to repeat: HTTP 429 (the request was rejected before processing; wait `Retry-After` seconds), HTTP 422 (fix the fields first) and any HTTP 200 `{"status": false, ...}` answer (nothing was charged; fix the cause first).
4. The read-only endpoints (`/categories`, `/category/{id}`, `/balance`, `/order`, `/orders`) can be retried with exponential backoff.

Reconciliation after an unknown `/buy` outcome (the Python example in section 9 implements it):

1. Wait about 2 minutes.
2. POST `/orders` (page 1, and page 2 if you place many orders). A match is an order with the same `id_product`, a `created_at` (UTC) no earlier than about 2 minutes before you sent the request (the margin absorbs clock differences), and an `id` you have not already recorded for another purchase. Purchases made on the website with the same account appear in this list too.
3. Found: POST `/order` with that `id`, store the accounts and mark the purchase done. Its `quantity` and `total_price` tell you what was delivered and charged.
4. Not found: check again until at least 3 minutes have passed since you sent the request. Still not found then means the purchase did not happen and nothing was charged; you may buy again. If in doubt, compare `/balance` with the balance you had before the attempt.

While a purchase of a product is unresolved, do not send another `/buy` for the same product (and do not buy it on the website with the same account), so that the match stays unambiguous. `/orders` is the source of truth for what you bought.

## 8. Errors and rate limits

### 8.1 Error reference

| HTTP | Body | When | Repeat the request? |
|---|---|---|---|
| 200 | `{"status": false, "msg": "..."}` | Business errors (tables in section 5) | Yes, once the cause is fixed: nothing was charged |
| 422 | `{"message": "The given data was invalid.", "errors": {"quantity": ["The quantity must be an integer."]}}` | Missing or malformed field on `/buy`, `/order` or `/orders` (with `Accept: application/json`) | Yes, after fixing the field |
| 302 | Redirect, normally to the home page | A validation error on a request without `Accept: application/json` | Add the header |
| 405 | `{"message": "The GET method is not supported for this route. Supported methods: POST."}` | Wrong HTTP method | Use the documented method |
| 429 | `{"message": "Too Many Attempts."}` | Rate limit exceeded (section 8.2) | Yes, after `Retry-After` seconds |
| 500 | `{"message": "Server Error"}` | Unexpected server error | Read endpoints: yes, with backoff. `/buy`: no, reconcile (section 7) |
| Other 5xx, or no answer | Often an HTML page from a proxy | Server busy or restarting, or the answer took too long | Read endpoints: yes, with backoff. `/buy`: no, reconcile (section 7) |

The JSON bodies of 405, 429 and 500 are returned when the request carries `Accept: application/json`; a script without the header gets HTML pages for these errors.

### 8.2 Rate limits

- **60 requests per minute per client IP address.** The budget is shared by every non-OTP endpoint under `/api/v1`: this API, the SMM API (`/api/v1/smm/...`) and the other `/api/v1` endpoints called from the same IP. The OTP API (`/api/v1/otp/...`) has its own budget of 240 requests per minute per `api_key`.
- Every response carries `X-RateLimit-Limit` and `X-RateLimit-Remaining`. A 429 also carries `Retry-After` (seconds) and `X-RateLimit-Reset` (Unix timestamp).
- Spend the budget carefully: cache `/categories` and refresh it every few minutes rather than before each purchase (supplier stock only refreshes about every 5 minutes anyway), poll pending orders every 1 to 2 minutes, and do not poll `/balance` in a loop.

## 9. Code examples

### cURL — full purchase flow

```bash
BASE="https://acckit.com/api/v1"; KEY="YOUR_API_KEY"; H="Accept: application/json"
curl -s -H "$H" "$BASE/categories?api_key=$KEY" | head -c 800; echo        # pick an id_product
curl -s -H "$H" -X POST "$BASE/balance" -d "api_key=$KEY"
curl -s -H "$H" --max-time 120 -X POST "$BASE/buy" -d "api_key=$KEY" -d "id_product=124" -d "quantity=1"
curl -s -H "$H" -X POST "$BASE/order" -d "api_key=$KEY" -d "order_id=12345"
curl -s -H "$H" -X POST "$BASE/orders?page=1" -d "api_key=$KEY"
```

### PHP

```php
<?php
const BASE = 'https://acckit.com/api/v1';
const KEY  = 'YOUR_API_KEY';

/** POST helper. Returns [httpStatus, decodedJsonOrNull]; httpStatus 0 = timeout or network error. */
function acckit(string $path, array $fields, int $timeout = 30): array
{
    $ch = curl_init(BASE . $path);
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => http_build_query(['api_key' => KEY] + $fields),
        CURLOPT_HTTPHEADER     => ['Accept: application/json'],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => $timeout,
    ]);
    $body = curl_exec($ch);
    $http = $body === false ? 0 : (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    $json = is_string($body) ? json_decode($body, true) : null;
    return [$http, is_array($json) ? $json : null];
}

[$http, $buy] = acckit('/buy', ['id_product' => 124, 'quantity' => 1], 120);

if ($http === 422 || $http === 429) {
    exit("Rejected before processing (HTTP $http): nothing was charged.\n");
}
if ($http !== 200 || $buy === null) {
    // Timeout, network error, 5xx or a non-JSON body: the purchase MAY have gone through.
    // Do NOT call /buy again. Wait about 2 minutes, then reconcile through /orders (section 7).
    exit("Unknown outcome: reconcile through /orders.\n");
}
if (($buy['status'] ?? null) === false) {
    exit('Failed, nothing was charged: ' . $buy['msg'] . "\n");
}

$orderId  = $buy['order_id'];     // store it right away, together with the accounts
$accounts = $buy['data'];
if (!empty($buy['pending'])) {    // charged; the accounts are added to the order later
    for ($i = 0; $i < 30 && !$accounts; $i++) {
        sleep(60);
        [$h, $order] = acckit('/order', ['order_id' => $orderId]);
        if ($h === 200 && isset($order['id'])) {
            $accounts = $order['data'];
        }
    }
}
foreach ($accounts as $acc) {
    echo $acc['full_info'], "\n";  // save them now: account data is deleted after 15 days
}
```

### Python

```python
import time
from datetime import datetime, timedelta, timezone

import requests

BASE = "https://acckit.com/api/v1"
KEY = "YOUR_API_KEY"
H = {"Accept": "application/json"}


def post(path, timeout=30, **fields):
    """POST helper. Returns (http_status, json_or_None); http_status 0 = timeout or network error."""
    try:
        r = requests.post(BASE + path, data={"api_key": KEY, **fields}, headers=H, timeout=timeout)
    except requests.RequestException:
        return 0, None
    try:
        return r.status_code, r.json()
    except ValueError:
        return r.status_code, None


def find_order(id_product, sent_at, known_ids):
    """Reconciliation after an unknown /buy outcome. sent_at = UTC time just before calling /buy."""
    for page in (1, 2):
        http, res = post("/orders", page=page)
        for o in (res or {}).get("data", []):
            created = datetime.fromisoformat(o["created_at"].replace("Z", "+00:00"))
            if (o["id_product"] == id_product and o["id"] not in known_ids
                    and created >= sent_at - timedelta(minutes=2)):
                return o
    return None


cats = requests.get(f"{BASE}/categories", params={"api_key": KEY}, headers=H, timeout=30).json()["data"]
product = cats[0]["list_product"][0]             # choose properly in real code
known_ids = set()                                # order ids you have already recorded

sent_at = datetime.now(timezone.utc)
http, buy = post("/buy", timeout=120, id_product=product["id_product"], quantity=1)
if http in (422, 429):
    raise SystemExit(f"rejected before processing (HTTP {http}); nothing was charged")
if http != 200 or buy is None:
    time.sleep(120)                              # never repeat /buy here: look for the order instead
    found = find_order(product["id_product"], sent_at, known_ids)
    raise SystemExit(f"order {found['id']} exists: fetch it with /order" if found
                     else "no order yet: check again in a few minutes before buying again")
if buy.get("status") is False:
    raise SystemExit("failed, nothing was charged: " + buy["msg"])

order_id, accounts = buy["order_id"], buy["data"]   # store both right away
known_ids.add(order_id)
for _ in range(30):                                  # pending: charged, accounts arrive later
    if accounts or not buy.get("pending"):
        break
    time.sleep(60)
    http, order = post("/order", order_id=order_id)
    if http == 200 and order and "id" in order:
        accounts = order["data"]
for acc in accounts:
    print(acc["full_info"])                          # save now: deleted after 15 days
```

### Node.js (18+)

```js
// Node.js 18+ (built-in fetch). Save as buy.js and run: node buy.js
const BASE = "https://acckit.com/api/v1", KEY = "YOUR_API_KEY";

async function post(path, fields, timeoutMs = 30000) {
  try {
    const r = await fetch(BASE + path, {
      method: "POST",
      headers: { "Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({ api_key: KEY, ...fields }),
      signal: AbortSignal.timeout(timeoutMs),
    });
    const json = await r.json().catch(() => null);
    return [r.status, json];
  } catch {
    return [0, null];                                   // timeout or network error
  }
}

(async () => {
  const [http, buy] = await post("/buy", { id_product: 124, quantity: 1 }, 120000);
  if (http === 422 || http === 429) return console.error(`rejected before processing (HTTP ${http}); nothing was charged`);
  if (http !== 200 || !buy) return console.error("unknown outcome: do NOT repeat /buy; wait about 2 minutes and reconcile through /orders");
  if (buy.status === false) return console.error("failed, nothing was charged:", buy.msg);

  let accounts = buy.data;                              // store buy.order_id and the accounts right away
  for (let i = 0; buy.pending && accounts.length === 0 && i < 30; i++) {
    await new Promise(resolve => setTimeout(resolve, 60000));
    const [h, order] = await post("/order", { order_id: buy.order_id });
    if (h === 200 && order && "id" in order) accounts = order.data;
  }
  accounts.forEach(a => console.log(a.full_info));      // save now: deleted after 15 days
})();
```

## 10. Integration checklist (developers and AI agents)

> Building a shop that resells these products? Read **acckit-storefront-integration.md**
> — catalogue mirroring, pricing, the checkout flow, timeout reconciliation and a
> paste-ready brief for an AI coding agent. Download it at https://acckit.com/api-docs.
> Where that guide and this reference disagree, this reference is correct.

- Send `Accept: application/json` on every request; otherwise validation failures come back as a redirect instead of JSON.
- Keep the key on your server. It works only on https://acckit.com, and regenerating it on the website invalidates the old key immediately.
- Take `id_product` from `list_product[]`; never send a category `id` to `/buy`.
- Treat catalogue prices as list prices. Your real charge follows section 3 and is returned by `/order` and `/orders` (`price`, `total_price`).
- Detect failures with `status === false`. A successful `/order` has no `status` key and is not wrapped in `data`; a successful `/orders` or `/categories` has no `status` key either.
- In `/order` and `/orders`, `type` is the product name; match on `id_product`.
- Save delivered accounts immediately: they are deleted after 15 days.
- Handle `pending: true` by polling `/order` (section 6.1), and compare the accounts received with `quantity` (section 6.2).
- Never repeat `/buy` after a timeout, a 5xx or an unreadable body: reconcile through `/orders` (section 7). Use a `/buy` timeout of at least 120 seconds.
- Expect refunded orders to disappear from `/orders` (section 6.3).
- Stock (`quantity`) changes constantly. Treat `Quantity exceeds available stock` as "re-read the catalogue and try a smaller amount or another product".
- Stay under 60 requests per minute per IP, a budget shared with the SMM API; cache `/categories` instead of polling it in a tight loop.
- Ignore response keys you do not recognise.


---

# AccKit OTP API v1 — rent a phone number, receive the SMS code

> **Tiếng Việt (tóm tắt nhanh):** API thuê số điện thoại nhận mã OTP cho tool. Địa chỉ gốc `https://acckit.com/api/v1/otp`, dùng chung `api_key` với API bán hàng, tiền tệ **VND**. Gửi kèm `api_key` ở `/services` và `/servers` để thấy **giá của chính tài khoản bạn** (đã trừ chiết khấu) — `/buy` trừ đúng giá đó. Luồng: `GET /servers` → chọn một dòng → `POST /buy` với đúng `operator` (hoặc `server`: `vip1`, `vip2`, `vip3`, `vip4`, hoặc mã SV thường) của dòng đó → hỏi `GET /check` mỗi 3–5 giây tới khi `status` là `RECEIVED` (có mã) hoặc `REFUNDED` (đã hoàn tiền). Mỗi số giữ tối đa 15 phút (hạn thật là `expires_ts` của đơn); không có mã thì tự hoàn 100%. `/buy` có thể mất tới ~90 giây: đặt timeout ≥ 120 giây và **không tự gửi lại `/buy` khi bị timeout**. Chi tiết bằng tiếng Anh bên dưới; có thể đưa nguyên file này cho AI / agent của bạn.

*Document version 2026-09-30 — checked against the source code on 2026-09-30. This copy: https://acckit.com, 2026-10-31.*

## 1. Quick facts

| Item | Value |
|---|---|
| Base URL | `https://acckit.com/api/v1/otp` |
| Authentication | The same `api_key` as the Reseller API (Account page → section API), sent as a query parameter, a form field or a JSON body field. Required on `/buy`, `/check`, `/cancel`, `/balance`. Optional on `/services` and `/servers`, where it switches the prices to yours |
| Currency | **VND**, integers |
| Your price | List price minus your account discount, never below the shop's cost, rounded up to a whole VND. Send `api_key` to `/services` and `/servers` to see it (section 2.1) |
| Charging | `/buy` debits your wallet, then requests the number. If no number is issued, the money is back before `/buy` answers |
| Hold time | At most 15 minutes after the number is issued. The real deadline of each order is its `expires_ts` |
| Refund | Automatic and full (`order.price`) when no code arrives before the deadline or the rental ends early upstream. Immediate when you cancel before a code arrives |
| One order, one code | Each order delivers one SMS code. Need another code → buy again |
| Rate limit | 240 requests per minute per `api_key`, shared by all OTP endpoints (per IP for calls without a key). HTTP 429 with a `Retry-After` header when exceeded |
| Slow calls | `/buy` can take up to about 90 s; `/check` and `/cancel` 30 s or more. Use client timeouts of 120 s and 60 s. Never repeat a `/buy` automatically after a timeout (section 2.2) |
| Response shape | `{"status": true, ...}` or `{"status": false, "msg": "..."}` with HTTP 200 |
| Validation errors | HTTP 422 JSON when you send `Accept: application/json`; without it the server answers HTTP 302. Always send the header |
| Messages | Fixed English texts. The language chosen for the Reseller API does not apply here. Branch on the fields (`status`, `sold_out`, `need_deposit`, `thieu`, `order.status`), not on `msg` |
| Module switched off | If the shop switches the OTP service off, every endpoint except `/balance` answers HTTP 404 |
| Time zone | `expires_at` is Vietnam time (UTC+7). Prefer `expires_ts` (unix seconds) or `expires_at_iso` (ISO-8601 with offset) |

## 2. Flow

1. `GET /countries` → pick a `country` slug (e.g. `philippines`).
2. `GET /services?country=…&api_key=…` → pick a `service` slug (e.g. `facebook`). This list is only an overview.
3. `GET /servers?country=…&service=…&api_key=…` → the rows you can buy right now, each with **your** `price`. Take one row's `server` or `operator` (`vip1`, `vip2`, `vip3`, `vip4`, or standard server code).
4. `POST /buy` with `api_key`, `country`, `service`, `operator` (or `server`) → you get `order.id`, `order.phone` and `order.expires_ts`. Keep the `id`: no endpoint lists your orders.
5. Submit `order.phone` to the app you are verifying.
6. Poll `GET /check?id=…` every 3–5 s until `order.status` is `RECEIVED` (read `order.code`) or `REFUNDED` (no code, the money is back).
7. Optional: `POST /cancel` while the order is still `WAITING` to give up and get the refund at once.

### 2.1 Pricing and your discount

- Every row has a **list price**. Your account can have a **discount**: a percentage the shop sets on your account (the same reseller discount as in the Reseller API). You cannot change it through the API.
- **Your price** = list price minus your discount, rounded **up** to a whole VND. The discount never takes a price below the shop's cost for that number, so on some rows your price is higher than the plain percentage would give (it is never above the list price).
- Send your `api_key` on `/services` and `/servers` to see your prices. Without a key, or with a wrong key, both endpoints answer with list prices; a wrong key is **not** reported as an error there. When you are logged in, the website shows you the same prices.
- `/buy` charges exactly the `price` that a keyed `/servers` call shows for that row at that moment: both use the same rule. `order.price` is the amount charged, and a refund returns exactly `order.price`.
- Prices follow the upstream market and can change at any time, including between your `/servers` call and your `/buy`. `/buy` takes no maximum-price parameter, so call `/servers` right before `/buy` and read `order.price` afterwards.
- `price_from` in `/services` is the lowest of your prices across the sources that list the service — an indication, not necessarily a price you can buy at (see 4.2).

### 2.2 Timeouts, retries and the rate limit

- **`/buy` can take up to about 90 seconds** (some tiers need several upstream calls before the number is known). Use a client timeout of at least 120 s.
- **Never repeat a `/buy` automatically after a timeout or a dropped connection.** The order may already exist and be charged, no endpoint lists orders, and a repeat rents a second number. Instead, check `/balance` and look at your recent orders on the website's OTP page (`https://acckit.com/buy-otp`, logged in) — API orders are listed there too. A number you never use is normally refunded at the end of its hold time. An HTTP 429 is different: it is rejected before anything happens, so the same `/buy` can be sent again after `Retry-After` seconds.
- `/check` and `/cancel` refresh the order from upstream and can take 30 s or more. Use a 60 s timeout. Both are safe to repeat.
- `/countries`, `/services` and `/servers` are usually quick but can take several seconds while their data is refreshed. Use a 60 s timeout.
- The 240 requests per minute are counted per `api_key` across every OTP call, `/buy` and `/servers` included. Polling one order every 3 s uses 20 requests per minute, so one key can follow at most 12 orders at that pace (20 at a 5 s pace), minus what your other calls use. Successful responses carry `X-RateLimit-Limit` and `X-RateLimit-Remaining`; on HTTP 429 wait `Retry-After` seconds.

## 3. Endpoint index

| Method | Path | Key | Purpose |
|---|---|---|---|
| GET | `/countries` | no | Countries on sale |
| GET | `/services?country=` | optional (your prices) | Overview of the services in one country: lowest price and total stock |
| GET | `/servers?country=&service=` | optional (your prices) | The rows you can buy now for that pair, best first |
| POST | `/buy` | yes | Charge the wallet and rent a number |
| GET or POST | `/check` | yes | Poll one order |
| POST | `/cancel` | yes | Give up an order before its code arrives → refund |
| GET or POST | `/balance` | yes | Wallet balance in VND |

A wrong HTTP method (for example `GET /buy`) answers HTTP 405.

## 4. Endpoints

### 4.1 GET /countries

```bash
curl -H "Accept: application/json" "https://acckit.com/api/v1/otp/countries"
```

```json
{"status": true, "data": [
  {"country": "afghanistan", "name": "Afghanistan", "prefix": "+93", "iso": "af"},
  {"country": "philippines", "name": "Philippines", "prefix": "+63", "iso": "ph"}
]}
```

`country` is the slug to send to the other endpoints, `name` the English name, `prefix` the dialling code and `iso` the 2-letter code (`prefix` and `iso` can be empty for a few countries). Sorted by `name`. Countries the shop does not sell are left out.

### 4.2 GET /services

| Param | Required | Description |
|---|---|---|
| `country` | yes | Country slug from `/countries` |
| `api_key` | no | Send it to get `price_from` at your price |

```bash
curl -H "Accept: application/json" "https://acckit.com/api/v1/otp/services?country=philippines&api_key=YOUR_API_KEY"
```

```json
{"status": true, "data": [
  {"service": "facebook", "price_from": 2785, "qty": 653226, "vip": true},
  {"service": "google", "price_from": 3100, "qty": 120544, "vip": true}
]}
```

| Field | Meaning |
|---|---|
| `service` | Slug to send to `/servers` and `/buy` (e.g. `facebook`, `google`, `telegram`, `whatsapp`, `tiktok`, `shopee`) |
| `price_from` | Lowest price across the sources that list this service, VND. Your price when you sent a valid `api_key`, otherwise the list price |
| `qty` | Sum of the stock reported for the `normal`, `vip` and `vip2` tiers. A service that only `vip3` sells shows `1` |
| `vip` | `true` when the `vip` tier lists this service. The other VIP tiers (`vip2`–`vip4`) are not reflected here — check `/servers` |

Sorted alphabetically by `service`. Services the shop does not sell, and services whose lowest list price is above the shop's price ceiling, are left out.

This list is an **overview built from the source catalogues**, not from the rows you can buy:

- It ignores servers that are temporarily hidden (see 4.3), so `price_from` can be lower than any row `/servers` returns, and `/servers` can return `[]` for a listed service.
- It does not include the `vip4` tier (Vietnam), and of the services that only `vip3` (USA) sells it lists just a few popular ones. `/servers` can therefore return rows for a service that is not listed here — if you know the slug, call `/servers` directly.
- `/servers` is authoritative.

### 4.3 GET /servers

| Param | Required | Description |
|---|---|---|
| `country` | yes | Country slug |
| `service` | yes | Service slug |
| `api_key` | no | Send it to get `price` at your price, i.e. what `/buy` charges |

```bash
curl -H "Accept: application/json" "https://acckit.com/api/v1/otp/servers?country=philippines&service=facebook&api_key=YOUR_API_KEY"
```

```json
{"status": true, "data": [
  {"server": "vip1", "server_name": "SV VIP 1", "operator": "vip1", "price": 4199, "qty": 148149, "rate": null, "tier": "vip1", "hold": 15, "ve_rate": 54, "ve_mau": 67, "ghim": false},
  {"server": "vip2", "server_name": "SV VIP 2", "operator": "vip2", "price": 3100, "qty": 79124, "rate": null, "tier": "vip2", "hold": 15, "ve_rate": null, "ve_mau": 117, "ghim": false},
  {"server": "virtual34", "server_name": "SV thường (virtual34)", "operator": "virtual34", "price": 2785, "qty": 400458, "rate": 72.5, "tier": "normal", "hold": 15, "ve_rate": 31, "ve_mau": 29, "ghim": false},
  {"server": "virtual38", "server_name": "SV thường (virtual38)", "operator": "virtual38", "price": 2890, "qty": 23110, "rate": 0, "tier": "normal", "hold": 15, "ve_rate": 12, "ve_mau": 17, "ghim": false}
]}
```

| Field | Meaning |
|---|---|
| `server` | Server code: `vip1`, `vip2`, `vip3`, `vip4` (VIP servers) or operator code for standard server (e.g. `virtual34`). Send it in `/buy` as `operator` or `server` |
| `server_name` | Display label (e.g. `SV VIP 1`, `SV VIP 2`, `SV VIP 3 (US)`, `SV VIP 4`, `SV thường (virtual34)`) |
| `operator` | Server identifier (matches `server`). Send it in `/buy` as `operator` |
| `tier` | Service level of the row: `vip1`, `vip2`, `vip3`, `vip4`, or `normal` |
| `price` | VND per number: your price with a valid `api_key`, otherwise the list price. It is a quote: `/buy` recomputes it from live data (section 2.1) |
| `qty` | Stock reported, an estimate (a server can still run out). Always `1` on `vip3` and `vip4`, whose providers publish no stock — do not filter those rows on `qty` |
| `rate` | Success rate published for standard servers (`0` when not published). Always `null` on VIP tiers. Prefer `ve_rate` |
| `hold` | Usual hold time of the server, in minutes. The order's own `hold` and `expires_ts` are what count |
| `ve_rate` | Our measured success rate, 0–100, or `null` — see below |
| `ve_mau` | Number of finished orders behind `ve_rate` — see below |
| `ghim` | `true` = a newly added server pinned to the top of the list until it has statistics. Unproven |

Tiers:

| `tier` | `server` | Countries | Usual `hold` | `qty` |
|---|---|---|---|---|
| `normal` | several per country, e.g. `virtual34` | many | 15 | stock estimate |
| `vip1` | `vip1` | many | 15 | stock estimate |
| `vip2` | `vip2` | many | 15 | stock estimate |
| `vip3` | `vip3` | USA only | 15 | always `1` |
| `vip4` | `vip4` | Vietnam only | 10 (set by the shop) | always `1` |

Each VIP tier has at most one row per country + service. A VIP tier is not automatically more expensive: prices depend on the tier and the country.

**`ve_rate` and `ve_mau`.** `ve_rate` is the share of our own orders for this country + service over the last 7 days that received a code, counting only finished orders (`RECEIVED` or `REFUNDED`):

- it is measured on this exact server once the server has at least 6 finished orders; before that, on all servers of the same tier for this country + service;
- it is set to `0` early when the first 3 or more finished orders all failed;
- it is `null` when there is not enough data yet.

`ve_mau` is the number of finished orders behind `ve_rate`. When `ve_rate` is `null`, `ve_mau` is the total number of finished orders for this country + service across all servers — context only, not this row's own count. Statistics are refreshed every 10 minutes.

**Order of the rows.** Rows are grouped as below; inside a group they are sorted by list price, cheapest first:

1. `ghim: true` rows (new servers still collecting statistics);
2. `ve_rate` 45 or more;
3. `ve_rate` `null`, with a list price at most 1.3 × the cheapest row's;
4. `ve_rate` 20 to 44;
5. `ve_rate` `null`, with a list price above 1.3 × the cheapest row's;
6. `ve_rate` below 20.

So the first row is not always a proven one. If you want a server with a track record, take the first row with `"ghim": false` and a `ve_rate`.

**Rows that are not listed.** `/servers` shows only what can be bought now. A row is left out when:

- the shop does not sell that country or service (then `data` is `[]` and nothing can be bought);
- its upstream is temporarily unavailable (hidden for at least 10 minutes at a time);
- the server has just run out of numbers: hidden for 5 minutes, then 30 minutes, then 2 hours if it keeps running out. This applies to `normal`, `vip1` and `vip2`; `vip3` and `vip4` rows usually stay listed;
- its list price is above the shop's price ceiling;
- it delivered no code at all over at least 10 recent finished orders (`ve_rate` 0 with `ve_mau` 10 or more).

An unknown or misspelled `country` or `service` also gives `{"status": true, "data": []}`.

### 4.4 POST /buy

| Param | Type | Required | Description |
|---|---|---|---|
| `api_key` | string | yes | Your API key |
| `country` | string | yes | Country slug |
| `service` | string | yes | Service slug |
| `operator` | string | yes* | `server` or `operator` of the chosen `/servers` row (`vip1`, `vip2`, `vip3`, `vip4`, or `virtual34`, etc.) |
| `server` | string | yes* | Alias for `operator`. You can send either `operator` or `server` |

How the row is chosen:
- Send `operator` (or `server`) equal to the server code (`vip1`, `vip2`, `vip3`, `vip4`, or standard code).

What happens: `/buy` reloads the live server list (the same list `/servers` returns), finds the row, computes your price, debits your wallet and asks for a number. If no number is issued, the money is refunded before the response. **This can take up to about 90 s** — read section 2.2 before you set timeouts or retries.

```bash
curl -X POST --max-time 120 -H "Accept: application/json" "https://acckit.com/api/v1/otp/buy" \
  -d "api_key=YOUR_API_KEY" -d "country=philippines" -d "service=facebook" \
  -d "operator=vip1"
```

Success:

```json
{
  "status": true,
  "order": {
    "id": 214,
    "phone": "+639171234567",
    "status": "WAITING",
    "code": null,
    "text": null,
    "product": "facebook",
    "country": "philippines",
    "operator": "virtual34",
    "price": 2785,
    "expires_at": "18:45:00 15/08/2026",
    "expires_ts": 1786794300,
    "vip": false,
    "hold": 15,
    "expires_at_iso": "2026-08-15T18:45:00+07:00"
  }
}
```

| Field | Meaning |
|---|---|
| `id` | Order id — keep it for `/check` and `/cancel`; no endpoint lists your orders |
| `phone` | The rented number in international format with `+`. Very rarely `null` when the number could not be read back — cancel such an order (full refund) |
| `status` | `WAITING` after a successful `/buy`. See section 5 |
| `code` | The OTP code as a string (usually digits) once `status` is `RECEIVED`, otherwise `null` |
| `text` | The SMS as the source returns it: the full message when the source provides it (`normal`, `vip3`, `vip4`); on `vip` and `vip2` usually just the code. `null` until `RECEIVED` |
| `product`, `country`, `operator` | The service, country and operator you bought |
| `price` | VND charged — exactly what a refund returns |
| `expires_at` | Deadline of the hold, Vietnam time (UTC+7), format `HH:MM:SS DD/MM/YYYY` |
| `expires_ts` | Same moment as a unix timestamp — use this one |
| `expires_at_iso` | Same moment in ISO-8601 with offset |
| `vip` | `true` for every VIP tier (`vip` to `vip4`), `false` for `normal` |
| `hold` | Length of this order's hold, in minutes (rounded). It can differ from the row's `hold` |

Failures come with HTTP 200 and always carry `sold_out`, `need_deposit` and `thieu` (except the invalid-key answer):

```json
{"status": false, "msg": "Insufficient balance!", "sold_out": false, "need_deposit": true, "thieu": 1215}
```

```json
{"status": false, "msg": "This server is out of stock, please pick another.", "sold_out": true, "need_deposit": false, "thieu": 0}
```

| Field | Meaning |
|---|---|
| `sold_out` | `true` → this row cannot deliver right now. Nothing was charged, or it was already refunded. Reload `/servers` and choose a **different** server |
| `need_deposit` | `true` → your balance is below the price. With `thieu` equal to 0 it means a temporary error while charging: retry |
| `thieu` | VND missing: the order price minus your current balance |

All failure messages of `/buy`:

| `msg` | Flags | Charged? | What to do |
|---|---|---|---|
| `Insufficient balance!` | `need_deposit: true` | no | Top up at least `thieu` VND. If `thieu` is 0, retry |
| `This server is out of stock, please pick another.` | `sold_out: true` | no | The row is not in the current `/servers` list: it ran out, is hidden, its price moved above the ceiling, or your `country` / `service` / `operator` match no row (check the spelling). Reload `/servers` |
| `This country/service just ran out of numbers — your money has been refunded. Please pick another country or service.` | `sold_out: true` | refunded | A VIP tier has no number for this country + service right now. Pick another row, or another country |
| `Could not get a number right now — your money has been refunded. Please try another server.` | `sold_out: true` | refunded | A `normal` server ran out (it is now hidden for a while), or the source is temporarily unavailable (all its rows are hidden for at least 10 minutes). Pick another row |
| same text as above | `sold_out: false` | refunded | Any other upstream error; nothing is hidden. Try another row, or the same one a little later |
| `This order was cancelled while the number was being issued — your money has been refunded.` | `sold_out: false` | refunded (by the cancel) | The order was cancelled — for example on the website — while `/buy` was still waiting for the number. The number was released. Buy again if you still need one |
| `Invalid api_key` | none | no | Key missing or wrong |

After a `sold_out` failure on a `vip3` or `vip4` row, that row can stay listed — even first, pinned with `ghim: true`. Remember the rows that just failed for about 10 minutes and skip them, or you may pay and be refunded on the same row again and again.

### 4.5 GET or POST /check

| Param | Required | Description |
|---|---|---|
| `api_key` | yes | Your API key |
| `id` | yes | Order id |

```bash
curl --max-time 60 -H "Accept: application/json" "https://acckit.com/api/v1/otp/check?api_key=YOUR_API_KEY&id=214"
```

```json
{"status": true, "order": {"id": 214, "phone": "+639171234567", "status": "RECEIVED", "code": "384729", "text": "384729 is your Facebook confirmation code", "product": "facebook", "country": "philippines", "operator": "virtual34", "price": 2785, "expires_at": "18:45:00 15/08/2026", "expires_ts": 1786794300, "vip": false, "hold": 15, "expires_at_iso": "2026-08-15T18:45:00+07:00"}}
```

While the order is open, each call first asks the source for news, which can take 30 s or more, and processes the refund once the order is over (section 5). A final order (`RECEIVED` or `REFUNDED`) is answered from our records at once, as often as you like. Unknown id or another customer's order → `{"status": false, "msg": "Order not found"}`. The response does not include your balance: call `/balance`.

### 4.6 POST /cancel

| Param | Required | Description |
|---|---|---|
| `api_key` | yes | Your API key |
| `id` | yes | Order id |

```bash
curl -X POST --max-time 60 -H "Accept: application/json" "https://acckit.com/api/v1/otp/cancel" \
  -d "api_key=YOUR_API_KEY" -d "id=214"
```

- Order still open (no code yet), any tier, any time → full refund at once: `{"status": true, "order": {... "status": "REFUNDED" ...}}`. Cancelling means giving up: an SMS that arrives afterwards, or that reached the source but was not fetched yet, is discarded. Do not cancel an order whose code you still expect.
- A code landed at the same moment → `{"status": true, "order": {... "status": "RECEIVED", "code": "..." ...}}` and no refund. **Always read `order.status` in the reply.**
- Already refunded → `{"status": true, "order": {...}}` again, so a repeated cancel is harmless.
- Code already received → `{"status": false, "msg": "This order already received a code."}` — a used number cannot be refunded.

### 4.7 GET or POST /balance

```bash
curl -X POST -H "Accept: application/json" "https://acckit.com/api/v1/otp/balance" -d "api_key=YOUR_API_KEY"
```

```json
{"status": true, "balance": 152000, "currency": "VND"}
```

Works even when the OTP service is switched off. The wallet is shared with the Reseller and SMM APIs and with the website. `/buy` and `/check` do not return the balance.

## 5. Order statuses and refunds

| `status` | Meaning | What to do |
|---|---|---|
| `PENDING` | Internal: exists only while `/buy` is still running, and `/buy` never returns it. If such an order is cancelled (the website shows it), it is refunded and `/buy` answers with the "cancelled while the number was being issued" failure | Treat as `WAITING` |
| `WAITING` | Number issued, waiting for the SMS | Submit `phone` to the target app, keep polling |
| `RECEIVED` | Code arrived. Final; the order stays charged | Read `code` / `text` |
| `REFUNDED` | Final. `order.price` is back in your wallet: no code before the deadline, the rental ended early upstream, or you cancelled | Buy again, preferably on another row |

How refunds work:

- **Deadline.** `expires_ts` is the order's own deadline: the source's hold window, but never more than 15 minutes after the number is issued. The `hold` column of `/servers` is only the usual value for the tier.
- **Automatic refund.** An open order is refunded when the source reports that the rental ended without a code (this can happen before `expires_ts`), or once `expires_ts` has passed by 30 seconds. This check runs on your `/check` calls and on a background job every minute, so you do not have to poll for the refund: it normally lands within about 2 minutes after `expires_ts`.
- **Late codes still count.** A code that arrives before the refund is processed makes the order `RECEIVED`, even a few seconds after `expires_ts`.
- **If you stop polling,** the background job keeps collecting codes and refunds. Nothing is lost: a later `/check` shows the result.
- **Ignored SMS.** A code that the same phone number already delivered on an earlier order, or an SMS sent before the rental started, is ignored (it belongs to a previous user of that number). The order keeps waiting and is refunded at the deadline if no new code arrives.
- **One code per order.** Once `RECEIVED`, the order is final and later SMS are not delivered.
- **Failed `/buy`.** If no number could be issued after the debit, the refund is made before `/buy` answers.

## 6. Error reference

| HTTP | Body | When |
|---|---|---|
| 200 | `{"status": false, "msg": "Invalid api_key"}` | Key missing or wrong on `/buy`, `/check`, `/cancel`, `/balance` |
| 200 | `{"status": false, "msg": "Order not found"}` | Wrong `id`, or another customer's order |
| 200 | `{"status": false, "msg": "...", "sold_out": true, ...}` | `/buy`: that row cannot deliver now. Reload `/servers` and choose another row (4.4) |
| 200 | `{"status": false, "msg": "Insufficient balance!", "need_deposit": true, "thieu": N, ...}` | `/buy`: top up at least N VND (N = 0: temporary error, retry) |
| 200 | `{"status": false, "msg": "Could not get a number right now — ...", "sold_out": false, ...}` | `/buy`: upstream error, already refunded |
| 200 | `{"status": false, "msg": "This order already received a code."}` | `/cancel` after the code arrived |
| 422 | `{"message": "The given data was invalid.", "errors": {...}}` | Missing or invalid `country`, `service`, `operator` or `id` (only with `Accept: application/json`; otherwise HTTP 302) |
| 404 | not meaningful | OTP service switched off (every endpoint except `/balance`), or a wrong path |
| 405 | not meaningful | Wrong HTTP method, e.g. `GET /buy` |
| 429 | `{"message": "Too Many Attempts."}` with `Accept: application/json` (an HTML page without it), plus a `Retry-After` header | More than 240 requests per minute for this key |

## 7. Code examples

### cURL

```bash
BASE="https://acckit.com/api/v1/otp"; KEY="YOUR_API_KEY"; H="Accept: application/json"
curl -s -H "$H" "$BASE/servers?country=philippines&service=facebook&api_key=$KEY"
curl -s -X POST --max-time 120 -H "$H" "$BASE/buy" -d "api_key=$KEY" -d "country=philippines" -d "service=facebook" -d "operator=virtual34" -d "provider=fivesim"
curl -s --max-time 60 -H "$H" "$BASE/check?api_key=$KEY&id=214"
curl -s -X POST --max-time 60 -H "$H" "$BASE/cancel" -d "api_key=$KEY" -d "id=214"
curl -s -H "$H" "$BASE/balance?api_key=$KEY"
```

### Python — buy a number and wait for the code

```python
import time
import requests

BASE = "https://acckit.com/api/v1/otp"
KEY = "YOUR_API_KEY"
H = {"Accept": "application/json"}


def call(method, path, timeout, **params):
    """One API call. Repeats only after HTTP 429, which is rejected before anything happens."""
    params["api_key"] = KEY
    while True:
        if method == "GET":
            r = requests.get(BASE + path, params=params, headers=H, timeout=timeout)
        else:
            r = requests.post(BASE + path, data=params, headers=H, timeout=timeout)
        if r.status_code == 429:
            time.sleep(int(r.headers.get("Retry-After", "10")))
            continue
        r.raise_for_status()
        return r.json()


def get_code(country, service, on_number, max_tries=3):
    failed = set()                                       # (provider, operator) rows that just failed
    for _ in range(max_tries):
        rows = call("GET", "/servers", 60, country=country, service=service)["data"]   # your prices
        rows = [r for r in rows if (r["provider"], r["operator"]) not in failed]
        if not rows:
            raise RuntimeError("nothing to buy for this country/service right now")
        row = next((r for r in rows if not r["ghim"]), rows[0])   # prefer rows with statistics
        failed.add((row["provider"], row["operator"]))

        # 120 s timeout. If it times out, do NOT call /buy again: the number may already be rented.
        res = call("POST", "/buy", 120, country=country, service=service,
                   operator=row["operator"], provider=row["provider"])
        if not res["status"]:
            if "sold_out" not in res:                    # e.g. "Invalid api_key"
                raise RuntimeError(res["msg"])
            if res["need_deposit"] and res["thieu"] > 0:
                raise RuntimeError(f"top up at least {res['thieu']} VND")
            if res["need_deposit"]:                      # thieu == 0: temporary error, same row is fine
                failed.discard((row["provider"], row["operator"]))
                time.sleep(5)
            continue                                     # not charged, or already refunded: next row

        order = res["order"]
        if not order["phone"]:                           # very rare: no usable number, take the refund
            call("POST", "/cancel", 60, id=order["id"])
            continue
        on_number(order["phone"])                        # submit the number to the target app here

        while time.time() < order["expires_ts"] + 180:   # the refund lands shortly after expires_ts
            time.sleep(4)                                # 3-5 s per order
            try:
                c = call("GET", "/check", 60, id=order["id"])
            except requests.RequestException:
                continue                                 # /check is safe to repeat
            if not c["status"]:
                raise RuntimeError(c["msg"])
            o = c["order"]
            if o["status"] == "RECEIVED":
                return o["phone"], o["code"]
            if o["status"] == "REFUNDED":
                break                                    # no code in time; money is back, try another row
        else:                                            # still open after the deadline: give up
            c = call("POST", "/cancel", 60, id=order["id"])
            if not c["status"]:                          # "This order already received a code."
                c = call("GET", "/check", 60, id=order["id"])
            if c["order"]["status"] == "RECEIVED":       # the code won the race
                return c["order"]["phone"], c["order"]["code"]
    raise RuntimeError(f"no code after {max_tries} numbers")


print(get_code("philippines", "facebook", on_number=lambda phone: print("use this number:", phone)))
```

## 8. Notes

- **Choosing a row.** Prefer a high `ve_rate` backed by a large `ve_mau`. `null` means "not measured yet", and `ghim: true` rows are new and unproven. A VIP tier is not automatically dearer or more reliable: compare `price` and `ve_rate` row by row.
- **Trying again.** After a failed `/buy` or a `REFUNDED` order, call `/servers` again and usually pick a different row. Skip rows that failed in the last 10 minutes or so.
- **Branch on fields, never on `msg`.** Use `status`, `sold_out`, `need_deposit`, `thieu` and `order.status`.
- **Keep every `order.id`.** No endpoint lists your orders; the website's OTP page shows the latest ones.
- All amounts are VND integers. The wallet is shared with the Reseller and SMM APIs and with the website.


---

# AccKit SMM API v1 — likes, followers, views (Buff)

> **Tiếng Việt (tóm tắt nhanh):** API buff like / follow / view cho Facebook, TikTok, Instagram, YouTube, Telegram, X, Shopee… Địa chỉ gốc `https://acckit.com/api/v1/smm`, dùng chung `api_key` với API bán hàng và API OTP, tiền **VND**. `rate` là giá cho 1.000 đơn vị; gọi `/services` **kèm key** thì `rate` là giá của chính tài khoản bạn (đã trừ chiết khấu), tiền bị trừ khi đặt = `ceil(số lượng × rate / 1000)`. Hai kiểu gọi: REST (`/services`, `/buy`, `/status`, `/cancel`, `/balance`) và chuẩn SMM Panel v2 (`POST /api/v1/smm` với `key` + `action`). Khác chuẩn v2: tiền VND, lỗi trả kèm mã HTTP 4xx/5xx, `cancel` chỉ nhận **một** mã đơn qua `order`, không có refill / drip-feed / bình luận tự nhập qua API (chỉ đặt gói `type` = `Default`). Hủy đơn: KHÔNG hoàn ngay — đơn ở `Canceling` ít nhất 12 giờ (`refund_after`) để chốt đúng số lượng đã chạy, sau đó phần chưa chạy được hoàn tự động. Đơn `Partial` hoặc bị nhà cung cấp hủy được hoàn tự động trong khoảng 10 phút; mỗi đơn chỉ hoàn một lần. Luôn gửi header `Accept: application/json` và URL-encode `link`. Chi tiết tiếng Anh bên dưới; có thể đưa nguyên file này cho AI / agent của bạn.

*Document version 2026-10-02 — checked against the source code on 2026-10-02. This copy: https://acckit.com, 2026-10-31.*

## 1. Quick facts

| Item | Value |
|---|---|
| Base URL | `https://acckit.com/api/v1/smm` |
| Authentication | Your key as `api_key` or `key` (form field, query parameter or JSON field), or header `Authorization: Bearer YOUR_API_KEY`, or header `X-API-Key: YOUR_API_KEY`. Checked in that order; the first non-empty one is used |
| Getting the key | Log in → **Account** page (`https://acckit.com/account`) → section **API**. The same key works for the Reseller API and the OTP API. Keys are per site: call the API on the domain where your account was created |
| Request encoding | `application/x-www-form-urlencoded` (recommended), query string, or a JSON body (`Content-Type: application/json`) with the same field names. **URL-encode `link`** |
| Always send | `Accept: application/json`. The endpoints' own answers are JSON anyway, but without this header framework-level errors (429, 405, unknown path, maintenance, crashes) come back as HTML pages |
| Two calling styles | **REST** endpoints, and the **SMM Panel v2** convention: `POST https://acckit.com/api/v1/smm` with `key` + `action` (differences in section 10) |
| Money | **VND**. `rate` = price per **1,000** units. With your key, `rate` is your own price (account discount applied). Charge = `ceil(quantity × rate / 1000)` VND, an integer |
| What you can order | Services with `type` = `Default` (a link and a quantity). Only `service`, `link` and `quantity` are read: no drip-feed, no custom comments, no refill through the API |
| Rate limit | 60 requests per minute per client IP, shared with the Reseller API (the OTP API has its own quota). HTTP 429 with `Retry-After` when exceeded |
| Failure shape | `{"status": false, "error": "<stable English text>", "message": "<text for humans>"}` with HTTP 400, 401, 404, 500 or 503 |
| Order ids | The shop's own ids, shown as `#BUFF-<id>` on the website |
| Statuses | `Verifying` (charged and held until the order is confirmed — rare), `Pending`, `In progress`, `Processing`, `Canceling` (you cancelled; held at least 12 hours before the refund), and the final `Completed`, `Partial`, `Canceled`, `Refunded` |
| Refunds | Your cancel: never at once — the order stays `Canceling` for at least 12 hours, then the undelivered part (the provider's final count) is refunded automatically. `Partial` or provider-cancelled orders: automatic, usually within 10 minutes. An order is never refunded twice |

## 2. Endpoints and typical flow

| REST (`https://acckit.com/api/v1/smm/…`) | v2 (`POST https://acckit.com/api/v1/smm`) | Key | Purpose |
|---|---|---|---|
| GET or POST `/services` | `action=services` | optional | Catalogue: price per 1,000 (yours when a key is sent), min / max, refill and cancel flags |
| POST `/buy` or POST `/order` | `action=add` | required | Create an order and pay for it |
| GET or POST `/status` | `action=status` | required | One order (checked live) or several (stored data) |
| POST `/cancel` | `action=cancel` | required | Cancel one running order; the undelivered part is refunded automatically, at least 12 hours later |
| GET or POST `/balance` | `action=balance` | required | Wallet balance |

- The v2 URL accepts POST only. Any other `action` (for example `refill` or `refill_status`) answers HTTP 400 `Incorrect request action`.
- `/buy`, `/order` and `/cancel` accept POST only; a GET answers HTTP 405.
- For AI agents that also use the Reseller API: `POST https://acckit.com/api/v1/smm/order` **creates and charges** a Buff order, while the Reseller API's `POST https://acckit.com/api/v1/order` only reads an order. Do not mix them up.

Typical flow:

1. `POST /services` with your key → pick a service with `type` = `Default` by `category` and `name`; note its `rate`, `min`, `max`, `cancel` and `refill`.
2. `POST /buy` with `service_id`, `link` (URL-encoded) and `quantity` → keep `order_id` and `charge`.
3. Poll `/status` until the status is final (section 5): one id every few minutes, or many ids in one call every 10 minutes.
4. Optional: `POST /cancel` while the order is still running, if the service has `cancel: true`.
5. `Partial` and provider-cancelled orders are refunded automatically; there is nothing to call.

## 3. Services and prices — GET or POST /services (or action=services)

```bash
# list prices, no key needed
curl -H "Accept: application/json" "https://acckit.com/api/v1/smm/services"

# your own prices, filtered by category
curl -X POST "https://acckit.com/api/v1/smm/services" -H "Accept: application/json" \
  -d "api_key=YOUR_API_KEY" --data-urlencode "category=Facebook Post Likes"
```

| Param | Required | Description |
|---|---|---|
| `api_key` or `key` (or a header) | no | With a valid key, `rate` is **your** price. No key, or a wrong key, gives list prices (no error) |
| `category` | no | Case-insensitive substring filter on `category` |

The answer is a bare JSON array of the enabled services, cheapest list price first. Illustrative example, called with the key of an account that has a 10 % discount:

```json
[
  {
    "service": 2936,
    "name": "Facebook Post Likes [EMOTICONS] [LOVE] [Refill: No] [Max: 10K] [Start Time: 0 - 1 Hr] [Speed: 5K/DAY]",
    "type": "Default",
    "category": "Facebook Post Likes [EMOJI]",
    "rate": 4843.8,
    "min": 10,
    "max": 10000,
    "refill": false,
    "cancel": true
  }
]
```

| Field | Meaning |
|---|---|
| `service` | Service id: send it as `service_id` (REST) or `service` (v2) |
| `name` | Description of the service (quality, speed, start time, refill period…), not translated |
| `type` | Order type. Only `Default` can be ordered through this API; skip every other type |
| `category` | Platform + action group. It also decides which links are accepted (section 4) |
| `rate` | VND per 1,000 units: yours when you sent a key, otherwise the list price. A JSON number with up to 2 decimals |
| `min` / `max` | Allowed `quantity` per order |
| `refill` | The service has a refill (top-up) guarantee. Refills are requested on the website, not through the API (section 7) |
| `cancel` | `true`: a running order can be cancelled with `/cancel` |

### Pricing and your discount

- **List price** = `rate` from a call without a key. It is the same for every customer.
- **Your discount** is a whole-number percentage the shop sets on your account (ask support; it cannot be changed through the API). It applies to Buff orders through the API and on the website alike; when you are logged in, the website's Buff page shows the same prices.
- **Your rate** = list × (100 − discount) / 100, rounded **up** to 0.01 VND, but never below the shop's own cost for that service. On low-margin services the discount stops there, so your rate can be higher than list × (1 − discount).
- **Charge** = `ceil(quantity × your rate / 1000)` VND, computed exactly (no floating-point drift), with the same formula as the website. It is deducted when the order is created, returned as `charge`, and fixed on the order.
- Prices follow the shop's catalogue and can change; the price at the moment of `/buy` applies. Read `/services` with your key before ordering.

Worked example, account with a 10 % discount:

| Example | List price | Your price |
|---|---|---|
| Service A, `rate` | 45,000 | 40,500 |
| Service A, 1,000 units | 45,000 VND | 40,500 VND |
| Service B, `rate` | 5,382 | 4,843.8 |
| Service B, 500 units | 2,691 VND | 2,422 VND (2,421.9 rounded up) |

## 4. Create an order — POST /buy (or /order, or action=add)

| Param | Type | Required | Description |
|---|---|---|---|
| `api_key` or `key` | string | yes | Your API key (or a header, see section 1) |
| `service_id` or `service` | integer | yes | Service id from `/services` (`type` = `Default`) |
| `link` | string | yes | Target URL (post, video, profile, channel…) or `@username` where allowed. **URL-encode it.** It must pass the link rules below |
| `quantity` | integer | yes | Between the service's `min` and `max` |

Any other field is ignored, including `runs` / `interval` (drip-feed), `comments`, `usernames` and `answer_number`: a request with `runs` and `interval` becomes one normal order of `quantity` units (charged for `quantity`), and services that need such fields (any `type` other than `Default`) are normally refused by the provider (`Provider error`, nothing charged).

```bash
curl -X POST "https://acckit.com/api/v1/smm/buy" -H "Accept: application/json" \
  -d "api_key=YOUR_API_KEY" -d "service_id=2936" -d "quantity=500" \
  --data-urlencode "link=https://www.facebook.com/permalink.php?story_fbid=123&id=456"
```

Use `--data-urlencode` (or your HTTP library's form encoder) for `link`. With a plain `-d "link=…&id=456"`, curl sends `&id=456` as a separate field: the order is placed, and charged, on the cut-down link `…?story_fbid=123`.

REST success (HTTP 200), same account (10 % discount):

```json
{
  "status": true,
  "message": "Tạo đơn Buff thành công! Mã đơn: #BUFF-1234",
  "order_id": 1234,
  "order_status": "Pending",
  "service_id": 2936,
  "quantity": 500,
  "charge": 2422,
  "balance": 97578,
  "currency": "VND"
}
```

`charge` is the amount deducted in VND (integer). `balance` is your balance right after the order, read from the database (integer).

`order_status` is normally `Pending`. It is `Verifying` when the order was sent on but no confirmation came back in time: the charge is held and the order is checked on our side, then it either continues as a normal order (`Pending` / `In progress` …) or ends `Canceled` with the full amount refunded. A `Verifying` order cannot be cancelled. The v2 answer (`{"order": 1234}`) is the same in both cases; read the status with `/status`.

v2 success (`action=add`): `{"order": 1234}`. The charge is not in this answer; use `action=status` or the formula in section 3.

Checks, in this order:

| HTTP | `error` | Cause |
|---|---|---|
| 401 | `Incorrect API key` | Key missing or wrong |
| 400 | `Service ID is required` | `service_id` / `service` missing or not a number |
| 400 | `Link is required` | `link` missing or empty |
| 400 | `Quantity must be greater than 0` | `quantity` missing, zero, negative or not a number |
| 404 | `Service not found or disabled` | Unknown or paused service id |
| 400 | `Quantity must be between MIN and MAX` | Outside the service's range (the real numbers are filled in) |
| 400 | `Invalid link format` | The link does not fit the service (link rules below); `message` says why |
| 400 | `Not enough balance` | Balance below the charge (`message` states the amount needed), or another order or payment took the money at the same moment |
| 500 | `Provider error: <reason>` | The order was refused, or could not be sent. Nothing is charged. `<reason>` is a stable code: `link_busy` (this link already has an order in progress — wait until it finishes), `invalid_link`, `invalid_quantity`, or `unavailable` (the service is not taking orders right now — try later or pick another service); `message` explains in Vietnamese |
| 500 | `System error` | Unexpected error. The order is rolled back; nothing is charged. The details are logged on our side, not returned |

### Link rules

They are checked before any money moves. The platform comes from the service's `category`: the first row that matches, from top to bottom.

| Platform | Keyword in `category` | Accepted `link` |
|---|---|---|
| Facebook | `facebook`, or the word `fb` | URL on `facebook.com`, `fb.com`, `fb.watch`, `fb.me`, `fb.gg` |
| TikTok | `tiktok` | URL on `tiktok.com`, or `@username` |
| Instagram / Threads | `instagram` or `threads` | URL on `instagram.com`, `instagr.am`, `threads.net`, `threads.com`, or `@username` |
| YouTube | `youtube`, or the word `yt` | URL on `youtube.com`, `youtu.be`. A bare `@handle` is rejected: send `https://www.youtube.com/@handle` |
| Telegram | `telegram` | URL on `t.me`, `telegram.me`, `telegram.dog`, or `@username` |
| Shopee | `shopee` | URL on `shopee.vn`, `shopee.co.id`, `shopee.ph`, `shopee.sg`, `shopee.com.my`, `shopee.co.th`, `shopee.tw`, `shopee.com.br`, `shopee.cl`, `shopee.com.co`, `shopee.com.mx`, `shp.ee` |
| X / Twitter | `twitter`, or a category starting with `X -` | URL on `x.com`, `twitter.com`, or `@username` |
| Google | `google` | Any http(s) URL; a bare domain such as `https://example.com` is fine |
| Spotify | `spotify` | URL on `spotify.com`, `spotify.link`, or a URI `spotify:<type>:<id>` |
| Discord | `discord` | URL on `discord.gg`, `discord.com`, `discordapp.com` |
| Anything else | none of the above | Any http(s) URL (a bare domain is fine), or `@username` |

For every link:

- Subdomains are fine (`m.facebook.com`, `vm.tiktok.com`, `www.`…). `https://` may be left out (it is assumed); only `http` and `https` are accepted.
- No spaces, and the host must be a domain name (IP addresses are rejected).
- On a listed platform domain the URL must point at something: a bare `https://www.tiktok.com/` (no path, no query) is rejected.
- `@username` means `@` followed by 2 to 60 letters, digits, `.` or `_`. A username without `@`, a numeric id or any other text is rejected.
- Links shorter than 3 characters are rejected.
- The link is passed to the provider exactly as you sent it (spaces at both ends are trimmed).

Also rejected, with a specific message (words are matched case-insensitively in the link and in the service's category + name):

| Service | Rejected when the link contains | Send instead |
|---|---|---|
| Facebook | `tiktok.com`, `youtube.com`, `youtu.be`, `instagram.com`, `t.me/` | a Facebook link |
| Facebook, category or name containing `page like`, `page follow`, `profile`, `like trang`, `follow trang`, `trang cá nhân`, `sub cá nhân` or `người theo dõi` | `/posts/`, `/photos/`, `/photo.php`, `/videos/`, `/video.php`, `/watch`, `/reel/`, `/reels/`, `permalink.php`, `story_fbid=`, `fbid=`, `story.php` | the page or profile URL, e.g. `https://www.facebook.com/username` or `https://www.facebook.com/profile.php?id=…` |
| TikTok | `facebook.com`, `fb.com`, `youtube.com`, `instagram.com` | a TikTok link |
| TikTok, category or name containing `follower` or `theo dõi` | `/video/` | the profile: `https://www.tiktok.com/@username` or `@username` |
| YouTube | `facebook.com`, `tiktok.com`, `instagram.com` | a YouTube link |
| YouTube, category or name containing `sub` (subscribers) or `đăng ký` | `watch?v=`, `youtu.be/`, `/shorts/` | the channel: `https://www.youtube.com/@channel` or `https://www.youtube.com/channel/…` |
| Instagram / Threads | `facebook.com`, `tiktok.com`, `youtube.com` | an Instagram or Threads link |
| Instagram / Threads, category or name containing `follower` or `theo dõi` | `/p/`, `/reel/`, `/tv/` | the profile: `https://www.instagram.com/username` or `@username` |
| Telegram, Shopee, X / Twitter | `facebook.com`, `tiktok.com`, `youtube.com` | a link of the service's platform |

Every link failure answers HTTP 400 with `error` = `Invalid link format`. Its `message` is Vietnamese for the too-short, other-platform and wrong-object cases, and English when the link is simply not a link of the platform, for example `Please enter a valid YouTube link (https://...).` or `Please enter a valid TikTok link (https://...) or @username.`

### Payment, timeouts and duplicates

- The charge is taken in one database transaction together with the order. The balance is reduced only if it still covers the charge, so two parallel orders can never push it below zero (the second one gets `Not enough balance`). The order is then sent to the provider and saved. If the provider refuses, or anything else fails, the whole transaction is rolled back and nothing is charged.
- A `/buy` call waits for the provider: usually a few seconds, but up to about a minute in the worst case. Use a client timeout of 90 seconds.
- There is no idempotency key and no order-list endpoint. If a `/buy` call times out, do not resend blindly: compare `/balance` with your balance before the call. If it dropped by the charge, the order exists; find its id on the Buff order history page of the website (`https://acckit.com/buff-like/history`), which also lists the orders placed through the API.

## 5. Order status — GET or POST /status (or action=status)

| Param | Required | Description |
|---|---|---|
| `api_key` or `key` (or a header) | yes | Your API key |
| `orders`, `order_id` or `order` | yes | One order id, or several separated by commas (`1234,1235`). If you send more than one of these fields, `orders` wins, then `order_id`, then `order` |

The shape of the answer depends on **how many ids** you send, not on the field name:

- **One id** (`orders=1234` included): the order is first refreshed **live** from the provider (unless it is already `Completed`, `Canceled` or `Refunded`), then returned. This can take up to about 30 seconds; if the provider does not answer, the stored values are returned.
- **Two or more ids**: a JSON object keyed by order id, built from **stored** data without asking the provider, the same for REST and v2. Stored data are refreshed by a background job about every 10 minutes, and whenever a one-id call (or the website) refreshes that order. Right after `/buy` an order shows `Pending`, `start_count` `"0"` and `remains` equal to the quantity until its first refresh. Unknown ids and other customers' ids get `{"error": "Incorrect order ID"}` (still HTTP 200).

REST, one id:

```bash
curl -X POST "https://acckit.com/api/v1/smm/status" -H "Accept: application/json" \
  -d "api_key=YOUR_API_KEY" -d "order_id=1234"
```

```json
{
  "status": true,
  "order_id": 1234,
  "service_id": 2936,
  "link": "https://www.facebook.com/permalink.php?story_fbid=123&id=456",
  "quantity": 500,
  "charge": 2422,
  "order_status": "In progress",
  "start_count": "150",
  "remains": "200",
  "currency": "VND",
  "created_at": "2026-09-30T09:11:00+07:00"
}
```

v2, one id (`action=status` with `order=1234`):

```json
{"charge": "2422", "start_count": "150", "status": "In progress", "remains": "200", "currency": "VND"}
```

Two or more ids, REST or v2 (`orders=1234,1235,9999`):

```json
{
  "1234": {"charge": "2422", "start_count": "150", "status": "In progress", "remains": "200", "currency": "VND"},
  "1235": {"charge": "40500", "start_count": "0", "status": "Pending", "remains": "1000", "currency": "VND"},
  "9999": {"error": "Incorrect order ID"}
}
```

Types: `charge` is the whole amount in VND you paid for the order (REST one-id answer: a number; v2 and multi-id answers: a string such as `"2422"`). `start_count` and `remains` are strings. `created_at` is ISO-8601 in Vietnam time (UTC+7).

Errors: 401 `Incorrect API key`; 400 `Order ID is required` (no id sent); 404 `Incorrect order ID` (one id that is unknown or not yours).

Polling: a one-id call is a live provider call and counts against the shared limit of 60 requests per minute. Poll one order every 2 to 5 minutes, or many orders with one multi-id call every 10 minutes (calling it more often mostly returns the same stored values).

### Status values

| Status | Meaning | Final |
|---|---|---|
| `Verifying` | Charged and held, but the order's confirmation has not come back yet; checked on our side, then it continues as a normal order or ends `Canceled` with a full refund | no |
| `Pending` | Accepted, not started yet | no |
| `In progress` / `Processing` | Running; `remains` goes down | no |
| `Canceling` | You cancelled and the provider accepted. Held for at least 12 hours after the cancel (`refund_after`), because the provider may keep delivering for a while and sometimes corrects its final count later; then the order takes the provider's final status and the undelivered part is refunded automatically | no |
| `Completed` | Fully delivered | yes |
| `Partial` | The provider stopped early; the undelivered `remains` is refunded automatically, usually within 10 minutes | yes |
| `Canceled` (also spelled `Cancelled`) | Cancelled by you or by the provider; the undelivered part is refunded (your cancels: at least 12 hours after the cancel; provider cancels: automatically within about 10 minutes). A cancelled order that was partly delivered ends as `Partial` instead | yes |
| `Refunded` | Rare; handled like `Canceled` | yes |

Most status texts come from the provider: compare them case-insensitively, and treat any value not in this table as not final.

## 6. Cancel — POST /cancel (or action=cancel)

| Param | Required | Description |
|---|---|---|
| `api_key` or `key` (or a header) | yes | Your API key |
| `order_id` or `order` | yes | **One** order id |

- One order per call. The v2 `orders` list is not read: a request with only `orders=…` answers 404 `Incorrect order ID`, and in `order=1234,1235` only `1234` is used.
- Possible only when the service has `cancel: true` and the order is not `Completed`, `Partial`, `Canceled` or `Refunded`, and not already `Canceling`.
- The order is marked `Canceling`, the provider is asked to cancel it, and **nothing is refunded in this answer** (`cancel_pending: true`, `refund_amount: 0`). Accepting a cancel is not the end of it: the provider stops the order a little later, may deliver more in the meantime, and sometimes corrects its final count hours later. So the order is held for at least 12 hours (`refund_after`). After that, once the provider reports a final status (`Canceled`, `Partial` or `Completed`), the order takes it and the background job credits `floor(remains × charge / quantity)` VND using the provider's **final** `remains`, in exact integer arithmetic, never more than `charge`. Poll `/status` to see the final status.
- An order is refunded at most once: the background job, the website and this endpoint use the same refund reference, so a cancelled order cannot be paid twice.
- The call waits for the provider (up to two requests) and can take up to about 60 seconds. Use a client timeout of 90 seconds.

```bash
curl -X POST "https://acckit.com/api/v1/smm/cancel" -H "Accept: application/json" \
  -d "api_key=YOUR_API_KEY" -d "order_id=1234"
```

Success (HTTP 200) — nothing is refunded in this answer; the refund follows automatically after `refund_after`. Say 200 of the 500 units end up undelivered and the charge was 2,422 VND: the refund is then floor(200 × 2,422 / 500) = 968 VND.

```json
{
  "status": true,
  "message": "Đã gửi yêu cầu hủy. Tiền phần chưa chạy tự hoàn sau 12 giờ (từ 20:15 02/10), theo số lượng thực chạy.",
  "order_id": 1234,
  "order_status": "Canceling",
  "cancel_pending": true,
  "refund_after": "2026-10-02T20:15:04+07:00",
  "refund_amount": 0,
  "new_balance": 97578,
  "currency": "VND"
}
```

`refund_amount` and `new_balance` are integers in VND. `refund_after` is ISO-8601 in Vietnam time (UTC+7): the earliest moment the refund can be paid (it is paid at the first background run after that moment at which the provider's status is final). `action=cancel` answers with this same body, not with the v2 list `[{"order": 1234, "cancel": 1}]`.

| HTTP | `error` | Meaning and what to do |
|---|---|---|
| 401 | `Incorrect API key` | Key missing or wrong |
| 404 | `Incorrect order ID` | No id, unknown id, another customer's order, or only `orders=` sent |
| 400 | `Order already finalized` | The order is `Completed`, `Partial`, `Canceled` or `Refunded`. Nothing to cancel; `Partial` is refunded automatically |
| 400 | `Service does not support cancel` | The service has `cancel: false` |
| 400 | `Cancel already in progress` | A cancel of this order is already being processed (status `Canceling`, from the website or another call); check `/status` in a minute |
| 400 | `Order is being verified` | The order is `Verifying` (see section 4); it cannot be cancelled until it is confirmed |
| 503 | `Provider unavailable, try again later` | The provider did not answer. The order is back to its previous status and keeps running; retry in a few minutes |
| 400 | `Cancel rejected` | The provider refused (usually the order is too far along). The order keeps running |
| 500 | `Failed to process cancel` | Unexpected error. Check `/status` later: an order that ended up `Canceled` is refunded by the background job |

## 7. Automatic refunds and refill

| Situation | Refund | When |
|---|---|---|
| You cancel (API or website) | `floor(remains × charge / quantity)`, with the provider's final `remains` | Automatically, at least 12 hours after the cancel (`refund_after`), once the provider's status is final |
| The provider ends the order as `Partial` | Same formula, with the provider's final `remains` | Automatically, usually within 10 minutes |
| The provider cancels the order (`Canceled`, `Refunded`) | Same formula | Automatically, usually within 10 minutes |
| `Completed` | Nothing | — |

- Automatic refunds come from a background job that re-reads running orders from the provider every 10 minutes. A `/status` call does not pay a refund by itself; pressing **Sync** on the order in the Buff order history page of the website does, at once.
- Every order is refunded at most once, whoever triggers it. The wallet entry reads `Hoàn tiền đơn Buff #BUFF-<id> …`.
- Orders are settled against what was actually delivered: any difference is charged or credited once per order, in a single wallet entry reading `Đơn buff #BUFF-<id>: đã chạy <delivered>/<quantity> — thu …đ` (or `— hoàn …đ`), or `Đơn buff 1 phần …` when only part of the order was delivered.

**Refill** (top-up after a drop) is not available through the API: there is no refill endpoint, and `action=refill` / `action=refill_status` answer 400 `Incorrect request action`. For services with `refill: true`, open the Buff order history page (`https://acckit.com/buff-like/history`), where orders placed through the API are listed too, and press **Refill** (shown while the order is `Completed`, `Partial`, `In progress` or `Processing`), or contact support.

## 8. Balance — GET or POST /balance (or action=balance)

```bash
curl -X POST "https://acckit.com/api/v1/smm/balance" -H "Accept: application/json" -d "api_key=YOUR_API_KEY"
```

REST: `{"status": true, "balance": 98546, "formatted": "98,546đ", "currency": "VND"}`

v2 (`action=balance`): `{"balance": "98546", "currency": "VND"}`

It is the same wallet you use for everything else on the site.

## 9. Errors, rate limit and languages

The endpoints' own answers are always JSON:

| HTTP | Body | When |
|---|---|---|
| 401 | `{"status": false, "error": "Incorrect API key", "message": "API Key không hợp lệ."}` | Key missing or wrong (every endpoint except `/services`) |
| 400 | `{"status": false, "error": "…", "message": "…"}` | Missing or invalid field, bad link, not enough balance, cancel not possible, unknown v2 `action` (`Incorrect request action`) |
| 404 | same shape | `Service not found or disabled`, `Incorrect order ID` |
| 500 | same shape | `Provider error: …`, `System error` (nothing charged), `Failed to process cancel` |
| 503 | same shape | `Provider unavailable, try again later` (cancel only) |

Errors raised by the framework before an endpoint runs have a different body, `{"message": "…"}` without `status` or `error`, and are **HTML pages unless you send `Accept: application/json`**:

| HTTP | When |
|---|---|
| 429 | Rate limit exceeded; wait the number of seconds given in the `Retry-After` header |
| 405 | Wrong method, e.g. GET on `/buy`, `/order`, `/cancel` or on the v2 URL |
| 404 | Unknown path, e.g. `/api/v1/smm/refill` |
| 503 | Site maintenance |
| 500 | Unexpected server error |

Always check the HTTP status code before parsing the body.

**Rate limit:** 60 requests per minute per client IP, counted together with the Reseller API and every other `https://acckit.com/api/v1/…` call except the OTP API, which has its own quota. Every answer carries `X-RateLimit-Limit` and `X-RateLimit-Remaining`.

**Languages:** `error` is a stable English text: branch on it (for `Provider error: …` only the prefix is stable). `message` is for people: mostly Vietnamese, while the "Please enter a valid … link" messages are English. `name` and `category` are not translated. The API-language setting on the API settings page does not apply to this API.

**JSON encoding:** non-ASCII characters and `/` are escaped (`\u00e0` for à, `\/` for /); every JSON parser decodes them. The examples in this document are shown decoded.

## 10. SMM Panel v2 compatibility

Panel scripts that follow the SMM Panel v2 convention can point at `POST https://acckit.com/api/v1/smm` with `key` + `action`. What each action does here:

| `action` | Behaviour on this API |
|---|---|
| `services` | Array of `service`, `name`, `type`, `category`, `rate`, `min`, `max`, `refill`, `cancel`. `rate` is VND per 1,000 and is your own price when `key` is sent; `rate`, `min` and `max` are JSON numbers; there is no `dripfeed` field |
| `add` | Reads only `service`, `link` and `quantity`; answers `{"order": 1234}`. Drip-feed, comment, username and poll fields are ignored: order `type` = `Default` services only |
| `status` | `order=1234` (or `orders=1234`) gives a flat object with `charge`, `start_count`, `status`, `remains`, `currency`; `orders=1234,1235` gives an object keyed by id |
| `cancel` | One id in `order` (or `order_id`); `orders` is not read. Answers the REST body of section 6, not `[{"order": 1234, "cancel": 1}]` |
| `balance` | `{"balance": "98546", "currency": "VND"}` |
| `refill`, `refill_status`, anything else | HTTP 400 `Incorrect request action` |

Errors come with HTTP 4xx / 5xx and the body `{"status": false, "error": "…", "message": "…"}` instead of HTTP 200. Scripts that look for an `error` field still find it, as long as they read the body of non-200 answers instead of treating them as network failures.

## 11. Code examples

### cURL — v2 (works like any SMM-panel client)

```bash
BASE="https://acckit.com/api/v1/smm"; KEY="YOUR_API_KEY"; H="Accept: application/json"
curl -s -X POST "$BASE" -H "$H" -d "key=$KEY" -d "action=services" | head -c 600; echo
curl -s -X POST "$BASE" -H "$H" -d "key=$KEY" -d "action=add" -d "service=2936" -d "quantity=500" \
  --data-urlencode "link=https://www.facebook.com/permalink.php?story_fbid=123&id=456"
curl -s -X POST "$BASE" -H "$H" -d "key=$KEY" -d "action=status" -d "order=1234"
curl -s -X POST "$BASE" -H "$H" -d "key=$KEY" -d "action=status" -d "orders=1234,1235"
curl -s -X POST "$BASE" -H "$H" -d "key=$KEY" -d "action=cancel" -d "order=1234"
curl -s -X POST "$BASE" -H "$H" -d "key=$KEY" -d "action=balance"
```

### Python — REST

```python
import time
from decimal import Decimal, ROUND_CEILING

import requests

BASE = "https://acckit.com/api/v1/smm"
KEY = "YOUR_API_KEY"
HEADERS = {"Accept": "application/json"}          # JSON errors instead of HTML pages
FINAL = {"completed", "partial", "canceled", "cancelled", "refunded"}


def call(path, **fields):
    """POST one request, waiting on HTTP 429. Returns (http_code, parsed JSON or None)."""
    for _ in range(5):
        r = requests.post(BASE + path, data={"api_key": KEY, **fields}, headers=HEADERS, timeout=90)
        if r.status_code == 429:
            time.sleep(int(r.headers.get("Retry-After", "60")))
            continue
        try:
            return r.status_code, r.json()
        except ValueError:
            return r.status_code, None
    raise RuntimeError("still rate-limited")


def expected_charge(quantity, rate):
    """Same rounding as the server: ceil(quantity * rate / 1000), exact."""
    return int((Decimal(quantity) * Decimal(str(rate)) / 1000).to_integral_value(rounding=ROUND_CEILING))


# 1. Your own prices; keep Default-type services only (cheapest list price first)
code, services = call("/services", category="Facebook Post Likes")
svc = next(s for s in services if s["type"] == "Default")
qty = min(max(svc["min"], 100), svc["max"])
print(svc["service"], svc["name"], "rate", svc["rate"], "expected charge", expected_charge(qty, svc["rate"]))

# 2. Order: requests URL-encodes form fields, so "&" inside the link is safe
code, res = call("/buy", service_id=svc["service"], quantity=qty,
                 link="https://www.facebook.com/permalink.php?story_fbid=123&id=456")
if code != 200 or not res or res.get("status") is not True:
    raise SystemExit(f"HTTP {code}: {res}")
order_id = res["order_id"]
print("order", order_id, "charged", res["charge"], "VND, balance", res["balance"])

# 3. Poll this one order (live check) every 3 minutes until it is final
while True:
    code, st = call("/status", order_id=order_id)
    if code == 200 and st and st.get("status") is True:
        print(st["order_status"], "remains", st["remains"])
        if st["order_status"].lower() in FINAL:
            break
    time.sleep(180)
```

### PHP — v2 client class (drop-in for panels, PHP 7.4+)

```php
<?php
// Every method returns [httpCode, decodedBody].
class AccKitSmm
{
    private string $url = 'https://acckit.com/api/v1/smm';
    private string $key;

    public function __construct(string $key) { $this->key = $key; }

    public function services(): array { return $this->call(['action' => 'services']); }

    public function add(int $service, string $link, int $quantity): array
    {
        return $this->call(['action' => 'add', 'service' => $service, 'link' => $link, 'quantity' => $quantity]);
    }

    /** One order: flat body with charge, start_count, status, remains, currency. */
    public function status(int $order): array { return $this->call(['action' => 'status', 'order' => $order]); }

    /** Two or more orders: body keyed by order id. */
    public function multiStatus(array $orders): array
    {
        return $this->call(['action' => 'status', 'orders' => implode(',', array_map('intval', $orders))]);
    }

    /** One order per call. */
    public function cancel(int $order): array { return $this->call(['action' => 'cancel', 'order' => $order]); }

    public function balance(): array { return $this->call(['action' => 'balance']); }

    private function call(array $fields): array
    {
        $ch = curl_init($this->url);
        curl_setopt_array($ch, [
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => http_build_query($fields + ['key' => $this->key]), // URL-encodes the link
            CURLOPT_HTTPHEADER     => ['Accept: application/json'],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 90,
        ]);
        $body = curl_exec($ch);
        $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        return [$code, is_string($body) ? json_decode($body, true) : null];
    }
}

$api = new AccKitSmm('YOUR_API_KEY');
[$code, $res] = $api->add(2936, 'https://www.facebook.com/permalink.php?story_fbid=123&id=456', 500);
if ($code === 200 && isset($res['order'])) {
    echo "order #{$res['order']}\n";
} else {
    echo "HTTP {$code}: " . ($res['error'] ?? 'no JSON body') . "\n";
}
```

## 12. Checklist for developers and AI agents

- Send `Accept: application/json`, check the HTTP code before parsing, and on 429 wait `Retry-After` seconds.
- Call `/services` with your key at run time, keep only `type` = `Default`, and choose by `category` + `name`. Ids and prices change; do not hard-code them.
- Respect `min` / `max`, URL-encode `link`, and make the link fit the platform and the object type (section 4).
- The charge is `ceil(quantity × rate / 1000)` with your own `rate`; the `/buy` answer states the exact `charge`.
- `/buy` has no idempotency key: after a timeout, check `/balance` and the Buff order history page before sending again.
- Poll with one multi-id call every 10 minutes, or one id every few minutes; the 60-per-minute limit is shared with the Reseller API.
- Final statuses: `Completed`, `Partial`, `Canceled` / `Cancelled`, `Refunded` (compare case-insensitively). Anything else is still running.
- `/cancel` takes one id and refunds nothing at once: the undelivered part follows automatically at least 12 hours later (`refund_after`); `Partial` and provider-cancelled orders are refunded automatically; no order is ever refunded twice.
- Refill is website-only.
- `POST /api/v1/smm/order` places a paid order; it is not the Reseller API's read-only `POST /api/v1/order`.
- Keep the key on your server, never in browser code, and prefer a POST body or a header to a URL that may be logged.
- Branch on `error`; show `message` to people.


---

# Storefront integration — sell our products on your own website

> **Tiếng Việt (tóm tắt nhanh):** Hướng dẫn dựng web riêng bán lại sản phẩm của chúng tôi qua Reseller API (`https://acckit.com/api/v1`, một `api_key`, tiền **VND**, luôn gửi `Accept: application/json`). Đồng bộ danh mục từ `GET /categories` 5–10 phút/lần: hàng hết là **biến khỏi danh sách** (không hiện số 0) — đánh dấu ẩn, đừng xoá. Giá trong danh sách là giá niêm yết; giá thật bị trừ = giá bậc rồi trừ chiết khấu tài khoản (không bao giờ cao hơn giá niêm yết lúc mua), xem ở `price`/`total_price` của `/order`. `POST /buy` chờ tới 120 giây; hết giờ, lỗi mạng hay HTTP 5xx thì **không gọi lại** mà đối soát `/orders` theo `id_product`, đủ 3 phút mới kết luận "chưa mua". `pending: true` = đã trừ tiền, tài khoản về sau. Lưu dữ liệu giao ngay (bên này xoá sau 15 ngày, 03:30 giờ Việt Nam); bảo hành chỉ làm trên web, trong 48 giờ. Mục 11 là bản dặn việc cho AI/agent.

*Build guide, version 2026-09-30 — generated on 2026-10-31. Site: https://acckit.com*

This is the **build guide**: how to mirror our catalogue into your own shop, price it, sell it to
your customers, deliver what they bought, and keep your books straight when something goes wrong.
Every endpoint, field and error string is described in the **Reseller API reference** on
https://acckit.com/api-docs; this guide assumes it and does not repeat it.

Written so that a developer **or an AI coding agent** can build straight from it. Section 11 is a
paste-ready brief for an agent. Section 14 lists what changed on 30 September 2026 — read it if you
integrated before that date.

---

## 1. What you are building

Your customers never talk to us. They see your website, your prices, your brand. Behind it:

```text
your visitor → your shop → your server → https://acckit.com/api/v1 → our catalogue and stock
                               ↑
                     your api_key lives here, and only here
```

Two ways to run it. Pick one before you write code.

| | **Dropship (recommended)** | **Buy-ahead stock** |
|---|---|---|
| When you call `/buy` | After your customer pays you | Before, in bulk |
| Your money at risk | Only the order in progress | Everything you pre-bought |
| Stock | You buy what exists at that moment; a purchase can still fail or come back short | Yours once bought |
| Delivery speed | Usually seconds, up to about 90 s; some orders are delivered minutes later (§6.5) | Instant, from your database |
| Our warranty (§7.4) | 48 hours from the moment your customer buys | 48 hours from the moment you stocked up |
| Main work | Unknown outcomes (§6.6) and pending orders (§6.5) | Inventory, ageing stock, accounts that stop working |

Start with dropship. Move to buy-ahead only for products you sell constantly and that sell out
quickly.

---

## 2. Before the first line of code

1. **Use a dedicated account for the shop.** `/orders` lists *every* purchase of the account —
   including anything bought by hand on our website — and the reconciliation in §6.6 assumes that
   every order on the account came from your integration. Discounts are set per account by us, so
   ask for yours on the new account too.
2. **Get the key.** Log in and open https://acckit.com/setting_api (the key is also on your Account
   page); copy `api_key`, or generate one if there is none. The same key works for the Reseller API,
   the OTP API and the SMM API. Changing it kills the old key immediately. It only works on
   https://acckit.com, the site where your account lives.
3. **Pick the catalogue language.** On the same page, https://acckit.com/setting_api, choose
   "API language": Vietnamese, English, Indonesian, Portuguese (Brazil), Simplified Chinese or Thai.
   It changes `name` and `desc` in the catalogue and the product name shown in `/orders` and
   `/order`; your code does not change. A text that has no translation in your language comes in
   English, or else in the Vietnamese original. Until you choose, you get the site default. Run a
   full catalogue sync right after changing it.
4. **Top up.** `/buy` charges your wallet. It checks your balance against the whole order
   (quantity × unit price) first; if it is not enough you get `Insufficient balance!` and nothing
   happens.
5. **Check that it is alive** before building anything:

```bash
curl -s "https://acckit.com/api/v1/categories" -H "Accept: application/json" | head -c 400
curl -s -X POST "https://acckit.com/api/v1/balance" -H "Accept: application/json" \
     -d "api_key=YOUR_API_KEY"
# {"status":true,"balance":150000}
```

Rules that bite if you skip them:

- **`Accept: application/json` on every request.** Without it a validation error comes back as an
  HTTP 302 redirect to an HTML page instead of an HTTP 422 JSON error, and your JSON parser reports
  something unrelated and confusing.
- **The key never leaves your server.** No browser fetch, no mobile app, no `NEXT_PUBLIC_*`
  variable, no repository. Anyone with the key spends your wallet.
- **Refuse to start with an empty key.** A missing or blank key never matches an account (you get
  `Invalid api_key`, or HTTP 422 on `/buy`, `/orders` and `/order`), but an unset environment
  variable quietly becomes an empty string and leaves your shop looking "up" while every purchase
  fails.

---

## 3. What the API gives you, and what it does not

Know the limits before you design the UI, not after.

**You get**

| Data | From | Notes |
|---|---|---|
| Categories with an icon | `GET /categories` | `icon` is an absolute image URL, or `null` |
| Products nested in their category | `GET /categories` | The only place a product's category appears |
| The products of one category | `GET /category/{id}` | Same product objects, no category id |
| Name, description, list price, stock | both catalogue endpoints | Keys: `id_product`, `name`, `desc`, `price`, `quantity` |
| Wallet balance | `POST /balance` | VND |
| A purchase and its accounts | `POST /buy` | Accounts in the reply, unless the order is pending (§6.5) |
| One order again | `POST /order` | Unit `price` and `total_price` charged, `id_product`, the accounts |
| Order history | `POST /orders` | 10 per page, newest first, with `id_product` |

**You do not get — design around these**

| Missing | What to do instead |
|---|---|
| A category id on product objects | Build the catalogue from `GET /categories` and store the category id yourself (§4) |
| Product images | Use the category `icon`, or your own artwork |
| Your discount or volume tiers in the listings | Listings show the list price only; what you paid is in `/order` (§5.1) |
| The amount charged, in the `/buy` reply | Call `/order` once after each purchase (§6.9) |
| Minimum order quantities | Learn each one from the `Minimum quantity is N` error (§5.2) |
| A maximum-price parameter on `/buy` | Re-read the price right before buying (§6.1) |
| Webhooks | Poll: the catalogue on a schedule, orders after buying |
| An idempotency key on `/buy` | Never resend after an unknown outcome; reconcile (§6.6) |
| Stock reservation | Treat stock errors at checkout as normal (§6.7) |
| A "pending / delivered" status in `/orders` | Read `/order`: an empty `data` on a recent order means pending (§6.5) |
| Warranty through the API | Claims are made on our website (§7.4) |
| A sandbox or test mode | Every call is real money; test with `quantity=1` on the cheapest product |
| Any currency other than VND | Convert for display only (§5.4) |

---

## 4. Step 1 — mirror the catalogue

### 4.1 Read it from one endpoint

```bash
curl "https://acckit.com/api/v1/categories?api_key=YOUR_API_KEY" -H "Accept: application/json"
```

The key is optional on this endpoint; it only selects your API language.

```json
{"data": [
  {"id": 211, "name": "Aged accounts", "icon": "https://acckit.com/assets/media/country/185-indonesia.png",
   "list_product": [
     {"id_product": 124, "name": "Aged account, 2FA", "desc": "uid|pass|2fa|mail|mailpass",
      "price": 42000, "quantity": 89},
     {"id_product": 131, "name": "Aged account, no 2FA", "desc": "Checked live before delivery",
      "price": 25000, "quantity": 7}
   ]},
  {"id": 310, "name": "Mail", "icon": null,
   "list_product": [
     {"id_product": 402, "name": "Mail account", "desc": "", "price": 15500, "quantity": 240}
   ]}
]}
```

Values are illustrative. Categories and products come in our display order.

`GET /category/{id}` returns `{"data": [ ...product objects... ]}` for one category. It is handy to
refresh a price right before a purchase (§6.1), but its objects carry **no category id**, and it
returns an empty list for a category that is hidden or has nothing on sale.

### 4.2 Store it on your side

Never call our API while rendering a page. Mirror into your database, render from your database.
Minimum workable schema (MySQL flavour):

```sql
CREATE TABLE src_category (
  id            INT PRIMARY KEY,                   -- our category id
  name          VARCHAR(255) NOT NULL,
  icon_url      VARCHAR(512) NULL,
  position      INT NOT NULL DEFAULT 0,            -- order in which we returned it
  listed        TINYINT(1) NOT NULL DEFAULT 1,     -- 0 = absent from the last good sync
  seen_at       DATETIME NOT NULL
);

CREATE TABLE src_product (
  id            INT PRIMARY KEY,                   -- our id_product: the key you send to /buy
  category_id   INT NOT NULL,                      -- from the nesting in /categories; it can change
  name          VARCHAR(512) NOT NULL,
  descr         TEXT NULL,
  list_vnd      DECIMAL(14,2) NOT NULL,            -- our list price at the last sync
  stock         INT NOT NULL DEFAULT 0,            -- approximate (§5.2)
  listed        TINYINT(1) NOT NULL DEFAULT 1,     -- 0 = sold out, paused or withdrawn
  min_qty       INT NOT NULL DEFAULT 1,            -- learned from "Minimum quantity is N"
  sell_vnd      DECIMAL(14,2) NULL,                -- YOUR price to your customer
  price_locked  TINYINT(1) NOT NULL DEFAULT 0,     -- 1 = set by an operator, the sync leaves it alone
  seen_at       DATETIME NOT NULL,
  INDEX (category_id), INDEX (listed)
);

CREATE TABLE src_purchase (
  id            BIGINT AUTO_INCREMENT PRIMARY KEY,
  shop_order_id BIGINT NOT NULL,                   -- your customer's order
  id_product    INT NOT NULL,
  quantity      INT NOT NULL,
  expected_unit DECIMAL(14,2) NOT NULL,            -- list price you confirmed just before /buy
  sent_at       BIGINT NOT NULL,                   -- unix time, written BEFORE /buy is sent
  state         VARCHAR(16) NOT NULL,              -- sending, delivered, short, awaiting, unknown,
                                                   -- not_bought, failed, refunded
  order_id      BIGINT NULL UNIQUE,                -- our order id once known: one order, one purchase
  unit_vnd      DECIMAL(14,2) NULL,                -- price from /order
  total_vnd     DECIMAL(14,2) NULL,                -- total_price from /order
  charged_qty   INT NULL,                          -- quantity from /order
  last_msg      VARCHAR(255) NULL,
  INDEX (state), INDEX (id_product, state)
);

CREATE TABLE src_line (
  id            BIGINT AUTO_INCREMENT PRIMARY KEY,
  order_id      BIGINT NOT NULL,                   -- our order id
  uid           VARCHAR(255) NOT NULL,
  full_info     TEXT NOT NULL,                     -- encrypted at rest
  INDEX (order_id)
);
```

`id_product` is stable — use it as the primary key so that a re-sync is an upsert, never a
duplicate.

### 4.3 The sync job

Run it every 5–10 minutes. More often buys you nothing — our own copy of partner prices and stock
refreshes about every 5 minutes — and you share a 60 requests/minute budget with checkout traffic
(§7.5).

```text
1. r = GET /categories?api_key=KEY
2. If the call failed, the reply is not JSON, or r.data is missing or empty:
       log it, alert if it keeps happening, and STOP.
       Never un-list your catalogue because one request failed.
3. now = current time
4. For each category c, at position i:
       upsert src_category (id = c.id, name = c.name, icon_url = c.icon, position = i,
                            listed = 1, seen_at = now)
       For each product p in c.list_product:
           upsert src_product (id = p.id_product, category_id = c.id,     ← every run: products move
                               name = p.name, descr = p.desc, list_vnd = p.price,
                               stock = p.quantity, listed = (p.quantity > 0), seen_at = now)
           new product                    → sell_vnd = your pricing rule (§5.1)
           existing, priced by your rule  → recompute sell_vnd from the new list_vnd
           existing, price_locked         → keep sell_vnd; alert if list_vnd has come close to it
5. Every product and every category with seen_at < now → listed = 0.   NEVER DELETE.
```

Step 5 is normal business, not an error. Products leave the listing when they sell out or are
paused, and come back later. If you delete the row you lose your price, your slug, your SEO and
every order that pointed at it. Un-list it; delete never.

### 4.4 What changes under you

- **Sold out means gone.** A product that sells out disappears from both catalogue endpoints —
  immediately when the last unit sells through us, otherwise within minutes — instead of showing
  `quantity: 0`. A category with nothing left on sale disappears too. Both come back by themselves
  when restocked. If you ever do see `quantity: 0`, treat it as sold out.
- **Paused means gone.** The same happens when we take a product off sale, for example while a
  delivery problem is being fixed. `/buy` refuses anything the listings do not show
  (`Product is not for sale right now`).
- **Products move between categories.** Update `category_id` on every full sync, and never un-list
  a product just because it is missing from one `/category/{id}` response.
- **Names and descriptions change** — when translations are added, or when a product is renamed.
  Key everything on `id_product`, never on names.
- **List prices change.** Prices of products we source from partners are refreshed about every
  5 minutes (§5.1).
- **Stock is approximate** for products we source from partners (§5.2).

---

## 5. Step 2 — put it on your site

### 5.1 Your price, and what we charge you

`price` in the listings is our **list price**. What we actually charge per unit is worked out at
the moment of `/buy`:

1. Start from the list price.
2. If your quantity reaches a volume tier of that product, use the tier price — the lowest price among
   the tiers your quantity reaches (normally the tier with the highest threshold not above your
   quantity), so buying more is never dearer per unit. A tier is never dearer than the list price.
3. Subtract your account discount: a percentage we set per account, which you cannot change. On
   some products the discount is capped.

It is the same calculation on our website and through the API, and **the unit price charged is
never above the list price at the moment you buy**. The listings never show tiers or your discount;
to see them, open the product's Buy dialog on our website while logged in with the same account.
After buying, the unit price actually charged is `price` in `/order` and `/orders`, and the amount
charged for the order is `total_price`.

Pricing safely:

- **Put your margin on the list price.** Because you are never charged more than the list price of
  the moment, a sell price above it keeps its margin even with no discount at all.
- **Re-read the list price right before buying** (§6.1). Partner-sourced prices can change every
  few minutes, `/buy` charges the price of that moment, and there is no maximum-price parameter.
- **Record what you paid** from `/order` after every purchase (§6.9) and compare it with what you
  expected.

Common rules — pick one and keep it in one place in your code:

```text
percentage   sell = ceil(list * 1.20 / 1000) * 1000    -- +20 %, rounded up to 1,000 VND
fixed        sell = list + 15000
tiered       cheap items +30 %, expensive items +12 %
manual       an operator sets sell per product; flag it (price_locked) so the sync never touches it
```

Orders placed before 30 September 2026 were priced differently: a volume tier replaced your
discount instead of combining with it.

### 5.2 Stock

`quantity` changes while your page is open. Treat it as advice, not a promise.

- For our own stock it is an exact live count. For products we source from partners it is our copy,
  refreshed about every 5 minutes and lowered as we sell, so `/buy` can still fail
  (`Product temporarily unavailable, …`) while `quantity` is positive.
- `/buy` refuses more than the listed stock (`Quantity exceeds available stock`).
- Some products have a minimum order quantity, which the listings do not show. `/buy` then answers
  `Minimum quantity is N`: store N for that product (`min_qty`) and enforce it from then on.

A display that works:

- `quantity > 20` → "In stock"
- `1 … 20` → show the number; it creates urgency and it is honest
- not listed → "Out of stock": keep the page up for SEO, hide the Buy button, and refuse the
  purchase on your server, not only in the UI

### 5.3 The description field

`desc` is a short free-text note written for a human buyer. Sometimes it lists the field order of
the delivered line (`uid|pass|2fa|mail|mailpass`), often it says something else entirely
(`Checked live before delivery`), it can be empty, and it can change at any sync. It is **not** a
format contract. Show it on your product page as plain text; for handling the delivered lines see
§6.8.

### 5.4 Currency and numbers

Every amount is VND. Parse amounts as decimal numbers: they are normally whole numbers but can be
sent as, for example, `44100.0`. If your shop charges in another currency, convert for display and
for charging your customer only; keep VND as the authoritative number, store the rate you used with
each order, and never round in a way that makes you sell below what you paid.

`price` in `/order` is the unit price. Since 30 September 2026 it is a whole number of VND (rounded
up) and `total_price` = `price × quantity`; older orders can differ by a few VND. Treat
`total_price` as the amount charged.

### 5.5 Language

Set once on https://acckit.com/setting_api (§2). One response carries one language. If you run a
multilingual shop, sync in your primary language and translate the rest on your side.

---

## 6. Step 3 — the purchase flow

### 6.1 Order of operations

Money out before goods in is how resellers lose money. This order matters:

```text
1. Customer clicks Buy on your site.
2. YOUR server checks: product listed; quantity ≥ its known minimum and ≤ stock;
   no other purchase of this product in flight or unresolved (if there is: queue this one,
   or ask the customer to try again in a few minutes).
3. Refresh: GET /category/{category_id} and find the product in it.
   Not there → GET /categories.  Not there either → "no longer available", stop.
   Fresh price above the list price you priced from → re-price and ask the customer to
   confirm, or absorb it if your margin allows.
4. Take or authorise your customer's payment.
5. Write the purchase row: id_product, quantity, expected unit price,
   sent_at = now, state = "sending".                  ← BEFORE calling /buy
6. POST /buy with a 120-second timeout (§6.4).
7. Act on the outcome (§6.3).
8. Whenever an order exists: POST /order once and store price and total_price (§6.9).
```

**One `/buy` per product at a time.** Keep at most one purchase of a given product in flight, and
none while an earlier one is still unresolved (§6.6): two unknown outcomes for the same product
cannot be told apart. Different products can be bought in parallel.

A purchase still in state "sending" a few minutes later — your process died mid-call — is an
unknown outcome too.

### 6.2 The call

```bash
curl -X POST "https://acckit.com/api/v1/buy" -H "Accept: application/json" \
  -d "api_key=YOUR_API_KEY" -d "id_product=124" -d "quantity=2"
```

```json
{"status": true, "order_id": 12345,
 "data": [
   {"buy_id": 12345, "uid": "1000123", "full_info": "1000123|Pass1|2FASECRET1|mail1@example.com|MailPass1", "type": 124},
   {"buy_id": 12345, "uid": "1000456", "full_info": "1000456|Pass2|2FASECRET2|mail2@example.com|MailPass2", "type": 124}
 ]}
```

- `quantity` must be a whole number, 1 or more. A decimal such as `2.5` gets HTTP 422.
- `order_id` is your receipt; `buy_id` repeats it on every line.
- In `/buy` lines, `type` is the product id. In `/orders` and `/order` the key `type` holds the
  product **name**, so use `id_product` there.
- The reply does not say how much you were charged — see §6.9.

### 6.3 The possible outcomes

| What comes back | Meaning | Charged | What you do |
|---|---|---|---|
| HTTP 200, `status: true`, as many lines as you ordered | Delivered | Yes | Store the lines, then fulfil |
| HTTP 200, `status: true`, fewer lines than you ordered | Short delivery | Normally only for the lines received; check (§6.9) | Fulfil what came, refund your customer the rest |
| HTTP 200, `status: true`, `pending: true`, empty `data` | Order placed, accounts come later | Yes, the full quantity | §6.5 |
| HTTP 200, `status: false` | Refused; no order exists | No | Map `msg` (§6.7) |
| HTTP 429 | Rate limited; not processed | No | Wait `Retry-After` seconds; you may send it again |
| Any other HTTP 4xx, such as 422 | Invalid request; not processed | No | It is your bug: fix it |
| Timeout, network error, HTTP 5xx, or a reply that is not JSON | **Unknown** | Maybe | Never resend; reconcile (§6.6) |

Resending `/buy` is safe only when you know the previous attempt bought nothing: `status: false`,
an HTTP 4xx, or a reconciliation that concluded "not bought".

### 6.4 Timeouts

Give `/buy` **120 seconds** (and about 10 seconds to connect). Our own stock answers in about a
second and most partner-sourced products within seconds, but a purchase can legitimately take up to
about 90 seconds: we wait up to 60 seconds for a partner, and for some products up to about half a
minute more for the accounts to be ready. A short client timeout turns purchases that succeeded —
or that would have failed cleanly after 60 seconds with `System error!` — into unknown outcomes.

The purchase runs to completion on our side even if your client gives up. A proxy in front of our
server may also give up before we answer and send you an HTTP 5xx error page (HTML, not JSON); that
is an unknown outcome too.

For every other call, 30 seconds is plenty.

### 6.5 Pending orders — charged, accounts come later

For some products the accounts are prepared after the order is placed. If they are not ready while
`/buy` waits, you get:

```json
{"status": true, "order_id": 12346, "pending": true, "data": [],
 "msg": "Order placed. The supplier is still preparing the accounts — call /order with this order_id in a few minutes."}
```

- You **have** been charged for the full quantity, and the order exists.
- Store `order_id` at once, mark the purchase "awaiting", and tell your customer the order is being
  prepared.
- Poll `POST /order` every 1–2 minutes for the first hour, then every 10 minutes. When `data`
  fills, deliver as usual (§6.8) and run the short-delivery check (§6.9).
- We retry automatically every minute for 24 hours. Still empty after that: contact support with
  the `order_id`.
- If `/order` starts answering `Order not found`, the order was refunded and the money is back in
  your wallet (§7.3). Refund your customer.

An empty `data` in `/order` means "pending" only for a recent order. For an order older than the
retention period (§7.2) it means the lines have been purged.

### 6.6 Reconciliation — when the outcome is unknown

There is no idempotency key: resending `/buy` after a timeout can buy twice and charge twice. When
the outcome is unknown, run this from a background job, not while your customer waits:

```text
Every minute, for each purchase in state "unknown" (or "sending" for more than 5 minutes):
  1. Read POST /orders?page=1, 2, … (newest first, 10 per page) until the oldest order on the page
     was created more than 2 minutes before sent_at, or you reach meta.last_page.
  2. Candidates are orders with
        id_product == the product you tried to buy,
        created_at ≥ sent_at − 2 minutes        (allows for clock differences),
        quantity ≤ the quantity you asked for   (a short delivery records fewer),
        an id that is not already in your database.
  3. Exactly one candidate → attach it (the unique key on order_id stops it being attached twice),
     then treat it like a normal result: POST /order, store the lines — an empty data means
     pending (§6.5).
  4. More than one candidate → stop and alert a human. Do not guess.
  5. None, and at least 3 minutes have passed since sent_at → the purchase did not happen:
     state "not_bought", refund or release your customer's payment. A new /buy is now safe.
  6. None yet → check again at the next run.
```

Why 3 minutes: an order only shows up in `/orders` once the purchase has finished, which can be
about 90 seconds after we receive the request. Checking after a few seconds and concluding "not
bought" is exactly how resellers end up paying twice.

`created_at` is an ISO-8601 timestamp in UTC, for example `2026-09-30T08:15:30.000000Z`.

One more sweep catches the rest. **Every 10 minutes, read the first page or two of `/orders` and
check that every order older than 5 minutes is in your database.** On a dedicated account, an
unknown id is a purchase you were charged for but never recorded — your server crashed between our
reply and your database write, or an order finished after you had concluded "not bought". Attach it
to the matching purchase (same `id_product`, within the last 24 hours) or alert a human. The
accounts are yours either way.

### 6.7 Mapping our errors to your customer

Every business failure is HTTP 200 with `status: false`. Nothing was charged and no order exists.
Never show our raw string to a buyer.

| `msg` from us | What happened | Show your customer | Your side |
|---|---|---|---|
| `Invalid api_key` | Key wrong, changed, or from another site | "Temporarily unavailable" | Alert yourself: the shop is down until you fix it |
| `Product ID does not exist` | No such product (deleted, or you sent a category id) | "No longer available" | Un-list it; run a sync |
| `Product is not for sale right now` | The product exists but is not listed now: sold out or paused | "Out of stock" | Un-list it until a sync lists it again |
| `Minimum quantity must be at least 1` | `quantity` was 0 or negative | Nothing: it is your bug | Validate quantity ≥ 1 |
| `Minimum quantity is N` | Below this product's minimum | "Minimum order is N" | Store N, enforce it |
| `Quantity exceeds available stock` | More than the listed stock | "Only X left — order fewer" | Run a sync, offer fewer |
| `Insufficient balance!` | **Your** wallet cannot cover quantity × unit price | "Temporarily unavailable" | Top up; alert loudly (§7.1) |
| `Product temporarily unavailable, please try again later or contact support.` | It could not be delivered right now | "Out of stock, try again later" | Un-list it until a sync lists it again |
| `System error!` | Unexpected failure | "Try again in a few minutes" | Log it; retrying later is safe |

HTTP 422 means your request was malformed — your bug, not a customer problem. HTTP 429 means you
went over the rate limit (§7.5).

### 6.8 Delivering to your customer

Each line of `data` is one account: `uid` (its identifier, usually the first field of `full_info`)
and `full_info` (the whole account on one line, usually with fields separated by `|`).

- **Always give your customer the raw `full_info` line**, and a **download as .txt** button. They
  will want to keep it.
- Split a line into named fields only for products whose layout you have checked on a real
  delivery, and keep that mapping per product on your side. Do not derive it from `desc` (§5.3), do
  not assume a field count, and fall back to the raw line whenever a line does not fit.
- `/order` lines also carry `link_backup`. **Never show it to your customers**: it points to our
  website, only works in a browser logged in to your account, and answers 404 for most products.
- Treat the lines as credentials: encrypt them at rest, and keep them out of logs, URLs and
  plain-text e-mail.

```php
// Only for a product whose layout you verified on a real delivery:
$layout = ['uid', 'password', '2fa', 'mail', 'mail_password'];   // your mapping for this product
$fields = explode('|', $line['full_info']);
$parsed = count($fields) === count($layout) ? array_combine($layout, $fields) : null;   // null → show the raw line
```

### 6.9 Record what you paid

The `/buy` reply has no price. After every purchase that created an order — delivered, short,
pending, or found by reconciliation — call `POST /order` once and store:

- `price` (the unit price charged) and `total_price` (the amount charged for the order, §5.4);
- `quantity` — the number of accounts you were charged for.

Then run the **short-delivery check**: if the order is not pending and `data` has fewer lines than
`quantity`, you were charged for accounts you did not receive — contact support with the
`order_id`. Normally a short delivery records only the lines delivered, so `quantity` equals the
number of lines and you paid only for those.

`/order` returns the same `uid` and `full_info` as `/buy`; its lines have no `buy_id` or `type`, but
they add `link_backup` (§6.8).

---

## 7. Step 4 — running it

### 7.1 Watch your balance

A wallet that hits zero looks to your customers like a broken website.

```text
Every 10 minutes:  POST /balance
If balance < (your busiest hour of sales) × 3   → alert a human
```

Alert through something a human actually reads — Telegram, SMS, PagerDuty. An e-mail nobody opens
is not an alert.

### 7.2 Retention — back the data up yourself

We delete the delivered account lines of every order older than the retention period — currently
**15 days** — in a job that runs every day at **03:30 Vietnam time (UTC+7), which is 20:30 UTC**
the day before. The lines therefore stay available for 15 to 16 days after purchase. We may change
the period (never to less than 3 days). The order itself remains: `/order` then returns it with an
empty `data` array.

**Store every delivered line in your own database the moment you receive it.** `/order` is not your
archive.

### 7.3 Refunds

If we refund an order — for example a pending order that was never delivered — its `total_price`
goes back to your wallet and the order **disappears from the API**: `/order` answers
`Order not found` and `/orders` stops listing it. `Order not found` for an `order_id` you stored
means exactly that: check your balance and refund your customer.

### 7.4 Warranty — on our website only

- Warranty claims are made **on our website**, never through the API. Log in with the account that
  bought, open the order from your order history, select the accounts concerned, give a reason and
  attach 1 to 5 screenshots.
- You can claim within **48 hours of the purchase**, once per account.
- We answer with one of: a refund of the unit price you paid, to your wallet (the order stays as it
  is in `/orders`); a replacement account, shown only in the claim details on the website ("My
  warranty claims") and **never added to `/order`** — pass it on to your customer yourself; or a
  rejection, with the reason and evidence.
- Offer your own customers a shorter warranty than 48 hours (24 hours, say) so that you still have
  time to claim with us. For buy-ahead stock, our 48 hours run from when you bought, not from when
  your customer does.

### 7.5 Rate limits and backoff

- **60 requests per minute per IP address**, one counter shared by every API call from that address
  except the OTP API — so Reseller API and SMM API calls count together. The OTP API has its own
  budget of 240 per minute per key.
- Over the limit you get HTTP 429 with `{"message": "Too Many Attempts."}` and a `Retry-After`
  header in seconds. Wait that long (never more than 60 seconds) before the next call. Normal replies
  carry `X-RateLimit-Limit` and `X-RateLimit-Remaining`: slow down before the second one reaches
  zero.
- A 429 means the request was not processed, so sending it again later is safe — even for `/buy`.
- On HTTP 5xx from a read-only call (`/categories`, `/category`, `/orders`, `/order`, `/balance`),
  back off 2, 4, 8, 16 seconds, then alert. **Never resend `/buy` after a 5xx** — reconcile (§6.6).
- Several servers behind one public IP share one budget. Send all API calls through a single worker.

A rough budget:

| Job | Requests |
|---|---|
| Catalogue sync, every 5–10 minutes | 1 per run |
| Balance alarm, every 10 minutes | 1 per run |
| Each checkout | About 3: price refresh, `/buy`, `/order` |
| Each pending order | 1 per poll |
| Each unknown outcome | 1–2 per check (pages of `/orders`) |

### 7.6 Logging

Log every request and response with a correlation id and your purchase id, **with `api_key` and
`full_info` redacted**. When an order goes wrong, being able to show what we sent you and when is
what saves you.

---

## 8. Worked example — PHP 8.0+

A client that classifies every reply the way §6.3 needs. It needs ext-curl.

```php
<?php
// PHP 8.0+ with ext-curl.

final class ApiUnknown extends RuntimeException {}    // timeout, network error, HTTP 5xx, non-JSON: outcome unknown
final class ApiRejected extends RuntimeException {}   // HTTP 4xx other than 429: not processed
final class ApiRateLimited extends RuntimeException   // HTTP 429: not processed
{
    public function __construct(public int $retryAfter)
    {
        parent::__construct('rate limited');
    }
}

final class ResellerApi
{
    private const BASE = 'https://acckit.com/api/v1';
    private string $key;

    public function __construct(string $key)
    {
        $this->key = trim($key);
        if ($this->key === '') {
            throw new InvalidArgumentException('API key is empty - refusing to start');
        }
    }

    /** GET when $body is null, POST otherwise. Returns the decoded JSON of a non-error reply. */
    private function call(string $path, ?array $body = null, int $timeout = 30): array
    {
        $headers = [];
        $ch = curl_init(self::BASE . $path);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 10,
            CURLOPT_TIMEOUT        => $timeout,
            CURLOPT_HTTPHEADER     => ['Accept: application/json'],
            CURLOPT_HEADERFUNCTION => function ($handle, string $line) use (&$headers): int {
                $kv = explode(':', $line, 2);
                if (count($kv) === 2) {
                    $headers[strtolower(trim($kv[0]))] = trim($kv[1]);
                }
                return strlen($line);
            },
        ]);
        if ($body !== null) {
            curl_setopt($ch, CURLOPT_POST, true);
            curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($body + ['api_key' => $this->key]));
        }
        $raw  = curl_exec($ch);
        $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);

        if ($raw === false) {
            throw new ApiUnknown('network: ' . curl_error($ch));
        }
        if ($code === 429) {
            throw new ApiRateLimited(max(1, (int) ($headers['retry-after'] ?? 60)));
        }
        if ($code >= 500) {
            throw new ApiUnknown("HTTP $code");
        }
        if ($code >= 400) {
            throw new ApiRejected("HTTP $code: " . substr($raw, 0, 300));
        }
        $json = json_decode($raw, true);
        if (!is_array($json)) {
            throw new ApiUnknown("non-JSON reply (HTTP $code)");
        }
        return $json;
    }

    public function categories(): array
    {
        return $this->call('/categories?api_key=' . urlencode($this->key))['data'] ?? [];
    }

    public function category(int $categoryId): array
    {
        return $this->call("/category/$categoryId?api_key=" . urlencode($this->key))['data'] ?? [];
    }

    public function balance(): float
    {
        $r = $this->call('/balance', []);
        if (empty($r['status'])) {
            throw new RuntimeException('balance: ' . ($r['msg'] ?? 'unexpected reply'));
        }
        return (float) $r['balance'];
    }

    /** The order, or null when it does not exist (it never did, or it was refunded). */
    public function order(int $orderId): ?array
    {
        $r = $this->call('/order', ['order_id' => $orderId]);
        if (isset($r['data']['id'])) {
            return $r['data'];
        }
        if (($r['msg'] ?? '') === 'Order not found') {
            return null;
        }
        throw new RuntimeException('order: ' . ($r['msg'] ?? 'unexpected reply'));
    }

    /** One page of /orders: ['data' => up to 10 orders, newest first, 'meta' => [...]]. */
    public function orders(int $page = 1): array
    {
        $r = $this->call("/orders?page=$page", []);
        if (!isset($r['data']) || !is_array($r['data'])) {
            throw new RuntimeException('orders: ' . ($r['msg'] ?? 'unexpected reply'));
        }
        return $r;
    }

    /**
     * One purchase attempt. state: delivered | short | awaiting | failed | not_sent | unknown.
     * Only 'failed' and 'not_sent' mean that nothing was bought; 'unknown' must be reconciled.
     */
    public function buy(int $idProduct, int $qty): array
    {
        try {
            $r = $this->call('/buy', ['id_product' => $idProduct, 'quantity' => $qty], 120);
        } catch (ApiRateLimited $e) {
            return ['state' => 'not_sent', 'retry_after' => $e->retryAfter];
        } catch (ApiRejected $e) {
            return ['state' => 'failed', 'msg' => $e->getMessage()];
        } catch (ApiUnknown $e) {
            return ['state' => 'unknown', 'msg' => $e->getMessage()];   // never resend: reconcile (§6.6)
        }
        if (empty($r['status'])) {
            return ['state' => 'failed', 'msg' => (string) ($r['msg'] ?? 'unknown error')];
        }
        $orderId = (int) $r['order_id'];
        if (!empty($r['pending'])) {
            return ['state' => 'awaiting', 'order_id' => $orderId, 'lines' => []];
        }
        $lines = is_array($r['data'] ?? null) ? $r['data'] : [];
        return ['state' => count($lines) < $qty ? 'short' : 'delivered', 'order_id' => $orderId, 'lines' => $lines];
    }

    /**
     * Unknown outcome: look for the order that /buy may have created (§6.6).
     * $sentAt  unix time stored just BEFORE sending /buy
     * $isKnown function (int $orderId): bool — is this order id already in your database?
     * Returns the order summary, or null if there is none (yet).
     */
    public function findOrder(int $idProduct, int $qty, int $sentAt, callable $isKnown): ?array
    {
        $since = $sentAt - 120;                                        // allow for clock differences
        $hits  = [];
        for ($page = 1; $page <= 10; $page++) {
            $r = $this->orders($page);
            foreach ($r['data'] as $o) {
                $t = strtotime((string) $o['created_at']);
                if ($t === false || $t < $since) continue;
                if ((int) ($o['id_product'] ?? 0) !== $idProduct) continue;
                if ((int) $o['quantity'] > $qty) continue;             // a short delivery records fewer
                if ($isKnown((int) $o['id'])) continue;
                $hits[] = $o;
            }
            $last = end($r['data']);
            if ($last === false || strtotime((string) $last['created_at']) < $since
                || $page >= (int) ($r['meta']['last_page'] ?? 1)) {
                break;
            }
        }
        if (count($hits) > 1) {
            throw new RuntimeException('Several candidate orders - resolve by hand');
        }
        return $hits[0] ?? null;
    }
}
```

Usage. Every function that is not a method of `ResellerApi` is your own code.

```php
$api = new ResellerApi((string) getenv('RESELLER_API_KEY'));    // throws if the key is missing or blank

// 1) Catalogue sync — cron every 5-10 minutes (§4.3)
$cats = $api->categories();
if (!$cats) {
    exit("empty catalogue reply - keeping the previous catalogue\n");
}
$now = date('Y-m-d H:i:s');
foreach ($cats as $pos => $cat) {
    upsertCategory($cat['id'], $cat['name'], $cat['icon'], $pos, $now);
    foreach ($cat['list_product'] as $p) {
        upsertProduct($p['id_product'], $cat['id'], $p['name'], $p['desc'], $p['price'], $p['quantity'], $now);
    }
}
unlistNotSeenSince($now);                                        // listed = 0, never delete

// 2) Checkout — after the checks, the price refresh and the payment of §6.1
$purchaseId = insertPurchase($shopOrderId, 124, 2, time());     // state 'sending', sent_at BEFORE /buy
$res = $api->buy(124, 2);
match ($res['state']) {
    'delivered', 'short' => saveLinesThenFulfil($purchaseId, $res['order_id'], $res['lines']),
    'awaiting'           => markAwaiting($purchaseId, $res['order_id']),
    'unknown'            => markUnknown($purchaseId),            // job 3 settles it
    default              => markFailedAndRefund($purchaseId, $res['msg'] ?? 'rate limited, nothing bought'),
};

// 3) Unknown outcomes — cron every minute (§6.6)
foreach (purchasesToReconcile() as $p) {                         // 'unknown', or 'sending' for > 5 minutes
    $o = $api->findOrder($p['id_product'], $p['quantity'], $p['sent_at'], 'isKnownOrderId');
    if ($o !== null) {
        attachOrder($p['id'], (int) $o['id']);                   // unique key on order_id
    } elseif (time() >= $p['sent_at'] + 180) {
        markNotBoughtAndRefund($p['id']);                        // the 10-minute sweep keeps watching
    }
}

// 4) Orders not priced yet, or still pending — cron every 2 minutes (§6.5, §6.9)
foreach (purchasesWithOpenOrder() as $p) {
    $o = $api->order($p['order_id']);
    if ($o === null) {
        markRefunded($p['id']);                                  // refunded: the money is back in your wallet
        continue;
    }
    savePrice($p['id'], $o['price'], $o['total_price'], $o['quantity']);
    if (count($o['data']) === 0) {
        continue;                                                // still pending; alert a human after 24 h
    }
    saveLines($p['id'], $o['data']);                             // skips lines you already have
    if (count($o['data']) < (int) $o['quantity']) {
        alertHuman("Order {$p['order_id']}: charged for {$o['quantity']}, received " . count($o['data']));
    }
}
```

---

## 9. Worked example — Node.js 18+

An ES module (save it as `.mjs`, or set `"type": "module"`). The jobs are the same as in §8.

```js
// Node.js 18+ (global fetch), ES module.
const BASE = 'https://acckit.com/api/v1';
const KEY = (process.env.RESELLER_API_KEY ?? '').trim();
if (!KEY) throw new Error('RESELLER_API_KEY is empty - refusing to start');
const Q = `api_key=${encodeURIComponent(KEY)}`;

export class Unknown extends Error {}    // timeout, network error, HTTP 5xx, non-JSON: outcome unknown
export class Rejected extends Error {}   // HTTP 4xx other than 429: not processed
export class RateLimited extends Error { // HTTP 429: not processed
  constructor(retryAfter) { super('rate limited'); this.retryAfter = retryAfter; }
}

async function call(path, body = null, ms = 30000) {
  let res;
  try {
    res = await fetch(BASE + path, {
      method: body ? 'POST' : 'GET',
      headers: { Accept: 'application/json' },
      body: body ? new URLSearchParams({ ...body, api_key: KEY }) : undefined,
      signal: AbortSignal.timeout(ms),
    });
  } catch (e) { throw new Unknown(String(e)); }
  if (res.status === 429) throw new RateLimited(Number(res.headers.get('retry-after')) || 60);
  if (res.status >= 500) throw new Unknown(`HTTP ${res.status}`);
  if (res.status >= 400) throw new Rejected(`HTTP ${res.status}: ${(await res.text().catch(() => '')).slice(0, 300)}`);
  try { return await res.json(); } catch { throw new Unknown(`non-JSON reply (HTTP ${res.status})`); }
}

export const categories = async () => (await call(`/categories?${Q}`)).data ?? [];
export const category = async (id) => (await call(`/category/${Number(id)}?${Q}`)).data ?? [];

export async function balance() {
  const r = await call('/balance', {});
  if (!r.status) throw new Error(`balance: ${r.msg}`);
  return Number(r.balance);
}

export async function getOrder(orderId) {   // null = never existed, or refunded
  const r = await call('/order', { order_id: orderId });
  if (r.data && r.data.id !== undefined) return r.data;
  if (r.msg === 'Order not found') return null;
  throw new Error(`order: ${r.msg}`);
}

export async function listOrders(page = 1) {
  const r = await call(`/orders?page=${Number(page)}`, {});
  if (!Array.isArray(r.data)) throw new Error(`orders: ${r.msg}`);
  return r;
}

export async function buy(idProduct, quantity) {
  let r;
  try {
    r = await call('/buy', { id_product: idProduct, quantity }, 120000);
  } catch (e) {
    if (e instanceof RateLimited) return { state: 'not_sent', retryAfter: e.retryAfter };
    if (e instanceof Rejected) return { state: 'failed', msg: e.message };
    if (e instanceof Unknown) return { state: 'unknown', msg: e.message };   // never resend: reconcile
    throw e;
  }
  if (!r.status) return { state: 'failed', msg: r.msg ?? 'unknown error' };
  const orderId = Number(r.order_id);
  if (r.pending) return { state: 'awaiting', orderId, lines: [] };
  const lines = Array.isArray(r.data) ? r.data : [];
  return { state: lines.length < quantity ? 'short' : 'delivered', orderId, lines };
}

// sentAtMs: Date.now() stored just BEFORE /buy.  isKnown(orderId): already in your database?
export async function findOrder(idProduct, quantity, sentAtMs, isKnown) {
  const since = sentAtMs - 120000;
  const hits = [];
  for (let page = 1; page <= 10; page++) {
    const r = await listOrders(page);
    for (const o of r.data) {
      if (!(Date.parse(o.created_at) >= since)) continue;
      if (Number(o.id_product) !== idProduct || Number(o.quantity) > quantity) continue;
      if (await isKnown(Number(o.id))) continue;
      hits.push(o);
    }
    const last = r.data.at(-1);
    if (!last || !(Date.parse(last.created_at) >= since) || page >= Number(r.meta?.last_page ?? 1)) break;
  }
  if (hits.length > 1) throw new Error('several candidate orders - resolve by hand');
  return hits[0] ?? null;
}
```

---

## 10. Worked example — Python 3.8+

Uses `requests`. The jobs are the same as in §8.

```python
# Python 3.8+ — pip install requests
import os
from datetime import datetime

import requests

BASE = "https://acckit.com/api/v1"
KEY = os.environ.get("RESELLER_API_KEY", "").strip()
if not KEY:
    raise SystemExit("RESELLER_API_KEY is empty - refusing to start")
HEADERS = {"Accept": "application/json"}


class Unknown(Exception):
    """Timeout, network error, HTTP 5xx or non-JSON reply: the outcome is unknown."""


class Rejected(Exception):
    """HTTP 4xx other than 429: the request was not processed."""


class RateLimited(Exception):
    """HTTP 429: not processed; wait retry_after seconds."""

    def __init__(self, retry_after):
        super().__init__("rate limited")
        self.retry_after = retry_after


def _call(path, data=None, timeout=30):
    try:
        if data is None:
            r = requests.get(BASE + path, params={"api_key": KEY}, headers=HEADERS, timeout=(10, timeout))
        else:
            r = requests.post(BASE + path, data={**data, "api_key": KEY}, headers=HEADERS, timeout=(10, timeout))
    except requests.RequestException as e:
        raise Unknown(str(e))
    if r.status_code == 429:
        retry = r.headers.get("Retry-After", "60")
        raise RateLimited(int(retry) if retry.isdigit() else 60)
    if r.status_code >= 500:
        raise Unknown(f"HTTP {r.status_code}")
    if r.status_code >= 400:
        raise Rejected(f"HTTP {r.status_code}: {r.text[:300]}")
    try:
        return r.json()
    except ValueError:
        raise Unknown(f"non-JSON reply (HTTP {r.status_code})")


def categories():
    return _call("/categories").get("data", [])


def category(category_id):
    return _call(f"/category/{int(category_id)}").get("data", [])


def balance():
    r = _call("/balance", {})
    if not r.get("status"):
        raise RuntimeError(f"balance: {r.get('msg')}")
    return float(r["balance"])


def get_order(order_id):
    """The order, or None when it does not exist (it never did, or it was refunded)."""
    r = _call("/order", {"order_id": order_id})
    if isinstance(r.get("data"), dict) and "id" in r["data"]:
        return r["data"]
    if r.get("msg") == "Order not found":
        return None
    raise RuntimeError(f"order: {r.get('msg')}")


def list_orders(page=1):
    r = _call(f"/orders?page={int(page)}", {})
    if not isinstance(r.get("data"), list):
        raise RuntimeError(f"orders: {r.get('msg')}")
    return r


def buy(id_product, quantity):
    try:
        r = _call("/buy", {"id_product": id_product, "quantity": quantity}, timeout=120)
    except RateLimited as e:
        return {"state": "not_sent", "retry_after": e.retry_after}
    except Rejected as e:
        return {"state": "failed", "msg": str(e)}
    except Unknown as e:
        return {"state": "unknown", "msg": str(e)}  # never resend: reconcile (6.6)
    if not r.get("status"):
        return {"state": "failed", "msg": r.get("msg", "unknown error")}
    order_id = int(r["order_id"])
    if r.get("pending"):
        return {"state": "awaiting", "order_id": order_id, "lines": []}
    lines = r.get("data") or []
    return {"state": "short" if len(lines) < quantity else "delivered", "order_id": order_id, "lines": lines}


def _ts(value):
    return datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp()


def find_order(id_product, quantity, sent_at, is_known):
    """sent_at: unix time stored just BEFORE /buy; is_known(order_id) -> True if already in your database."""
    since = sent_at - 120
    hits = []
    for page in range(1, 11):
        r = list_orders(page)
        items = r["data"]
        for o in items:
            if _ts(o["created_at"]) < since or int(o.get("id_product") or 0) != id_product:
                continue
            if int(o["quantity"]) > quantity or is_known(int(o["id"])):
                continue
            hits.append(o)
        if not items or _ts(items[-1]["created_at"]) < since or page >= int(r.get("meta", {}).get("last_page", 1)):
            break
    if len(hits) > 1:
        raise RuntimeError("several candidate orders - resolve by hand")
    return hits[0] if hits else None
```

---

## 11. Brief for an AI coding agent

Paste this verbatim into the agent, together with this file and the Reseller API reference.

> You are integrating the Reseller API of https://acckit.com into an existing e-commerce site so that the
> site can resell these products under its own brand.
>
> **Base URL** `https://acckit.com/api/v1` · **Auth** an `api_key` form or query field on every call ·
> **Header** `Accept: application/json` on every call · **Money** VND, parsed as decimals ·
> **Rate limit** 60 requests/minute per IP, shared with the SMM API.
>
> Build, in this order:
>
> 1. A server-side API client. `api_key` comes from an environment variable; refuse to start if it
>    is empty. Timeouts: 120 s for `POST /buy`, 30 s for everything else. Classify every reply:
>    HTTP 2xx with JSON → use it; HTTP 429 → not processed, wait `Retry-After` seconds; any other
>    HTTP 4xx → not processed, a bug to fix; timeout, network error, HTTP 5xx or a body that is not
>    JSON → outcome unknown. Never log `api_key` or `full_info`.
> 2. Tables: categories; products keyed by our `id_product`, with a `category_id` you fill from the
>    nesting in `GET /categories` (and update on every sync), the list price, stock, a `listed`
>    flag, `min_qty`, and your own price; purchases (`id_product`, quantity, expected unit price,
>    `sent_at`, state, our `order_id` under a unique key, `price`, `total_price`); delivered lines,
>    encrypted at rest.
> 3. A catalogue sync every 5–10 minutes, from `GET /categories` only. A failed, non-JSON or empty
>    reply changes nothing. Upsert everything seen with `listed = 1`; everything not seen gets
>    `listed = 0`. Never delete. Sold-out products disappear from the listing instead of showing
>    quantity 0, and come back later; products can change category; names and `desc` can change.
> 4. Catalogue and product pages rendered from your own tables, never from a live API call. Your
>    price is a margin on our list `price` (the unit price we charge is never above the list price
>    at the moment of purchase). Never show our prices.
> 5. A checkout that: checks the product is listed, quantity ≥ `min_qty` and ≤ stock, and no other
>    purchase of this product is in flight or unresolved → refreshes the product with
>    `GET /category/{category_id}` (fallback `GET /categories`; missing → unavailable; price above
>    the one you priced from → re-price or confirm) → takes the customer's money → stores the
>    purchase with `sent_at` and state `sending` → calls `POST /buy` (`id_product`, a whole-number
>    `quantity`).
> 6. Reply handling: `status:true` with lines → store every line, then deliver; fewer lines than
>    ordered → deliver those, refund the rest; `pending:true` → charged in full, no lines yet, state
>    `awaiting`; `status:false` → nothing charged, refund, show a mapped message (on
>    `Minimum quantity is N` store N); HTTP 4xx → nothing charged; unknown → state `unknown`, and
>    **never resend `POST /buy`**.
> 7. A job every minute for purchases in state `unknown` (or `sending` for more than 5 minutes):
>    read `POST /orders?page=1,2,…` (10 per page, newest first) back to `sent_at − 2 min`. A
>    candidate has the same `id_product`, `created_at` (ISO-8601 UTC) ≥ `sent_at − 2 min`,
>    `quantity` ≤ the requested quantity, and an `id` not yet in your database. One candidate →
>    attach it; several → alert a human; none and at least 3 minutes since `sent_at` → not bought,
>    refund. In `/orders` and `/order`, `type` is the product NAME; match on `id_product`.
> 8. A job every 2 minutes for purchases that have an `order_id` but no recorded price, or are
>    `awaiting`: call `POST /order`; store `price`, `total_price`, `quantity` and the lines.
>    `Order not found` → refunded (money back in the wallet); empty `data` → still pending (alert a
>    human after 24 hours); fewer lines than `quantity` → alert a human (contact support).
> 9. A sweep every 10 minutes: every order older than 5 minutes on the first pages of `/orders`
>    must be in your database; otherwise attach it to the matching purchase or alert a human.
> 10. A balance alarm: `POST /balance` every 10 minutes; alert a human when the balance falls below
>     three times your busiest hour of sales.
>
> Constraints you must respect:
>
> - Use a dedicated account for the shop: `/orders` also lists purchases made on the website.
> - At most one `POST /buy` per product in flight.
> - Stay under 60 requests per minute in total; on HTTP 429 wait `Retry-After`.
> - `desc` is free text, not a field layout. Deliver `full_info` raw; split it only with per-product
>   layouts verified on a real delivery; never show `link_backup` to customers.
> - Copy every delivered line into your own storage immediately: ours are deleted after about
>   15 days (daily at 03:30 Vietnam time, 20:30 UTC).
> - Warranty claims (within 48 hours of purchase) are made on the website only, and replacements
>   never appear in `/order`.
> - There is no sandbox: every call spends real money. Test with `quantity=1` on the cheapest
>   product.
>
> Do not invent endpoints, fields or parameters. If something you need is not in this document or
> in the Reseller API reference, say so instead of guessing.

---

## 12. Go-live checklist

**Correctness**

- The catalogue renders from your own database, not from a live API call
- `category_id` comes from the nesting in `GET /categories` and is updated on every sync
- Products that vanish are un-listed, never deleted, and are listed again when they come back
- A failed or empty sync leaves the previous catalogue intact
- Your prices, not ours, are shown everywhere
- A product that is not listed cannot be bought — checked on your server, not only in the UI
- The minimum learned from `Minimum quantity is N` is enforced

**Money**

- `/buy` has a 120-second timeout and is never resent after an unknown outcome
- The purchase row, with `sent_at`, is written before `/buy` is sent
- At most one `/buy` per product is in flight
- Reconciliation matches on `id_product` and waits 3 minutes before concluding "not bought"
- Pending orders are polled until they are delivered, refunded, or 24 hours old
- `price` and `total_price` are recorded from `/order` for every order, and short deliveries raise
  an alert
- Every failure path refunds or releases the customer's payment
- The balance alarm reaches a human

**Data**

- Every delivered line is stored on your side the moment it arrives
- Account lines are encrypted at rest and absent from logs
- The customer can download their purchase as a file
- `link_backup` is never shown to customers

**Operations**

- `Accept: application/json` on every request
- The app refuses to start with an empty `api_key`; the key comes from the environment, is not in
  your repository, and has never been sent to a browser
- A dedicated account is used for the shop
- Under 60 requests/minute (Reseller and SMM APIs together), from a single worker; HTTP 429 waits
  for `Retry-After`
- Your warranty to customers is shorter than our 48 hours, and someone knows how to file a claim on
  our website

**Tested once for real** (this costs real money)

- Buy 1 unit of the cheapest product end to end; the recorded `price` matches `/order`
- Make your client throw a timeout right after sending a real 1-unit `/buy` (ignore our reply):
  reconciliation must attach the order within 3 minutes
- Make your client throw just before sending: reconciliation must conclude "not bought" after
  3 minutes, and the customer is refunded
- Ask for more units than your balance covers: the customer sees a friendly message and is
  refunded
- Start the app with `RESELLER_API_KEY` unset: it refuses to start

---

## 13. Frequently hit problems

**"The API does not return the category of a product."** Correct — a product object has only
`id_product`, `name`, `desc`, `price` and `quantity`. The relationship exists only as the nesting in
`GET /categories`. Read that endpoint, store `category_id` yourself, and update it on every sync:
products can move.

**"I get an HTML page instead of JSON."** You are missing `Accept: application/json`, so a
validation error redirected you to a web page — or a proxy answered with an error page during a very
long purchase. For `/buy` the second case is an unknown outcome: reconcile (§6.6).

**"`type` in `/orders` is a name, not the product id."** Yes, it is the product name in your API
language. Use `id_product`.

**"A product I sell disappeared from `/categories`."** It sold out or was paused. Un-list it; it
comes back by itself. `/buy` on it answers `Product is not for sale right now`.

**"I was charged a different unit price than the listing showed."** The charge is the volume tier
(if your quantity reaches one) minus your account discount, and never more than the list price at
the moment of purchase. If it is higher than what you synced, the list price changed after your
sync: refresh right before buying (§6.1).

**"`/order` returns an empty `data` array."** On a recent order it is still pending (§6.5). On an
order older than the retention period the lines were purged (§7.2) — which is why you store them
yourself at delivery.

**"`/order` says `Order not found` for an order id I stored."** The order was refunded and the money
is back in your wallet (§7.3) — or you are calling with another account's key, or on another site's
domain.

**"I was charged but got no accounts."** Either the order is pending (`pending: true` — §6.5), or
your call ended without a reply and the order exists: reconcile (§6.6) before doing anything else.
If `/order` shows fewer lines than its `quantity` on an order that is not pending, contact support
with the `order_id`.

**"`link_backup` gives a 404 or a login page."** It is not meant for your customers: it works only
in a browser logged in to your account, and most products have no backup file (§6.8).

**"Can I see my discount and tier prices through the API?"** No. Open the product's Buy dialog on
our website while logged in; after buying, read `price` in `/order`.

**"I get 429 although my shop is quiet."** The limit is per IP address and shared with SMM API calls
and with every other server behind the same address (§7.5).

**"Can I get notified when something changes?"** No webhooks today. Poll on a schedule.

---

## 14. What changed on 30 September 2026

- `/orders` items and `/order` have a new last key, `id_product` (the product id). Reconcile with
  it: the key `type` there has always been the product name, so matching `type` against a product id
  never worked.
- Unit price: the volume tier price first, then your account discount on top of it (before, a tier
  price replaced your discount), and never above the list price at the moment of purchase.
- `/buy` only sells what the listings show. A product that exists but is not listed answers
  `Product is not for sale right now`.
- `quantity` must be a whole number; anything else gets HTTP 422.
- Minimum order quantities are now enforced through the API for every product
  (`Minimum quantity is N`).
- For products delivered in the reply, a short delivery is charged and recorded for the lines
  delivered only, and a delivery with no lines at all is a failure: nothing charged, no order.
- `/balance` with a missing or blank key answers `Invalid api_key`.

---

Questions this guide does not answer belong in the Reseller API reference (every endpoint, field and
error string), the OTP API reference (renting phone numbers) or the SMM API reference (likes,
followers, views). All three are on https://acckit.com/api-docs, each downloadable as Markdown.

