# 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.
