# Sklik (Seznam) Proxy over the Sklik **Drak JSON API** (`https://api.sklik.cz/drak/json/v5/{method}`). ## Protocol (verified against seznam/api-examples) - HTTP `POST` to the base URL with the **method name appended** to the path. - Body is a **JSON array** of positional arguments. - `client.loginByToken` takes the API token and returns `{"status":200,"session":"...","statusMessage":"OK"}`. - Every authenticated method takes the user struct `{"session": ...}` (optionally `"userId"`) as its **first** argument, followed by the method's own arguments. - Every response is an object with `status` (HTTP-style), `statusMessage`, a refreshed `session`, and method-specific data. `200`, `206` and `301` are treated as success. The proxy performs `client.loginByToken` per request from `X-Sklik-Token` and injects the session — callers never handle the session. ## Credentials | Header | Required | Meaning | | --- | --- | --- | | `X-Sklik-Token` | yes | API token from Sklik → account settings → API. | | `X-Sklik-User-Id` | no | Managed account `userId` (agency/MCC access). | ### Kde získat token (návod pro klienta) 1. Přihlaste se na [sklik.cz](https://www.sklik.cz/). 2. Vpravo nahoře **uživatelské jméno → Nastavení**. 3. Sekce **Přístup k API Drak** → **Zobrazit token**. 4. Token → hlavička `X-Sklik-Token`. > Nový token zneplatní ten předchozí. Token je vázaný na účet, pod kterým jste > přihlášeni. Pro správu cizích účtů (agentura/MCC) použijte `X-Sklik-User-Id`. Missing token → `401 missing_credentials`. Sklik business errors (invalid token, access denied, bad arguments) are surfaced as `upstream_error` with the Sklik `status` and full body. ## Endpoints | Method | Path | Purpose | | --- | --- | --- | | POST | `/sklik/login` | Verify the token. Returns `{valid, status, statusMessage}` (no session). | | GET | `/sklik/limits` | `api.limits` — quotas and the `statsDataLimit`. | | GET | `/sklik/write-limits` | Guard rails applied to writes. No credentials needed. | | GET | `/sklik/campaigns` | `campaigns.list`, all pages collected. | | GET | `/sklik/groups` | `groups.list`, filterable by `campaign_ids`. | | GET | `/sklik/ads` | `ads.list`, filterable by `campaign_ids` / `group_ids`. | | POST | `/sklik/campaigns` `/sklik/groups` `/sklik/ads` | **Create — always paused.** | | PUT | `/sklik/campaigns` `/sklik/groups` `/sklik/ads` | **Update** by id (partial). | | DELETE | `/sklik/campaigns` `/sklik/groups` `/sklik/ads` | **Remove** (`?ids=1,2,3`) — reversible. | | POST | `/sklik/{entity}/restore` | **Restore** removed entities (`?ids=1,2,3`). | | POST | `/sklik/report/{entity}` | `createReport` + paged `readReport` for an entity. | | POST | `/sklik/rpc/{method}` | Generic authenticated call to any method. | > The proxy never returns the Sklik `session` — it is a credential, and > AGENTS.md forbids returning secrets from ordinary endpoints. The session is > managed internally and callers have no use for it. ### Listing entities `GET /sklik/campaigns`, `/sklik/groups`, `/sklik/ads` wrap `{entity}.list` and page through the whole result set (offset/limit, `SKLIK_LIST_PAGE_LIMIT` rows per page, capped by `SKLIK_LIST_MAX_PAGES`): ```json { "totalCount": 42, "returnedCount": 42, "truncated": false, "campaigns": [ ... ] } ``` Query parameters: | Parameter | Endpoints | Meaning | | --- | --- | --- | | `ids` | all | Comma-separated ids of the entity itself. | | `campaign_ids` | groups, ads | Restrict to these campaigns. | | `group_ids` | ads | Restrict to these groups. | | `is_deleted` | all | `true`/`false`. Omit to get both. | | `display_columns` | all | Comma-separated columns; defaults to a useful subset. | > Sklik's `campaigns.list` filter supports only `ids` and `isDeleted` — there is > **no status filter upstream**, so filter on `status` in the returned rows. > `groups.list` and `ads.list` do support parent filters (`campaign.ids`, > `group.ids`), which is what `campaign_ids` / `group_ids` map to. ## Writes — full CRUD over campaigns / groups / ads | Operation | Endpoint | Upstream | Body / params | | --- | --- | --- | --- | | Create | `POST /sklik/{entity}` | `{entity}.create` | JSON array of structs | | Update | `PUT /sklik/{entity}` | `{entity}.update` | JSON array of structs, `id` required | | Remove | `DELETE /sklik/{entity}?ids=1,2` | `{entity}.remove` | ids in the query | | Restore | `POST /sklik/{entity}/restore?ids=1,2` | `{entity}.restore` | ids in the query | `{entity}` ∈ `campaigns`, `groups`, `ads`. The structs are exactly the ones Sklik documents ([campaigns.create](https://api.sklik.cz/drak/campaigns.create.html), [campaigns.update](https://api.sklik.cz/drak/campaigns.update.html), and the `groups.*` / `ads.*` equivalents). Sklik batches are **all-or-nothing**: if one item fails, nothing is applied. ### Create — everything is paused, not configurable Sklik's `status` field **defaults to `active`**, so an omitted status would create a *live, spending* campaign. The proxy therefore forces `status: "suspend"` on every created entity and ignores any other value you send (the override is logged). ### Update — ordinary CRUD, status is not forced `PUT` changes only the fields you send; `id` is required per item. Unlike create, `status` is passed through as given — setting `suspend` is how you pause a running campaign and `active` is how you resume one, so forcing a value here would break half the use cases. That does mean **update can start spending**. If you want activation to stay a manual action in the Sklik UI, set `SKLIK_BLOCK_ACTIVATION=true`: `status: "active"` is then refused with `403` while pausing still works. Default is `false` (both directions allowed). Every activation is logged at WARNING either way. > **Ads: changing the creative replaces the ad.** Sklik cannot edit an existing > ad's headlines, description or URLs — it deletes the old ad and creates a new > one, so the ad gets a **new id**. Re-read the group's ads after such an > update. Changing only `status` keeps the id. `type` cannot be changed on a campaign. ### Remove and restore — reversible Sklik's removal is a soft delete: "the campaign is not really removed; it is only marked as removed". Every `DELETE` therefore has a matching restore endpoint, and removed entities still show up in listings unless you filter with `is_deleted=false`. ```bash curl -X DELETE ".../sklik/campaigns?ids=123456" -H "X-Sklik-Token: " curl -X POST ".../sklik/campaigns/restore?ids=123456" -H "X-Sklik-Token: " ``` ### Optional guard rails Both are **off by default** — the proxy does not second-guess your numbers unless you ask it to: | Guard | How to enable | Effect | | --- | --- | --- | | Budget ceilings | Set `SKLIK_MAX_DAY_BUDGET_HALERS`, `SKLIK_MAX_TOTAL_BUDGET_HALERS`, `SKLIK_MAX_CPC_HALERS` to a non-zero value | A create **or update** above the ceiling is rejected with `400` before Sklik is called. | | Idempotency | Send an `X-Idempotency-Key` header | A retry with the same key returns the original result instead of repeating the write. Works on every write endpoint. | | No activation via API | Set `SKLIK_BLOCK_ACTIVATION=true` | `PUT` refuses `status: "active"` with `403`; pausing still works. | Amounts are in **halers** (100 halers = 1 Kč), matching the Sklik API — a ceiling mainly protects against a misplaced decimal point. `GET /sklik/write-limits` reports what is currently enforced (`null` = no limit). Basic shape validation always applies (required fields present, money fields integer and non-negative), so you get a clear message instead of a generic upstream rejection. ### Idempotency Recommended for writes: if a create times out on the network you cannot tell whether the campaign was created, and a blind retry creates a second one. - Send `X-Idempotency-Key: ` (e.g. a UUID). - A retry with the same key returns the stored result plus `"idempotentReplay": true` — Sklik is not called again. - A **failed** create releases the key, so you can retry it. - A concurrent duplicate (same key still in flight) gets `409`. Limitations, stated plainly: the store is **in-memory and per-container**. It does not survive a restart and is not shared between replicas, so with more than one container a retry can land somewhere that has never seen the key. It removes the common failure (an immediate retry after a timeout); it is not a distributed guarantee. Moving it to Redis should be a conscious decision, not a surprise. ### Example — create a paused campaign ```bash curl -X POST "https://services.csbot.cz/apps/analytics/sklik/campaigns" \ -H "X-Sklik-Token: " \ -H "X-Idempotency-Key: 8f3a1c02-0f1e-4c3a-9d6b-2b7e5f0a1c44" \ -H "Content-Type: application/json" \ -d '[{"name":"Léto 2026","type":"fulltext","dayBudget":20000,"totalBudget":200000}]' ``` `dayBudget: 20000` = 200 Kč/day. Response: ```json { "status": 200, "statusMessage": "OK", "campaignIds": [123456], "createdCount": 1, "createdStatus": "suspend", "idempotentReplay": false, "note": "Created paused. Activate manually in the Sklik UI ..." } ``` Then a group and an ad in it: ```bash curl -X POST ".../sklik/groups" -H "X-Sklik-Token: " \ -H "Content-Type: application/json" \ -d '[{"campaignId":123456,"name":"Sestava A","cpc":300}]' curl -X POST ".../sklik/ads" -H "X-Sklik-Token: " \ -H "Content-Type: application/json" \ -d '[{"groupId":654321,"adType":"eta","headline1":"Nadpis jedna","headline2":"Nadpis dva","description":"Popis inzerátu.","finalUrl":"https://example.com/"}]' ``` ### Report helper `entity` ∈ `campaigns, groups, ads, keywords, queries, sitelinks, productSets, banners`. Body = the arguments for `{entity}.createReport` (restriction filter + optional display options). The proxy creates the report then pages through `{entity}.readReport` (100 rows/page) and returns: ```json { "reportId": "...", "totalCount": 1234, "returnedCount": 1234, "truncated": false, "report": [ ... ] } ``` Example body for `POST /sklik/report/campaigns`: ```json [ { "dateFrom": "2026-06-01", "dateTo": "2026-06-18", "statGranularity": "daily" }, { "statGranularity": "daily" } ] ``` ### Generic RPC `POST /sklik/rpc/{method}` with a JSON-array body of the arguments **after** the session struct (which the proxy injects). Reaches **any** method, including mutating ones — that is the long-standing behaviour and it is unchanged. > Calls made this way bypass the typed write endpoints' guard rails: nothing > forces `status: "suspend"`, no budget ceiling applies and there is no > idempotency. For creating campaigns prefer `POST /sklik/campaigns`. > An operator who wants to enforce that can set > `SKLIK_RPC_ALLOW_MUTATIONS=false`, which makes this endpoint refuse > `.create` / `.update` / `.remove` / `.delete` / `.restore` / `.setStatus` > with `403`. Default is `true` (everything allowed). Examples: ```bash # List campaigns curl -X POST ".../apps/analytics/sklik/rpc/campaigns.list" \ -H "X-Sklik-Token: " -H "Content-Type: application/json" \ -d '[{"statuses":["active"]}, {"displayColumns":["id","name","status"]}]' # Account info curl -X POST ".../apps/analytics/sklik/rpc/client.get" \ -H "X-Sklik-Token: " -H "Content-Type: application/json" -d '[]' ``` `client.loginByToken` cannot be called via `/sklik/rpc` — the proxy manages the session (returns `400`). ## Method reference Full method list: . Common ones: `client.get`, `api.limits`, `campaigns.list`, `groups.list`, `ads.list`, `keywords.list`, `*.createReport` / `*.readReport`.