Guide

Redeem

Base: https://api.kabotapi.com · Auth: Authorization: Bearer pk_… (pk_ + 32 hex)

POST /v1/redeem

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:

SituationWhat 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

UseIf…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.

FieldRule
player_idDigits only, 5–24 characters. Otherwise 400 bad_player.
codesMax 50 per request. Malformed → 400 bad_codes (with up to 5 samples in invalid).
categories / packsMax 50 codes resolved in total; quantity 1–50.
ucResolved into packs from your stock. No combination → 400 no_combo.
Body size256 KB max → 413 payload_too_large.
Mixing sources → 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

ValueMeaningBilled
activatedUC delivered✅
code_usedCode was already redeemed✅
invalid_codeMalformed / unknown / expired code✅
bad_playerPlayer ID does not exist❌
failedTemporary issue on our side❌

disposition — what happened to your copy of the code

ValueMeaning
consumedSpent successfully
deletedProven spent or dead — removed so it is never re-issued
returnedVerified still valid and put back in your stock
unchangedYou supplied the code; we hold no copy
A code is only ever 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)

CallScopeWhat it doesCost
GET /v1/health—Liveness (no key)0
GET /v1/mereadPlan, quota, balance, subscription0
GET /v1/stockreadYour codes with us, by category0
GET /v1/jobs/{id}readAsync job state + result0
POST /v1/webhookreadRegister an HTTPS callback0
POST /v1/redeemredeemActivate codes1 / 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.

No internal implementation details are exposed — only merchant-safe status and codes.