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