Guide
Redeem
Base: https://api.kabotapi.com ·
Auth: Authorization: Bearer pk_… (pk_ + 32 hex)
Idempotency — read this before you write any retry logic
POST /v1/redeem requires an
Idempotency-Key header. Without it you get
400 idempotency_required and nothing is spent.
Idempotency-Key: order-98213
A network timeout never tells you whether the redemption happened. Instead of making that your problem, we resolve it server-side:
| Situation | What you get |
|---|---|
| Retry after a timeout — same key, same body | The original response, replayed, with
KA-Idempotent-Replay: true. No second code is spent. |
| Two identical requests racing | 409 idempotency_in_progress on the second — never a
duplicate activation. |
| Same key, different body | 422 idempotency_conflict — protects you from silently
losing an order. |
| Our side returned 5xx | The key is released, so a retry runs cleanly. |
Keys are remembered for 24 hours. Use your own order id, and reuse the same value across retries of the same order. Generating a fresh key per attempt defeats the entire guarantee.
When to use A vs B
| Use | If… | Failure handling |
|---|---|---|
| A · Your codes | Codes live in your bot / supplier DB | We hold no copy, so disposition is always
unchanged. You decide refund / keep / retry from
status. |
| B · KA BOT stock | You stocked codes in KA BOT | Easier: each result says whether the code was
consumed, deleted, or
returned to your inventory. |
Request body
Provide exactly one code source.
{
"player_id": "5123456789",
"codes": ["ABCD…"], // A) your own codes
"uc": 120, // B) we pick the packs
"categories": ["60", "60"], // C) explicit packs
"packs": [{ "denomination": 60, "quantity": 2 }], // D) denomination × qty
"async": false // optional, see below
}
{ "player_id": "…", "denomination": 60, "quantity": 4 } is
accepted as a single-pack shorthand for packs.
| Field | Rule |
|---|---|
player_id | Digits only, 5–24 characters. Otherwise 400 bad_player. |
codes | Max 50 per request. Malformed → 400 bad_codes (with up to 5 samples in invalid). |
categories / packs | Max 50 codes resolved in total; quantity 1–50. |
uc | Resolved into packs from your stock. No combination → 400 no_combo. |
| Body size | 256 KB max → 413 payload_too_large. |
400 ambiguous_source. No source at all →
400 empty.Not enough KA BOT inventory →
409 no_stock (nothing is billed;
the body carries needed and your current
by_category counts).
Response
200 when at least one code activated ·
422 when none did. Both carry the same shape — always read
the per-code array, never just the status line.
{
"ok": true,
"player_id": "5123456789",
"player_name": "BLAKCMONSTER1",
"requested": 2,
"activated": 1,
"failed": 1,
"took_ms": 4130,
"source": "stock",
"codes": [
{ "code": "U3rSx…", "status": "activated", "disposition": "consumed", "billed": true },
{ "code": "U3rSx…", "status": "failed", "disposition": "returned", "billed": false,
"error_code": "ACTIVATION_FAILED", "message": "Temporary issue. Retry later." }
],
"billed_requests": 1,
"refunded_requests": 1,
"request_id": "req_9f2c…"
}
The player nickname comes back as player_name on every
redeem, so you can show the customer who was topped up.
status — what happened to the activation
| Value | Meaning | Billed |
|---|---|---|
activated | UC delivered | ✅ |
code_used | Code was already redeemed | ✅ |
invalid_code | Malformed / unknown / expired code | ✅ |
bad_player | Player ID does not exist | ❌ |
failed | Temporary issue on our side | ❌ |
disposition — what happened to your copy of the code
| Value | Meaning |
|---|---|
consumed | Spent successfully |
deleted | Proven spent or dead — removed so it is never re-issued |
returned | Verified still valid and put back in your stock |
unchanged | You supplied the code; we hold no copy |
returned after we re-check it
and prove it is still unburnt. We never hand back a code that might already
be spent.
Async mode
Activation can take tens of seconds — long enough for your own HTTP client
to time out, and a timeout is exactly what makes people retry.
"async": true answers in milliseconds:
// 202 Accepted
{
"ok": true,
"job_id": "job_46c8…",
"state": "queued",
"poll_url": "/v1/jobs/job_46c8…",
"requested": 2,
"request_id": "req_…"
}
Then poll GET /v1/jobs/{job_id} or — better —
take a webhook.
Job state is one of
queued → running → done |
failed. The completed job carries the same body as a
synchronous redeem under result.
If a job is interrupted by a server restart it lands in
failed with error_code: job_interrupted —
unused stock and quota are restored; start a new
attempt with a new Idempotency-Key (see
Errors).
{
"ok": true,
"job_id": "job_46c8…",
"state": "done",
"created_at": 1789000000,
"updated_at": 1789000042,
"result": { "…": "same shape as a sync redeem" }
}
Endpoints (cheat sheet)
| Call | Scope | What it does | Cost |
|---|---|---|---|
GET /v1/health | — | Liveness (no key) | 0 |
GET /v1/me | read | Plan, quota, balance, subscription | 0 |
GET /v1/stock | read | Your codes with us, by category | 0 |
GET /v1/jobs/{id} | read | Async job state + result | 0 |
POST /v1/webhook | read | Register an HTTPS callback | 0 |
POST /v1/redeem | redeem | Activate codes | 1 / billable code |
Python
Call the HTTP API from your backend with any HTTP client.
This example uses httpx and reuses the
same Idempotency-Key across retries of the same order:
import httpx
BASE = "{{BASE}}"
H = {
"Authorization": "Bearer pk_0123456789abcdef0123456789abcdef",
"Content-Type": "application/json",
# Your order id. Reuse it on every retry of THIS order.
"Idempotency-Key": "order-98213",
}
r = httpx.post(
f"{BASE}/v1/redeem",
headers=H,
json={"player_id": "5123456789", "uc": 120},
timeout=120.0,
)
if r.status_code in (200, 422): # business outcome — read the codes
body = r.json()
for c in body["codes"]:
print(c["code"], c["status"], c["disposition"], c["billed"])
print("billed:", body["billed_requests"],
"refunded:", body.get("refunded_requests", 0))
elif r.status_code == 429: # slow down, then retry the SAME key
print("retry after", r.headers.get("Retry-After"), "s")
else: # 4xx = fix the request; 5xx = retry
print(r.status_code, r.json()["error_code"], r.json()["message"])
Polling an async job
Bounded — never poll in an unbounded loop from a checkout path:
import time, httpx
job = httpx.post(f"{BASE}/v1/redeem", headers=H,
json={"player_id": "5123456789", "uc": 120, "async": True},
timeout=30.0).json()
deadline = time.time() + 300 # give up after 5 minutes
delay = 1.5
while time.time() < deadline:
j = httpx.get(f"{BASE}/v1/jobs/{job['job_id']}",
headers=H, timeout=30.0).json()
if j["state"] in ("done", "failed"):
break
time.sleep(delay)
delay = min(delay * 1.5, 10) # back off; do not hammer
else:
raise TimeoutError("job still running — quote the job_id to support")
curl
# smoke
curl -sS "{{BASE}}/v1/me" -H "Authorization: Bearer pk_0123456789abcdef0123456789abcdef"
# A · your codes
curl -sS -X POST "{{BASE}}/v1/redeem" \
-H "Authorization: Bearer pk_0123456789abcdef0123456789abcdef" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-98213" \
-d '{"player_id":"5123456789","codes":["CODE1","CODE2"]}'
# B · our stock
curl -sS -X POST "{{BASE}}/v1/redeem" \
-H "Authorization: Bearer pk_0123456789abcdef0123456789abcdef" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-98214" \
-d '{"player_id":"5123456789","uc":120}'
Receipt / history
Every response carries request_id (and job_id in
async mode). Store it on your order — it is what support asks for.
GET /v1/jobs/{job_id}— async job state and result (free)- In Telegram:
/api_system→ paste the id when support asks
No internal implementation details are exposed — only merchant-safe status and codes.