Files
2026-07-20 09:02:42 +02:00

306 lines
14 KiB
Markdown

# 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`. |
| GET | `/sklik/keywords` | `keywords.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`, `keywords`. 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.
### The bodies are typed in Swagger
Create and update bodies are modelled (`app/sklik_models.py`), so `/docs` shows
every field with its type, whether it is required, the allowed enum values
(campaign `type`, `status`, `adType`, `adSelection`, `paymentMethod`,
`premiseMode`, `videoFormat`) and a description with the unit. "Try it out" is
therefore fillable without reading the Sklik docs first.
Two properties of the models matter:
- **They do not restrict you.** Every model accepts undeclared fields and
forwards them to Sklik untouched, so the less common parts of the API
(retargeting, product sets, anything Sklik adds later) keep working without a
code change here. The schema documents the common path; it is not a whitelist.
- **Update is genuinely partial.** Only the fields you actually send are
forwarded, so omitting a field leaves it alone rather than resetting it. An
explicit `null` is still sent (that is how you clear `totalBudget`).
Schema violations (missing required field, bad enum value) come back as **422**
with the offending field in `detail[].loc` — before any Sklik call. The business
guard rails (budget ceilings, blocked activation) return **400** / **403**.
### Create — campaigns, groups and ads are 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 campaign, group and ad, and ignores any
other value you send (the override is logged).
> **Keywords are the exception** and are *not* forced. A keyword cannot spend
> anything on its own — the campaign, group and ad above it are all created
> paused, and those gate the spending. Forcing keywords paused as well would
> only create a trap: you activate the campaign in the Sklik UI, nothing
> happens, and the cause is a fourth paused level nobody expected. Sklik's own
> default (`active`) applies; send `status` explicitly if you want otherwise.
> `GET /sklik/write-limits` lists which entities are force-paused.
### 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.
Immutable fields, so `PUT` does not offer them at all:
| Entity | Cannot be changed |
| --- | --- |
| campaign | `type` |
| keyword | `name`, `matchType` — remove it and create a new one |
| ad | the creative (see the note above) |
### 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: <TOKEN>"
curl -X POST ".../sklik/campaigns/restore?ids=123456" -H "X-Sklik-Token: <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: <unique string per logical operation>` (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: <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: <TOKEN>" \
-H "Content-Type: application/json" \
-d '[{"campaignId":123456,"name":"Sestava A","cpc":300}]'
curl -X POST ".../sklik/ads" -H "X-Sklik-Token: <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: <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: <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: <https://api.sklik.cz/drak/>. Common ones: `client.get`,
`api.limits`, `campaigns.list`, `groups.list`, `ads.list`, `keywords.list`,
`*.createReport` / `*.readReport`.