# KısaLinkAtla Developer API — Reference

> KısaLinkAtla is a community Trends feed of the most-unlocked links in Turkey, an ad-gated short-link resolver, and a developer REST API. The API resolves ad-gated and shortened links (ouo.io, aylink.co, shorte.st, cuty.io, cpmlink, tr.link, bit.ly and 40+ registered domains) to their final destination URL. JSON in, JSON out.
>
> Türkçe: KısaLinkAtla; topluluk Trendler akışı, reklamlı kısa link çözücü ve geliştirici API'sidir. Bu dosya API'nin düz Markdown referansıdır; insan okuması için tam sayfa: https://www.kisalinkatla.com/developers
>
> Last updated: 2026-10-05

- Base URL: `https://api.kisalinkatla.com`
- Auth: `X-API-Key` header (keys start with `bp_`)
- Machine-readable spec: [OpenAPI 3](https://www.kisalinkatla.com/openapi.json) (also at `https://api.kisalinkatla.com/openapi.json`)
- Live limits and supported domains: `GET https://api.kisalinkatla.com/api-info` (no auth, no quota)
- Human docs, plans and key management: https://www.kisalinkatla.com/developers
- Pricing (Markdown): https://www.kisalinkatla.com/pricing.md

## Authentication

Send your key in the `X-API-Key` header on every request.

```
X-API-Key: bp_xxxxxxxx
```

- Create and revoke keys at https://www.kisalinkatla.com/developers/keys (sign-in required). The raw key is shown once; only a masked version is listed afterwards. Keys are stored as SHA-256 hashes.
- Up to 3 active keys per account, on every plan. A revoked key returns `403 api_key_disabled`.
- `Authorization: Bearer <key>` is **not** supported. A key sent that way is treated as a web session token and rejected with `401`.
- Never put a key in client-side (browser) code. Call the API from your server.

## Endpoints

| Method | Path | Auth | Quota cost | Purpose |
|---|---|---|---|---|
| POST | `/resolve` | X-API-Key | 1 | Synchronous resolve (waits up to ~10 s) |
| POST | `/bypass` | X-API-Key | 1 | Asynchronous resolve, returns a `job_id` |
| GET | `/job/{job_id}` | X-API-Key | 0 | Job status and result |
| GET | `/status/{id}` | X-API-Key | 0 | Deprecated alias of `/job/{job_id}` |
| POST | `/batch/bypass` | X-API-Key | 1 per URL | Queue up to 50 links in one call |
| GET | `/batch/{batch_id}` | X-API-Key | 0 | Aggregate status of a batch |
| GET | `/history` | X-API-Key | 0 | Your key's last 20 finished jobs |
| GET | `/api-info` | none | 0 | Plans, limits, error codes, supported domains |
| GET | `/health` | none | 0 | Liveness probe: `{"status": "ok", "uptime_s": 3600}` |

### Request body (`POST /resolve`, `POST /bypass`)

```json
{ "url": "https://ouo.io/2yK4Pq" }
```

- `url` (string, required): must start with `https://`, at most 2048 characters, no control characters, and the domain must be a registered shortener (see [Supported domains](#supported-domains)).

### POST /resolve — synchronous

Waits on the server for up to ~10 seconds. Shares auth, quota, cache and queue with `POST /bypass`.

`200 OK` — result ready:

```json
{
  "status": "success",
  "url": "https://ouo.io/2yK4Pq",
  "result_url": "https://example.com/article",
  "engine": "ouo",
  "lane": "heavy",
  "safety_status": null,
  "cached": false,
  "elapsed_ms": 8421,
  "job_id": "7c1a9f3e-6b02-4a1d-9f41-2ac5d0e7b913"
}
```

On a cache hit the response is still `200` with `"cached": true`, `"elapsed_ms": 0` and `"job_id": null`.

`202 Accepted` — not finished within the window; poll `GET /job/{job_id}`:

```json
{
  "status": "pending",
  "job_id": "7c1a9f3e-6b02-4a1d-9f41-2ac5d0e7b913",
  "lane": "heavy",
  "message": "Still processing; poll GET /job/{job_id} for the result."
}
```

`422` — the job failed permanently inside the window (quota refunded):

```json
{ "detail": { "status": "failed", "job_id": "7c1a9f3e-…", "reason": "…" } }
```

### POST /bypass — asynchronous

Queues the link and returns immediately.

`200 OK` — queued:

```json
{
  "status": "started",
  "job_id": "7c1a9f3e-6b02-4a1d-9f41-2ac5d0e7b913",
  "lane": "heavy",
  "message": "Job queued."
}
```

`200 OK` — cache hit (no job is queued; handle both shapes):

```json
{
  "status": "success",
  "result_url": "https://example.com/article",
  "engine": "ouo",
  "cached": true,
  "message": "Result served from cache."
}
```

### GET /job/{job_id} — poll

Does not consume quota. Only the key that created the job can read it; any other caller gets `404` (existence is not leaked).

```json
{
  "job_id": "7c1a9f3e-6b02-4a1d-9f41-2ac5d0e7b913",
  "status": "success",
  "lane": "heavy",
  "attempt": 1,
  "max_attempts": 2,
  "result_url": "https://example.com/article",
  "engine_used": "ouo",
  "safety_status": null,
  "created_at": "2026-10-05T12:00:00.000000",
  "completed_at": "2026-10-05T12:00:08.421000"
}
```

- `status`: `pending` → `processing` → `success` | `dead` (`retrying` between attempts). Terminal states: `success`, `dead`.
- When `status` is `dead` or `retrying`, the body also contains a `last_error` object (with a `message`).
- Recommended polling: start at 2 s, multiply by 1.5 each round, cap at 10 s.
- Lanes: `fast` (plain HTTP, usually seconds) and `heavy` (real browser; ouo.io, aylink.co, shorte.st, cuty.io, cpmlink — typically 10–60 s, a few minutes in the worst case because of two attempts with a 45/60/75 s timeout ladder). Use `/bypass` + polling for heavy-lane links.
- A job that ends `dead` has its quota unit refunded.

### POST /batch/bypass — batch

Queues up to 50 links in one call and returns one `job_id` per URL.

```json
{ "urls": ["https://ouo.io/2yK4Pq", "https://aylink.co/abc123"] }
```

- `urls`: 1–50 items, no duplicates, each following the same rules as `url` above. A list that breaks any of these rules (more than 50 URLs, a duplicate, an unsupported domain, a non-`https://` URL) is rejected at schema validation with FastAPI's `422 {"detail": [ … ]}` and no quota is charged.
- Quota: 1 unit per URL, reserved atomically up front. If the remaining quota does not cover the whole batch, the request is rejected with `429 quota_exceeded` and nothing is charged. If a URL turns out to be unsupported after reservation, the request is rejected and the whole reservation is refunded. Each job that later ends `dead` is refunded individually.

`200 OK`:

```json
{
  "batch_id": "b7a3c1d0-91e4-4d2b-8f77-0c5a9e31ab42",
  "count": 2,
  "jobs": [
    { "url": "https://ouo.io/2yK4Pq", "job_id": "7c1a9f3e-6b02-4a1d-9f41-2ac5d0e7b913", "lane": "heavy", "status": "pending" },
    { "url": "https://aylink.co/abc123", "job_id": "2fd5a7c8-1b34-49e0-a0c6-71b8e2d4f905", "lane": "heavy", "status": "pending" }
  ]
}
```

Batch jobs are always queued (no inline cache answer); poll `GET /batch/{batch_id}` or each `GET /job/{job_id}`.

### GET /batch/{batch_id} — batch status

Does not consume quota. Only jobs created by the calling key are visible; an unknown batch or another key's batch returns `404`. An invalid or revoked key returns `401 invalid_api_key`.

```json
{
  "batch_id": "b7a3c1d0-91e4-4d2b-8f77-0c5a9e31ab42",
  "status": "processing",
  "total": 2,
  "done": 1,
  "successful": 1,
  "failed": 0,
  "jobs": [
    {
      "job_id": "7c1a9f3e-6b02-4a1d-9f41-2ac5d0e7b913",
      "url": "https://ouo.io/2yK4Pq",
      "status": "success",
      "lane": "heavy",
      "result_url": "https://example.com/article",
      "engine_used": "ouo",
      "safety_status": null,
      "completed_at": "2026-10-05T12:00:08.421000"
    },
    {
      "job_id": "2fd5a7c8-1b34-49e0-a0c6-71b8e2d4f905",
      "url": "https://aylink.co/abc123",
      "status": "processing",
      "lane": "heavy",
      "result_url": null,
      "engine_used": null,
      "safety_status": null,
      "completed_at": null
    }
  ]
}
```

- `status`: `processing` (some job still pending/processing/retrying), `complete` (all succeeded), `partial` (finished, at least one `dead`). `done` counts `success` + `dead`.
- Jobs in `dead` or `retrying` also carry `last_error`. Recommended polling: start at 3 s, ×1.5 per round, cap at 15 s.

### GET /history — recent jobs

Returns the calling key's last finished jobs (`success` and `dead`), newest first. Does not consume quota. API key only; a web session cannot use it. Query: `limit` 1–20, default 20.

```json
[
  {
    "url": "https://ouo.io/2yK4Pq",
    "result_url": "https://example.com/article",
    "lane": "heavy",
    "engine": "ouo",
    "status": "success",
    "safety_status": null,
    "elapsed_ms": 8421,
    "ts": "2026-10-05T12:00:00.000000"
  }
]
```

Errors: `401 missing_api_key` / `401 invalid_api_key`.

### safety_status

Carries the VirusTotal verdict (`scanning`, `Clean`, `Malicious`, `Suspicious`, `Unknown`, `Error`). The scan currently runs for premium web sessions only; **for API-key requests this field is `null`**.

## Quota, rate limits and caching

- Daily quota per key on paid plans; on the Free plan the 50/day quota is **shared by all free keys of an account** (creating or revoking keys does not reset it). Resets at 00:00 UTC. Every API-key response carries:
  - `X-RateLimit-Limit` — the plan's daily limit (`unlimited` on unlimited plans)
  - `X-RateLimit-Remaining` — requests left today
  - `X-RateLimit-Reset` — next UTC midnight as a unix epoch (seconds)
- IP rate limit: 30 requests per minute → `429 rate_limited` with `Retry-After: 60`.
- Quota exhausted → `429 quota_exceeded` (`quota_scope`: `account` for shared Free quota, `key` otherwise); `Retry-After` is the number of seconds until UTC midnight. There is no paid overage.
- Refunds: dead link detected before queueing (`422 dead_url`), a job that ends `dead` (single or batch), and an in-memory cache hit. A database cache hit is charged.
- Quota cost: `/resolve`, `/bypass` 1 unit; `/batch/bypass` 1 unit per URL; `/job`, `/status`, `/batch/{batch_id}`, `/history`, `/api-info`, `/health` 0.
- Cache: links resolved before are answered from cache with a normal `200` response (`"cached": true`).

## Plans

Daily limits come from the backend (`GET /api-info` → `limits.plans`).

| Plan | Price | Requests/day |
|---|---|---|
| Free | ₺0 | 50 (per account, all free keys combined) |
| Starter | ₺399/month | 500 |
| Pro | ₺1.490/month | 5,000 |
| Scale (`business`) | Custom — https://www.kisalinkatla.com/contact | 50,000 |

Starter and Pro are bought with Paddle on https://www.kisalinkatla.com/developers; the plan is applied to the buying account's keys automatically. Scale is provisioned manually.

## Errors

Auth and quota errors use a structured envelope. Read `error.code` (stable); `error.message` is English, human-oriented and may change. `detail` is kept for backwards compatibility (deprecated).

```json
{
  "error": {
    "code": "quota_exceeded",
    "message": "Daily quota exhausted for the 'starter' plan (500/500 requests); it resets at UTC midnight. …",
    "retryable": true,
    "docs_url": "https://kisalinkatla.com/developers/keys",
    "plan": "starter",
    "daily_limit": 500,
    "reset_at": 1791244800
  },
  "detail": "Daily quota exhausted for the 'starter' plan (500/500 requests); it resets at UTC midnight. …"
}
```

| HTTP | `error.code` | Retryable | Meaning |
|---|---|---|---|
| 401 | `missing_api_key` | no | No `X-API-Key` header |
| 401 | `invalid_api_key` | no | Key not recognised |
| 403 | `api_key_disabled` | no | Key revoked |
| 429 | `rate_limited` | yes | 30 requests/minute per IP exceeded; `Retry-After: 60` |
| 429 | `quota_exceeded` | yes | Daily plan quota used up; `Retry-After` = seconds to UTC midnight |
| 400 | `invalid_url` | no | Malformed URL |
| 422 | `unsupported` | no | Domain is not a registered shortener |
| 502 | `cloudflare_block` | no | Target returned a Cloudflare challenge |
| 502 | `access_denied` | no | Target denied automated access |

Responses outside the envelope:

- `422` dead link (preflight): `{"detail": {"error": "dead_url", "reason": "…"}}` — not queued, quota refunded.
- `422` schema validation (unsupported domain, non-`https://` URL, too long): FastAPI's standard `{"detail": [ … ]}` list. No quota is charged.
- `404`: job or batch not found, or owned by another key.

## Supported domains

The authoritative live list is `GET /api-info` → `supported_domains`. As of the date above (44 domains):

- Heavy lane (browser): ouo.io, ouo.press, aylink.co, ay.link, ay.live, shorte.st, sh.st, gestyy.com, destyy.com, cuty.io, cutyio.com, cutyion.com, cuttty.com, cpmlink.net, cpmlink.co, cpmlink.io, cpmlink.pro, cpmlink.me, cpmlink.link, cpmlink.club, cpmlink.site
- Fast lane (HTTP): tr.link, linkbull.cc, bit.ly, bit.do, tinyurl.com, t.co, is.gd, v.gd, rb.gy, shorturl.at, shorturl.asia, cutt.ly, tl.tc, s.id, t.ly, tiny.cc, ow.ly, buff.ly, bc.vc, soo.gd, rebrand.ly, short.io, kutt.it

Any other domain (e.g. linkvertise.com, shrinkme.io) is rejected with `422` and costs no quota.

Not available: webhooks/callbacks (results are delivered by polling only), official SDKs, IP allowlisting, SSO.

## Examples

Set the key once (`export BYPASS_API_KEY="bp_xxxxxxxx"`); all examples read it from the environment.

### curl

```bash
# Synchronous
curl -sS -X POST https://api.kisalinkatla.com/resolve \
  -H "X-API-Key: $BYPASS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://ouo.io/2yK4Pq"}'

# Asynchronous: enqueue, then poll (polling is free)
curl -sS -X POST https://api.kisalinkatla.com/bypass \
  -H "X-API-Key: $BYPASS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://ouo.io/2yK4Pq"}'
curl -sS https://api.kisalinkatla.com/job/JOB_ID -H "X-API-Key: $BYPASS_API_KEY"

# Batch (up to 50 URLs), then poll the aggregate status
curl -sS -X POST https://api.kisalinkatla.com/batch/bypass \
  -H "X-API-Key: $BYPASS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"urls": ["https://ouo.io/2yK4Pq", "https://aylink.co/abc123"]}'
curl -sS https://api.kisalinkatla.com/batch/BATCH_ID -H "X-API-Key: $BYPASS_API_KEY"

# Last 20 finished jobs (free)
curl -sS "https://api.kisalinkatla.com/history?limit=20" -H "X-API-Key: $BYPASS_API_KEY"
```

### Python (requests)

```python
import os
import time

import requests

API = "https://api.kisalinkatla.com"
HEADERS = {"X-API-Key": os.environ.get("BYPASS_API_KEY", "bp_xxxxxxxx")}


def poll(job_id: str, interval: float = 2.0, timeout: float = 180.0) -> dict:
    deadline = time.time() + timeout
    while time.time() < deadline:
        r = requests.get(f"{API}/job/{job_id}", headers=HEADERS, timeout=30)
        r.raise_for_status()
        job = r.json()
        if job["status"] == "success":
            return job
        if job["status"] == "dead":
            raise RuntimeError(job.get("last_error", {}).get("message", "bypass failed"))
        time.sleep(interval)
        interval = min(interval * 1.5, 10.0)
    raise TimeoutError(job_id)


def resolve(url: str, max_retries: int = 3) -> dict:
    for _ in range(max_retries):
        r = requests.post(f"{API}/resolve", headers=HEADERS, json={"url": url}, timeout=60)
        if r.status_code == 429:
            wait = int(r.headers.get("Retry-After", "60"))
            if r.json().get("error", {}).get("code") == "quota_exceeded":
                raise RuntimeError(f"quota_exceeded: resets in {wait}s")
            time.sleep(wait)  # rate_limited: per-minute window
            continue
        if r.status_code == 202:
            return poll(r.json()["job_id"])
        if r.status_code == 422:
            raise ValueError(r.text)  # dead or unsupported link, no quota charged
        r.raise_for_status()
        return r.json()
    raise RuntimeError("rate limited: retries exhausted")


if __name__ == "__main__":
    print(resolve("https://ouo.io/2yK4Pq")["result_url"])
```

### JavaScript (fetch, Node 18+)

```js
const API = "https://api.kisalinkatla.com";
const HEADERS = {
  "X-API-Key": process.env.BYPASS_API_KEY || "bp_xxxxxxxx",
  "Content-Type": "application/json",
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function poll(jobId, { interval = 2000, timeout = 180000 } = {}) {
  const deadline = Date.now() + timeout;
  let wait = interval;
  while (Date.now() < deadline) {
    const res = await fetch(API + "/job/" + jobId, { headers: HEADERS });
    if (!res.ok) throw new Error("poll failed: " + res.status);
    const job = await res.json();
    if (job.status === "success") return job;
    if (job.status === "dead") throw new Error(job.last_error?.message || "bypass failed");
    await sleep(wait);
    wait = Math.min(wait * 1.5, 10000);
  }
  throw new Error("timeout: " + jobId);
}

export async function resolve(url, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    const res = await fetch(API + "/resolve", {
      method: "POST",
      headers: HEADERS,
      body: JSON.stringify({ url }),
    });
    if (res.status === 429) {
      const wait = Number(res.headers.get("Retry-After") || 60);
      const body = await res.json();
      if (body.error?.code === "quota_exceeded") throw new Error("quota_exceeded: resets in " + wait + "s");
      await sleep(wait * 1000); // rate_limited: per-minute window
      continue;
    }
    if (res.status === 202) return poll((await res.json()).job_id);
    if (!res.ok) throw new Error(await res.text());
    return res.json();
  }
  throw new Error("rate limited: retries exhausted");
}
```

## Changelog

- 2026-10-05: Public OpenAPI spec published at `/openapi.json` (now including `/batch/bypass`, `/batch/{batch_id}` and `/history`); this Markdown reference added.
- 2026-09-17: `GET /api-info` added; structured error envelope and `X-RateLimit-*` headers on API-key responses; `detail` and `GET /status/{id}` deprecated.
