The Cookies správně API
An HTTP API for reading the consent records your cookie bar has collected. Bearer token authentication, JSON responses, cursor pagination. The whole reference is on this one page.
On this page
- 1. What the API is for
- 2. Authentication
- 3. GET /api/v2/licenses
- 4. GET /api/v2/consents
- 5. Sorting and pagination
- 6. Limits and errors
- 7. Example calls
- 8. Legacy API (v1)
1. What the API is for
The API has two endpoints:
GET /api/v2/licensesreturns the licenses your token can reach and theirid.GET /api/v2/consentsreturns a paginated export of one license's consents.
The base address is https://cookies-spravne.cz/api/v2.
In the data, a consent is a state, not an event. One row per license and visitor pair, overwritten on every change of choice. The export therefore returns each visitor's current decision, not a time series of their clicks. Details are in GET /api/v2/consents.
The API has no writing, deleting or webhooks. Consents are created only in the bar.
Conventions
| Base address | https://cookies-spravne.cz/api/v2 |
|---|---|
| Methods | GET |
| Response Content-Type | application/json |
| Cache | both endpoints respond with Cache-Control: no-store |
| Times | UTC, ISO 8601 with the Z suffix, accurate to the second |
| Consent id | UUID as a string |
license_id | integer |
| Booleans | true and false |
A successful response contains data, meta and links. An error response contains error and never data.
Legacy API
Integrations built on the original system can keep using the /api/consent-report and /api/consent-configs endpoints. They are described at the end of this page. Build new integrations on v2.
2. Authentication
You generate the token in the dashboard under Account → API access and send it in the Authorization header as a bearer token. An account always has at most one token.
How to send the token
GET /api/v2/licenses HTTP/1.1
Host: cookies-spravne.cz
Authorization: Bearer cs_2f9Ktz...Token properties
| Issued in | the dashboard, Account → API access |
|---|---|
| Format | cs_ followed by 48 characters, 51 in total |
| Number | one token per account |
| Scope | licenses owned by the account and licenses shared with it |
| Transport | only the Authorization header; a ?token= parameter is not read |
The token is shown only once, when it is generated. After that the dashboard shows only the last four characters, the creation date and the date of last use. The token is meant for server-to-server communication; never put it in code that runs in a browser.
Managing the token
- Generate token creates a new token and shows it once.
- Regenerate token immediately replaces the existing one. An integration using the old token stops working.
- Revoke token closes access immediately.
The Last used field is your warning light. If you do not use the API and it still shows recent use, the token has probably leaked. Generate a new one.
Invalid or missing token
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="cookiesspravne", error="invalid_token"
Cache-Control: no-store
Content-Type: application/json
{"error":{"code":"invalid_token","message":"The API token is missing, malformed or no longer valid."}}
3. GET /api/v2/licenses
Returns all licenses your token can reach, sorted by id. No parameters and no pagination. This is where you get the id you then use as license_id when exporting consents.
Request
curl -sS -H "Authorization: Bearer $TOKEN" \
"https://cookies-spravne.cz/api/v2/licenses"Response
{
"data": [
{ "id": 1, "domain": "example.cz", "is_active": true, "created_at": "2025-04-01T09:12:00Z" },
{ "id": 7, "domain": "eshop.cz", "is_active": false, "created_at": "2026-01-20T14:03:00Z" }
]
}Record fields
| Field | Type | Meaning |
|---|---|---|
id | int | The value of the license_id parameter for the /consents endpoint. |
domain | string | The domain the license is valid for. |
is_active | bool | Whether the license is running. An inactive license remains readable, including its whole consent history. |
created_at | string or null | When the license was created. |
The license key the bar authenticates with is not shown in the response. Neither is the plan or the billing status.
4. GET /api/v2/consents
A paginated export of one license's consents. The only required parameter is license_id; the rest have sensible defaults.
Parameters
| Parameter | Required | Values | Default |
|---|---|---|---|
license_id | yes | int | – |
limit | no | 1 to 10000 | 100 |
order_by | no | created_at, updated_at | created_at |
order | no | asc, desc | asc |
from | no | Y-m-d or ISO 8601 | – |
to | no | Y-m-d or ISO 8601 | – |
cursor | no | opaque string | – |
How parameters are validated
license_idmust be a number. Missing, empty or non-numeric returns400 invalid_request. Syntactically valid but without access to the license returns404 license_not_found. The API does not distinguish between “does not exist” and “you have no access”.limitthat is non-numeric returns400. A numeric value is clamped to the range 1 to 10000:limit=50000returns 10000 rows,limit=0returns one. The value used is always in the response in themeta.limitfield.order_byandorderoutside the allowed values, for exampleorder_by=id, return400 invalid_request.fromandtoaccept either a bare dateY-m-dor a full ISO 8601 value, which is used exactly.from=2026-06-01means2026-06-01T00:00:00Z,to=2026-06-30means2026-06-30T23:59:59Z. Both bounds are inclusive. A non-existent date such as2026-02-31returns400 invalid_date.fromlater thantoreturns an empty page.- The date range applies to the column chosen in
order_by. Withorder_by=updated_at,fromtherefore means “changed since”, not “created since”.
Response
curl -sS -H "Authorization: Bearer $TOKEN" \
"https://cookies-spravne.cz/api/v2/consents?license_id=1&limit=100"{
"data": [
{
"id": "9c1f7a2e-5b3d-4e18-9a6c-2f0d81b7e4aa",
"uid": "aB3xY7kQ",
"license_id": 1,
"created_at": "2026-06-01T10:00:00Z",
"updated_at": "2026-06-04T08:31:00Z",
"necessary": true,
"analytics": false,
"marketing": false,
"ad_user_data": false,
"ad_personalization": false,
"was_shown": true,
"was_dismissed": false
}
],
"meta": {
"count": 100,
"limit": 100,
"order": "asc",
"order_by": "created_at",
"has_more": true,
"next_cursor": "v1.eyJ0IjoiMjAyNi0wNi0wMSAxMDowMDowMCJ9"
},
"links": {
"next": "https://cookies-spravne.cz/api/v2/consents?license_id=1&limit=100&cursor=v1.eyJ0IjoiMjAyNi0wNi0wMSAxMDowMDowMCJ9"
}
}Record fields
| Field | Type | Meaning |
|---|---|---|
id | string (UUID) | The record's primary key. It does not change when the choice is overwritten. |
uid | string or null | The user identifier. Used to look up a consent; the visitor sees it in the bar and can report it to you. |
license_id | int | The license the record belongs to. |
created_at | string or null | The first record for the license. |
updated_at | string or null | The last write for the license. |
necessary | bool | Necessary cookies. |
analytics | bool | Analytics cookies. |
marketing | bool | Marketing cookies. |
ad_user_data | bool | Google Consent Mode, the ad_user_data signal. |
ad_personalization | bool | Google Consent Mode, the ad_personalization signal. |
was_shown | bool | The bar was shown to the visitor. |
was_dismissed | bool | The bar was closed with the cross, without choosing categories. |
The internal reference to the configuration version the visitor saw and the flag for imports from the older system are not exported. If you need them, write to us at podpora@cookies-spravne.cz.
The meta and links fields
| Field | Type | Meaning |
|---|---|---|
meta.count | int | The number of records in data. |
meta.limit | int | The limit after clamping to the allowed range. |
meta.order | string | The sort direction. |
meta.order_by | string | The sort column used. |
meta.has_more | bool | Another page exists. |
meta.next_cursor | string or null | The cursor for the next page. |
links.next | string or null | The original query with all parameters and the cursor added. |
links.next is built from the query string of the incoming request, with cursor added. It therefore also keeps the limit, order_by, from and to the pagination started with.
The consent data model
The bar cares about the visitor's current decision, not their history. A write is therefore an upsert on the license_id and uid pair: one row per visitor, overwritten on every change of choice.
The was_shown and was_dismissed flags are in the export because without them three different situations look the same, with all categories false:
was_shown | was_dismissed | What happened |
|---|---|---|
true | false | The visitor saw the bar and rejected all categories. |
true | true | The visitor closed the bar without setting categories. |
false | false | The bar was not rendered for the visitor. |
5. Sorting and pagination
For regular data imports, always sort by updated_at. Pagination uses a cursor that remembers the filters of the original query and does not expire.
Sorting and data freshness
For regular imports use order_by=updated_at. When sorting by another field, updated consents of existing visitors may not get into the export and your data will be out of date.
The reason is the data model: a consent is a state, not an event. When a visitor changes their mind, their row is overwritten but created_at stays old. When sorting by created_at, such a record stays in its old position and your incremental import misses it.
Pagination
One page returns at most 10,000 records. Further pages are read with the cursor parameter, whose value you will find in the response in meta.next_cursor. links.next gives you the full address of the next page straight away, with all the original parameters.
GET /api/v2/consents?license_id=1&limit=2
{ "data": [ … 2 rows … ],
"meta": { "count": 2, "limit": 2, "has_more": true, "next_cursor": "v1.eyJ0IjoiMjAyNi0w…" },
"links": { "next": "…&cursor=v1.eyJ0IjoiMjAyNi0w…" } }GET /api/v2/consents?license_id=1&limit=2&cursor=v1.eyJ0IjoiMjAyNi0w…
{ "data": [ … 2 rows … ],
"meta": { "count": 2, "limit": 2, "has_more": true, "next_cursor": "v1.eyJ0IjoiMjAyNi0x…" },
"links": { "next": "…&cursor=v1.eyJ0IjoiMjAyNi0x…" } }GET /api/v2/consents?license_id=1&limit=2&cursor=v1.eyJ0IjoiMjAyNi0x…
{ "data": [ … 1 row … ],
"meta": { "count": 1, "limit": 2, "has_more": false, "next_cursor": null },
"links": { "next": null } }Another page exists as long as has_more === true and links.next !== null. The has_more value is derived from loading one row more than is returned.
Cursor properties
- It keeps the filters of the original query. Using it with a different
license_id,order,order_by,fromortoreturns400 cursor_mismatch. - An invalid cursor returns
400 invalid_cursor. - It does not expire. It points to a position in the ordering, not to a query result.
6. Limits and errors
With a valid token you have 120 requests per minute. Every error response has the same shape: an error object with code and message fields.
Limits
| Key | Limit |
|---|---|
| Account with a valid token | 120 requests per minute |
| IP address without a valid token | 10 requests per minute |
With a valid token, the limit is keyed to the account, not the IP. Servers behind one NAT address therefore do not share a budget, but two machines of the same account do. With limit=10000 the ceiling corresponds to 1,200,000 rows per minute.
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
{"error":{"code":"rate_limited","message":"Too many requests."}}The 429 response carries no Retry-After header and no X-RateLimit-* headers. The window is a fixed minute, so it is enough to wait and repeat the request.
Error response shape
All errors, including 500, have the same shape:
{
"error": {
"code": "invalid_token",
"message": "The API token is missing, malformed or no longer valid."
}
}Error codes
| HTTP | code | When it occurs |
|---|---|---|
400 | invalid_request | Missing or invalid license_id, order_by, order or limit. |
400 | invalid_date | from or to is neither Y-m-d nor ISO 8601, or is a non-existent date. |
400 | invalid_cursor | The cursor cannot be decoded. |
400 | cursor_mismatch | The cursor was created with different filters than the current query carries. |
401 | invalid_token | The token is missing, invalid or has been revoked. |
404 | license_not_found | The license does not exist, or you have no access to it. |
429 | rate_limited | The request limit has been exceeded. |
500 | server_error | An error on the API side. |
Example
GET /api/v2/consents?license_id=abc
HTTP/1.1 400 Bad Request
{"error":{"code":"invalid_request","message":"A numeric license_id is required."}}
7. Example calls
Three ready-to-use samples: a full export to JSONL, incremental reading of changes and loading a single page.
Full export to JSONL
Bash with curl and jq. It goes through all pages via links.next as long as another one exists.
#!/usr/bin/env bash
set -euo pipefail
TOKEN="cs_…"
URL="https://cookies-spravne.cz/api/v2/consents?license_id=1&limit=1000"
while [ -n "$URL" ]; do
BODY=$(curl -sS -H "Authorization: Bearer $TOKEN" "$URL")
echo "$BODY" | jq -c '.data[]' >> consents.jsonl
URL=$(echo "$BODY" | jq -r '.links.next // ""')
doneIncremental reading of changes
JavaScript. It reads records changed since a given moment, sorts by updated_at and can wait when the limit is exceeded.
const TOKEN = process.env.COOKIES_SPRAVNE_TOKEN;
const BASE = 'https://cookies-spravne.cz/api/v2/consents';
async function* changes(licenseId, since) {
let url = `${BASE}?license_id=${licenseId}&order_by=updated_at&order=asc&limit=1000`
+ (since ? `&from=${encodeURIComponent(since)}` : '');
while (url) {
const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
if (res.status === 429) {
await new Promise(resolve => setTimeout(resolve, 60_000));
continue;
}
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${res.status} ${error.code}: ${error.message}`);
}
const { data, links } = await res.json();
yield* data;
url = links.next;
}
}A single page in PHP
<?php
$token = getenv('COOKIES_SPRAVNE_TOKEN');
$context = stream_context_create([
'http' => [
'method' => 'GET',
'header' => "Authorization: Bearer {$token}\r\n",
],
]);
$url = 'https://cookies-spravne.cz/api/v2/consents?' . http_build_query([
'license_id' => 1,
'order_by' => 'updated_at',
'order' => 'asc',
'limit' => 1000,
]);
$page = json_decode(file_get_contents($url, false, $context), true);For regular imports, store the time of the last successful run and pass it next time in the from parameter together with order_by=updated_at. That way you download only what has changed.
8. Legacy API (v1)
The original system's endpoints remain available at the same addresses and return data in the same format, so existing integrations keep working without changes.
Build new integrations on API v2 described above. This part is here only for backward compatibility.
Legacy API authentication
The legacy API is not authenticated with the API token but with the legacy API key you received when access was activated in the original system. If we hold it for your account, you will find it in the dashboard under Account → API access. If you do not see it there, write to us at podpora@cookies-spravne.cz.
The v2 API token does not work on the legacy endpoints, and the legacy API key does not work on v2.
The key is sent in the Authorization header. For backward compatibility the Authentication header, which the original documentation gave, is also accepted:
Authorization: Bearer xxxxxxxxx
Authentication: Bearer xxxxxxxxxResponses are always application/json. Errors have the same shape and codes as v2, and the same limits apply.
GET /api/consent-report
Returns users' consents. Supports pagination and filtering.
| Parameter | Required | Meaning |
|---|---|---|
key | yes | The license key of the domain you want the report for. The license must belong to your account or be shared with it, otherwise 404 license_not_found. |
from | no | A Y-m-d date. On its own it forms an interval from that day until today; the given day is part of the interval. |
to | no | A Y-m-d date. On its own it forms an interval from the beginning until that day; the given day is not part of the interval. |
limit | no | The number of records per page. Default 1000, maximum 10000. |
page | no | The page number; the first is 1. |
Combining from and to creates an interval that includes from and excludes to. For example ?from=2022-01-01&to=2022-02-01 returns the whole of January 2022. Filtering is by the date consent was first given, date_of_consent, and records are sorted by it in ascending order.
Response headers
x-document-count | The total number of records matching the key, from and to filters. |
|---|---|
x-total-pages | The total number of pages. Depends on the limit parameter. |
Example
curl -sS -H "Authorization: Bearer $LEGACY_KEY" \
"https://cookies-spravne.cz/api/consent-report?key=bstl7sn95ns7sno97s1q&from=2022-01-01&to=2022-02-01&page=1"[
{
"_id": { "$oid": "9c1f7a2e-5b3d-4e18-9a6c-2f0d81b7e4aa" },
"uid": "CxQ25iVbozc2o82J4RcuX1Lv5DoXpO",
"key": "bstl7sn95ns7sno97s1q",
"necessary": "1",
"analytics": "0",
"marketing": "1",
"config": { "$oid": "4812" },
"date_of_consent": "1644842294"
}
]| Field | Type | Meaning |
|---|---|---|
_id.$oid | string | The record identifier. |
uid | string or null | A unique user ID by which the user can be identified. |
key | string | The domain's license key. |
necessary | string | Always "1". |
analytics | string | "1" or "0" according to the consent given. |
marketing | string | "1" or "0" according to the consent given. |
config | object or null | The $oid of the configuration from /api/consent-configs that the user saw. |
date_of_consent | string | A Unix timestamp of when the user first gave consent. |
GET /api/consent-configs
Returns all versions of the bar's settings and texts that were shown to visitors. Consents may be tied to a different wording of the bar, so each consent has a reference to a specific version in the config field. The only parameter is the required key, with the same meaning as above.
[
{
"_id": { "$oid": "4812" },
"modified": 1644236417,
"texts": {
"cs": {
"reject_all": "Odmítnout",
"cookie_policy": "Více o cookies",
"iframe_no_cookies": "Pro zobrazení tohoto obsahu povolte prosím ukládání cookies."
}
}
}
]_id.$oid matches the config.$oid value in the consent listing, modified is a Unix timestamp of when this version was first shown, and texts contains the bar's texts by language.
Differences from the original system
- A consent is one record per visitor. When a visitor changes their choice, the record is overwritten and
date_of_consentremains the date of the first consent. To track changes, use API v2 withorder_by=updated_at. - Errors return JSON with an
errorobject. For example, a missingkeyreturns400 invalid_request.