# Campaigns (/api/campaigns) All endpoints require `Authorization: Bearer ck_...`. ## List campaigns [#list-campaigns] `GET /v1/campaigns` **Scope:** `campaigns:list` ### Query parameters [#query-parameters] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `page` | integer | `1` | Page number (min 1) | | `limit` | integer | `10` | One of `1`, `5`, `10`, `25`, `50`, `100` | | `active` | boolean | — | Filter by active status | | `name` | string | — | Max 255 chars | | `network` | string | — | `facebook`, `google`, `tiktok`, `kwai`, `taboola`, `pinterest`, `yandex`, `mgid`, `outbrain`, `sms`, `revcontent`, `adskeeper`, `newsbreak` | ### Response `200` [#response-200] ```json { "count": 0, "data": [] } ``` Each item in `data` is a [CampaignShort](/reference/schemas#campaignshort). The `in_review` field mirrors `filters.deny_always`. ### Example [#example] ```bash curl -H "Authorization: Bearer ck_YOUR_KEY" \ "https://api.cloakup.me/v1/campaigns?page=1&limit=10" ``` --- ## Create campaign [#create-campaign] `POST /v1/campaigns` **Scope:** `campaigns:create` ### Request body [#request-body] [CampaignBody](/reference/schemas#campaignbody). See [Campaign create](/guides/campaign-create) for defaults and minimal example. ### Response `201` [#response-201] [CampaignFull](/reference/schemas#campaignfull) --- ## Get campaign [#get-campaign] `GET /v1/campaigns/:id` **Scope:** `campaigns:show` ### Path parameters [#path-parameters] | Name | Type | Description | |------|------|-------------| | `id` | integer | Campaign ID | ### Response `200` [#response-200-1] [CampaignFull](/reference/schemas#campaignfull) — includes `is_test_mode` (true if caller IP is in `filters.whitelist`). --- ## Update campaign [#update-campaign] `PUT /v1/campaigns/:id` **Scope:** `campaigns:update` Full replace — same schema as create. **Not** a partial PATCH. ### Request body [#request-body-1] [CampaignBody](/reference/schemas#campaignbody) ### Response `200` [#response-200-2] [CampaignFull](/reference/schemas#campaignfull) --- ## Delete campaign [#delete-campaign] `DELETE /v1/campaigns/:id` **Scope:** `campaigns:delete` Soft-deletes the campaign. ### Response `204` [#response-204] HTTP status is `204`. The handler may attach a JSON body `{ "message": "..." }` (Express quirk). Treat success as status `204`. --- ## Clone campaign [#clone-campaign] `POST /v1/campaigns/:id/clone` **Scope:** `campaigns:clone` No request body. ### Response `200` [#response-200-3] [CampaignFull](/reference/schemas#campaignfull) --- ## Toggle status [#toggle-status] `PUT /v1/campaigns/:id/toggle-status` **Scope:** `campaigns:toggle` Toggles the campaign `active` flag. No request body. ### Response `200` [#response-200-4] Empty body. Re-fetch with GET show if you need the updated `active` value. # Domains (/api/domains) All endpoints require `Authorization: Bearer ck_...`. ## List domains [#list-domains] `GET /v1/domains` **Scope:** `domains:list` ### Query parameters [#query-parameters] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `page` | integer | `1` | Page number (min 1) | | `limit` | integer | `100` | One of `1`, `5`, `10`, `25`, `50`, `100` | | `verified` | boolean | — | Filter by verification status | | `name` | string | — | Max 128 chars | ### Response `200` [#response-200] ```json { "count": 0, "data": [] } ``` Each item in `data` is a [Domain](/reference/schemas#domain). --- ## Create domain [#create-domain] `POST /v1/domains` **Scope:** `domains:create` Register a domain. **Subdomain required** (e.g. `track.example.com`). Rejected if `name` contains `http`, `www`, or `/`, or is not a valid subdomain hostname. ### Request body [#request-body] ```json { "name": "track.example.com" } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | yes | Subdomain hostname — no scheme, `www`, or path. Max 128 chars. | ### Response `201` [#response-201] ```json { "id": 1, "name": "track.example.com", "verified": false, "migrated": false } ``` --- ## Verify domain [#verify-domain] `PATCH /v1/domains/:id/verify` **Scope:** `domains:verify` Checks DNS/CNAME propagation and activates the certificate. No request body. **Rate limit:** 1 request per domain ID per 30 seconds (in addition to the global API key limit). Faster polling returns `429` and does not speed up verification. ### Path parameters [#path-parameters] | Name | Type | Description | |------|------|-------------| | `id` | integer | Domain ID | ### Response `200` [#response-200-1] ```json { "name": "track.example.com", "verified": true } ``` ### Polling for verification [#polling-for-verification] DNS/CNAME propagation and certificate activation can take several minutes. Poll until `verified: true` before using the domain in campaigns. Do not spam verify requests. Each call checks upstream infrastructure (Cloudflare). The API allows **one verify per domain ID every 30 seconds** — retrying faster returns `429` and does not help. Wait at least **30 seconds** between attempts on the same domain ID. A practical interval is **30–60 seconds**. --- ## Delete domain [#delete-domain] `DELETE /v1/domains/:id` **Scope:** `domains:delete` ### Response `204` [#response-204] Same quirk as campaign delete — status `204`, optional `{ "message": "..." }` body. Treat status as success. # Authentication (/authentication) ## API key format [#api-key-format] | Item | Value | |------|-------| | Prefix | `ck_` | | Secret | 64 hex characters | | Header | `Authorization: Bearer ck_` | | Content-Type | `application/json` | Example: ```http GET /v1/campaigns?page=1&limit=10 HTTP/1.1 Host: api.cloakup.me Authorization: Bearer ck_0123456789abcdef... Accept: application/json ``` Create keys in the [dashboard](https://app.cloakup.me/settings/api-keys). Keys are shown only once at creation. ## Prerequisites (every `/v1` request) [#prerequisites-every-v1-request] Middleware applied to all `/v1` routes: 1. Valid API key (`Authorization: Bearer ck_...`) 2. Plan must have **API keys enabled** (`plan.api_keys_enabled`) 3. Active **cloakup** subscription 4. Route-specific **scope** (see each endpoint) 5. Rate limit (see below) Failure at any step returns `401` or `403`. See [Errors](/errors). ## Rate limits [#rate-limits] Every `/v1` route enforces a global limit of **120 requests/minute** per API key. Responses include standard `RateLimit-*` headers when applicable. Some endpoints apply stricter limits on top of the global one. ### Endpoint-specific limits [#endpoint-specific-limits] | Endpoint | Limit | |----------|-------| | All `/v1/*` | 120 req/min per API key | | `PATCH /v1/domains/:id/verify` | 1 req per domain ID per 30 seconds | When you exceed a limit, the API returns `429` with code `TOO_MANY_REQUESTS`. See [Errors](/errors). ## Scopes [#scopes] Each key has one or more scopes. The endpoint rejects requests when the key lacks the required scope. | Scope | Description | |-------|-------------| | `campaigns:list` | List campaigns | | `campaigns:create` | Create campaign | | `campaigns:show` | Get campaign by ID | | `campaigns:update` | Update campaign | | `campaigns:delete` | Delete campaign | | `campaigns:clone` | Clone campaign | | `campaigns:toggle` | Toggle campaign active status | | `domains:list` | List domains | | `domains:create` | Register domain | | `domains:verify` | Verify domain DNS/certificate | | `domains:delete` | Delete domain | Presets in the dashboard: - **Read only** — `campaigns:list`, `campaigns:show`, `domains:list` - **Full access** — all scopes above ## IDs [#ids] Resource IDs are numeric (`bigint`). JSON may serialize them as numbers or strings depending on the serializer. # Errors (/errors) All application errors return JSON: ```json { "message": "string", "action": "string", "code": "string", "data": {} } ``` | Field | Required | Description | |-------|----------|-------------| | `message` | yes | Human-readable error | | `action` | yes | Suggested next step | | `code` | no | Machine-readable code | | `data` | no | Extra context (e.g. validation field errors) | ## HTTP status codes [#http-status-codes] | HTTP | Meaning | |------|---------| | `401` | Missing, invalid, expired, or revoked API key | | `403` | Missing scope, API keys disabled on plan, or inactive subscription | | `404` | Resource not found (or not owned by your account) | | `422` | Validation error (`data` contains field errors) | | `429` | Rate limit exceeded — global 120 req/min per key, or 1 verify per domain ID per 30s on `PATCH /v1/domains/:id/verify`. Check `RateLimit-*` response headers. | ## Validation errors (`422`) [#validation-errors-422] When request body or query fails validation, `data` is an object keyed by field name: ```json { "message": "Validation failed", "action": "Fix the fields in data", "data": { "name": ["Name is required"] } } ``` Campaign filter format rules are documented in [Campaign filters](/guides/campaign-filters). # Campaign create (/guides/campaign-create) Omitted fields are filled server-side. **Do not copy the full `filters` object from GET responses** — only send what you need. ## Create defaults [#create-defaults] | Field | Default when omitted | |-------|----------------------| | `active` | `true` | | `filters.facebook`, `google`, `tiktok`, `kwai`, `taboola`, `pinterest`, `yandex`, `mgid`, `outbrain`, `sms`, `revcontent`, `adskeeper`, `newsbreak` | `false` | | `filters.adspy`, `bots`, `proxy`, `cloakup_ai` | `true` | | `filters.deny_always` | `false` | | `filters.state` | `{ "allow": true, "states": [] }` | | `filters.city` | `{ "allow": true, "cities": [] }` | | `filters.network_type` | `{ "allow": true, "types": [] }` | | `filters.device` | `{ "allow": true, "devices": [] }` | | `filters.geolocation.allow` | `true` | | `filters.referer.allow` | `true` | | `filters.query.allow` | `true` | | `filters.query.remove_params` | `false` | | `filters.domain.allow`, `browser.allow`, `browser_language.allow`, `isp.allow`, `os.allow`, `user_agent.allow` | `true` | **Still required** (no default for the key itself): `name`, `mode`, `pages`, `domain`, `alias`, and in `filters`: `geolocation`, `referer`, `query`, `domain`, `browser`, `browser_language`, `isp`, `os`, `user_agent`, `blacklist`. Inside required objects you may omit nested defaults (e.g. send `"domain": {}`, `"referer": { "block_null": true, "domains": [] }`). To target a single ad network, send only that network as `true` (e.g. `"facebook": true`). All other networks default to `false`. ## Pages / offers business rules [#pages--offers-business-rules] Returns `422` if violated: | Rule | Detail | |------|--------| | Offer `share` | Within each segmentation group, `share` values must sum to **exactly 100**. Group key = sorted `segmentation.country` join, or `"fallback"` when country list empty/missing. | | Fallback offer | At least **one** offer must have no country segmentation (empty/missing `segmentation.country`). | | `mode: "basic"` | Exactly **one** ad-network flag `true`, and `filters.device.devices` must have **≥ 1** entry. | | `mode: "advanced"` | Zero or one ad network; empty `device.devices` allowed. | ## Minimal example [#minimal-example] Production-safe body with only non-default values: ```json { "name": "My Campaign", "mode": "advanced", "domain": null, "alias": null, "pages": { "white": { "type": "content", "content": "safe.html" }, "offers": [{ "type": "content", "content": "offer.html", "share": 100 }] }, "filters": { "facebook": true, "geolocation": { "countries": ["BR"] }, "referer": { "block_null": true, "domains": [] }, "query": { "condition": "some", "params": {}, "rules": {} }, "domain": {}, "browser": {}, "browser_language": { "languages": ["pt-br"] }, "isp": {}, "os": {}, "user_agent": {}, "blacklist": [] } } ``` Omitted here (defaults apply): `active`, all other ad networks, `deny_always` (`false`), `bots`, `proxy`, `adspy`, `cloakup_ai`, `state`, `city`, `network_type`, `device`, and every `allow` / `remove_params` flag. ## Example with query params [#example-with-query-params] Same production-safe body, plus URL param matching. This campaign allows traffic when the URL has `utm_source=google` **or** the custom token `asiudhasudhasd=123u1basj`. Visitor URL that matches: ```text https://track.example.com/slug?utm_source=google&asiudhasudhasd=123u1basj ``` `"asiudhasudhasd": "equals"` is the **comparison**, not the value. In `params` the value is an array: `"asiudhasudhasd": ["123u1basj"]`. A plain string (`"asiudhasudhasd": "123u1basj"`) is invalid. See [Campaign filters](/guides/campaign-filters#url-query-params-filtersquery) for `contains`, `starts_with`, `ends_with`, `every`, and more copy-paste examples. ```json { "name": "My Campaign", "mode": "advanced", "domain": null, "alias": null, "pages": { "white": { "type": "content", "content": "safe.html" }, "offers": [{ "type": "content", "content": "offer.html", "share": 100 }] }, "filters": { "facebook": true, "geolocation": { "countries": ["BR"] }, "referer": { "block_null": true, "domains": [] }, "query": { "condition": "some", "params": { "utm_source": ["google"], "asiudhasudhasd": ["123u1basj"] }, "rules": { "utm_source": "equals", "asiudhasudhasd": "equals" } }, "domain": {}, "browser": {}, "browser_language": { "languages": ["pt-br"] }, "isp": {}, "os": {}, "user_agent": {}, "blacklist": [] } } ``` For test/review mode only, set `"deny_always": true`. See [Production safety](/guides/production-safety). ## Update (PUT) [#update-put] `PUT /v1/campaigns/:id` uses the same full body as create. This is **not** a partial PATCH — send the complete `CampaignBody`. # Campaign filters (/guides/campaign-filters) Create/update returns `422` if a value does not match the format or is not in the API allowlist. Do not guess country names or free-form locale strings. ## Allowed values [#allowed-values] | Field | Type | Max items | Format | |-------|------|-----------|--------| | `filters.geolocation.countries` | `string[]` | 256 | ISO 3166-1 alpha-2, **uppercase** | | `filters.browser_language.languages` | `string[]` | 255 | IETF language tag, **all lowercase** (`xx` or `xx-yy`) | | `filters.state.states` | `string[]` | 100 | Place name (see below) | | `filters.city.cities` | `string[]` | 100 | Place name (see below) | | `pages.offers[].segmentation.country` | `string[]` | 256 | Same as `filters.geolocation.countries` | | `filters.device.devices` | `string[]` | — | `desktop`, `smartphone`, `tablet`, `unknown` | | `filters.os.os` | `string[]` | — | Lowercase detector name (see [OS](#os-filtersos)) | | `filters.browser.browsers` | `string[]` | — | Lowercase detector name (see [Browser](#browser-filtersbrowser)) | | `filters.isp.isps` | `string[]` | — | Substring of the GeoIP ISP name | | `filters.blacklist` / `filters.whitelist` | `string[]` | — | Exact IP string | ### Country codes [#country-codes] Used in `filters.geolocation.countries` and `pages.offers[].segmentation.country`. | Rule | Value | |------|-------| | Format | Exactly 2 uppercase letters ([ISO 3166-1 alpha-2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2)) | | Examples | `"BR"`, `"US"`, `"PT"` | | Invalid | `"br"`, `"Brazil"`, `"BRA"`, `"brasil"` | ### Browser language codes [#browser-language-codes] Used in `filters.browser_language.languages`. | Rule | Value | |------|-------| | Format | Lowercase [IETF language tag](https://en.wikipedia.org/wiki/IETF_language_tag): `xx` or `xx-yy` (hyphen, not underscore) | | Examples | `"pt-br"`, `"en"`, `"es"` | | Invalid | `"pt-BR"`, `"portuguese"`, `"pt_BR"` | ### State and city names [#state-and-city-names] Used in `filters.state.states` and `filters.city.cities`. | Rule | Value | |------|-------| | Length | 1–100 characters (trimmed) | | Allowed chars | Unicode letters, spaces, `'`, `-`, `.` | | Must start with | A letter | | Examples | `"São Paulo"`, `"California"`, `"St. Louis"` | | Invalid | `"123"`, `""`, `"City@Name"` | Free-text GeoIP place names — not ISO codes. ## URL query params (`filters.query`) [#url-query-params-filtersquery] Cloakup matches the query string after `?` in the visitor URL against `filters.query`. You always send **two objects with the same keys**: | Object | What it stores | |--------|----------------| | `params` | Param **name → values** to match (always a `string[]`, never a plain string) | | `rules` | Param **name → how to compare** (`equals`, `contains`, `starts_with`, `ends_with`) | `"asiudhasudhasd": "equals"` does **not** set the value. It only says: compare the URL param named `asiudhasudhasd` with **exact match**. The value lives in `params` as an array: `"asiudhasudhasd": ["123u1basj"]`. Visitor URL: ```text https://track.example.com/slug?asiudhasudhasd=123u1basj ``` Matching config: ```json "query": { "allow": true, "remove_params": false, "condition": "some", "params": { "asiudhasudhasd": ["123u1basj"] }, "rules": { "asiudhasudhasd": "equals" } } ``` Every key in `params` needs the same key in `rules`. Empty `params: {}` and `rules: {}` means **no URL param filtering**. | Field | Type | Description | |-------|------|-------------| | `params` | `Record` | Param name → list of values in the URL | | `rules` | `Record` | Per-param comparison. Not the value itself | | `condition` | `"some"` \| `"every"` | `some` = at least one param must match; `every` = all must match | | `allow` | `boolean` | `true` = allow when params match; `false` = block when params match | | `remove_params` | `boolean` | Strip matched params from the URL before redirect | ### Match rules [#match-rules] The value in `rules` is the operator. The value in `params` is what you compare against. | Rule | Meaning | `params` value | Matches | Does not match | |------|---------|----------------|---------|----------------| | `equals` | Exact value | `["google"]` | `?utm_source=google` | `?utm_source=Google`, `?utm_source=googleads` | | `contains` | Substring anywhere | `["google"]` | `?utm_source=googleads` | `?utm_source=facebook` | | `starts_with` | Prefix | `["Cjw"]` | `?gclid=CjwKEAjabc` | `?gclid=xxCjw` | | `ends_with` | Suffix | `["-br"]` | `?utm_campaign=promo-br` | `?utm_campaign=br-promo` | ### Wildcard (`*`) [#wildcard-] `"*"` is a special **value** in `params` (not a rule, not a glob). If that key is present in the URL, the checker accepts **any** value and skips `equals` / `contains` / `starts_with` / `ends_with`. | URL | Config | Result | |-----|--------|--------| | `?fbclid=IwAR123abc` | `"fbclid": ["*"]` | Match — key exists | | `?fbclid=` | `"fbclid": ["*"]` | Match — key exists, even empty | | `?utm_source=google` | `"fbclid": ["*"]` | No match — `fbclid` missing | | `?utm_source=googleads` | `"utm_source": ["goo*"]` | No match — `"goo*"` is a literal string, not a pattern | You still send a `rules` entry for that key (use `"equals"`). The operator is ignored when `"*"` is in the values list. ```json "params": { "fbclid": ["*"] }, "rules": { "fbclid": "equals" } ``` Do not mix `"*"` with other values on the same key (`["*", "google"]`). `"*"` already matches any value, so the extra entries never run. ### Examples [#examples] URL: `?asiudhasudhasd=123u1basj` The visitor must send this exact token. Common for a secret query key you append to the ad URL. ```json "query": { "allow": true, "remove_params": false, "condition": "some", "params": { "asiudhasudhasd": ["123u1basj"] }, "rules": { "asiudhasudhasd": "equals" } } ``` URL: `?utm_source=google` ```json "query": { "allow": true, "remove_params": false, "condition": "some", "params": { "utm_source": ["google"] }, "rules": { "utm_source": "equals" } } ``` URL: `?utm_source=google&asiudhasudhasd=123u1basj&utm_campaign=black-friday` `condition: "some"` — at least **one** of these params must match. ```json "query": { "allow": true, "remove_params": false, "condition": "some", "params": { "utm_source": ["google"], "asiudhasudhasd": ["123u1basj"], "utm_campaign": ["black-friday"] }, "rules": { "utm_source": "equals", "asiudhasudhasd": "equals", "utm_campaign": "equals" } } ``` Same keys as above, but the visitor must match **all** of them. URL that matches: `?utm_source=google&asiudhasudhasd=123u1basj` URL that does **not** match: `?utm_source=google` (missing token) ```json "query": { "allow": true, "remove_params": false, "condition": "every", "params": { "utm_source": ["google"], "asiudhasudhasd": ["123u1basj"] }, "rules": { "utm_source": "equals", "asiudhasudhasd": "equals" } } ``` URL: `?utm_source=google` **or** `?utm_source=facebook` Values are always an array. Do not send `"utm_source": "google"`. ```json "query": { "allow": true, "remove_params": false, "condition": "some", "params": { "utm_source": ["google", "facebook"] }, "rules": { "utm_source": "equals" } } ``` URL: `?utm_content=summer-promo-br` matches because the value **contains** `promo`. ```json "query": { "allow": true, "remove_params": false, "condition": "some", "params": { "utm_content": ["promo"] }, "rules": { "utm_content": "contains" } } ``` URL: `?gclid=CjwKEAjabc123` matches because the value **starts with** `Cjw`. ```json "query": { "allow": true, "remove_params": false, "condition": "some", "params": { "gclid": ["Cjw"] }, "rules": { "gclid": "starts_with" } } ``` URL: `?utm_campaign=sales-br` matches because the value **ends with** `-br`. ```json "query": { "allow": true, "remove_params": false, "condition": "some", "params": { "utm_campaign": ["-br"] }, "rules": { "utm_campaign": "ends_with" } } ``` URL that matches: `?fbclid=IwAR123abc` (any value) URL that matches: `?fbclid=` (empty value still has the key) URL that does **not** match: `?utm_source=google` (`fbclid` missing) ```json "query": { "allow": true, "remove_params": false, "condition": "some", "params": { "fbclid": ["*"] }, "rules": { "fbclid": "equals" } } ``` URL that matches: `?asiudhasudhasd=anything` URL that does **not** match: no `asiudhasudhasd` in the query Use this when you only care that the secret param **name** is present, not the value. ```json "query": { "allow": true, "remove_params": false, "condition": "some", "params": { "asiudhasudhasd": ["*"] }, "rules": { "asiudhasudhasd": "equals" } } ``` Visitor must have `fbclid` (any value) **and** `utm_source=google`. URL that matches: `?fbclid=IwAR123&utm_source=google` URL that does **not** match: `?utm_source=google` (missing `fbclid`) URL that does **not** match: `?fbclid=IwAR123&utm_source=facebook` ```json "query": { "allow": true, "remove_params": false, "condition": "every", "params": { "fbclid": ["*"], "utm_source": ["google"] }, "rules": { "fbclid": "equals", "utm_source": "equals" } } ``` Match `utm_source=google`, then remove it from the URL before redirect so the destination does not see it. ```json "query": { "allow": true, "remove_params": true, "condition": "some", "params": { "utm_source": ["google"] }, "rules": { "utm_source": "equals" } } ``` Send `"utm_source": ["google"]`, never `"utm_source": "google"`. A plain string returns `422`. ## Referer (`filters.referer`) [#referer-filtersreferer] `filters.referer.domains` is a list of hostnames. Send the hostname only (no `https://`, no path). `www.` is stripped at match time. `*` in a referer domain matches **that subdomain slot**. Use `*.facebook.com` to allow every subdomain of `facebook.com`. | Config | Matches | Does not match | |--------|---------|----------------| | `"facebook.com"` | `https://facebook.com/foo`, `https://www.facebook.com` (`www.` is stripped) | `https://m.facebook.com` | | `"*.facebook.com"` | `https://m.facebook.com`, `https://l.facebook.com` | `https://facebook.com` (no subdomain) | `*.facebook.com` does **not** match the apex host `facebook.com`. Add both entries if you need apex + subdomains. `block_null: true` blocks visitors with no referer (direct access). ```json "referer": { "allow": true, "block_null": true, "domains": ["facebook.com", "*.facebook.com", "instagram.com"] } ``` Allow traffic that came from `facebook.com` only (not `m.facebook.com`). ```json "referer": { "allow": true, "block_null": true, "domains": ["facebook.com"] } ``` Allow `m.facebook.com`, `l.facebook.com`, `lm.facebook.com`, and any other subdomain. Does **not** match `facebook.com` itself. Add both if you need apex + subdomains. ```json "referer": { "allow": true, "block_null": true, "domains": ["facebook.com", "*.facebook.com"] } ``` ```json "referer": { "allow": true, "block_null": true, "domains": [ "facebook.com", "*.facebook.com", "instagram.com", "*.instagram.com", "google.com" ] } ``` ## Device (`filters.device`) [#device-filtersdevice] Empty `devices: []` means no device filtering. `allow: true` = only listed devices pass. `allow: false` = listed devices are blocked. | Value | What it matches | |-------|-----------------| | `smartphone` | Phones. Phablet is treated as smartphone | | `tablet` | Tablets | | `desktop` | Desktops and laptops | | `unknown` | Detector could not classify the device | ```json "device": { "allow": true, "devices": ["smartphone", "tablet"] } ``` Desktop visitors are blocked. ```json "device": { "allow": true, "devices": ["smartphone"] } ``` ```json "device": { "allow": false, "devices": ["desktop"] } ``` ```json "device": { "allow": true, "devices": ["smartphone", "tablet", "desktop"] } ``` ## OS (`filters.os`) [#os-filtersos] Empty `os: []` means no OS filtering. Names are **lowercase** as the device detector reports them. | Value | Matches | |-------|---------| | `android` | Android | | `ios` | iOS | | `windows` | Windows | | `mac` | macOS | | `gnu/linux` | Generic Linux desktop. Send this, not `"linux"` | | `ubuntu` | Ubuntu | | `unknown` | Detector could not classify the OS | | `other` | Any OS **not** in `windows`, `mac`, `gnu/linux`, `android`, `ios`, `ubuntu` | ```json "os": { "allow": true, "os": ["android", "ios"] } ``` ```json "os": { "allow": true, "os": ["android", "ios"] } ``` ```json "os": { "allow": true, "os": ["windows", "mac", "gnu/linux", "ubuntu"] } ``` Include `"other"` to also match uncommon OS names (Chrome OS, Tizen, and so on). ```json "os": { "allow": true, "os": ["android", "ios", "windows", "mac", "other"] } ``` ## Browser (`filters.browser`) [#browser-filtersbrowser] Empty `browsers: []` means no browser filtering. Match is **case-insensitive exact** on the detector name. Common values: `chrome`, `chrome mobile`, `chrome mobile ios`, `chrome webview`, `safari`, `mobile safari`, `firefox`, `microsoft edge`, `opera`, `samsung browser`, `miui browser`, `android browser`, `internet explorer`, `facebook`, `instagram app`, `whatsapp`, `baidu spark`. ```json "browser": { "allow": true, "browsers": ["chrome", "chrome mobile", "safari", "mobile safari"] } ``` ```json "browser": { "allow": true, "browsers": [ "chrome", "chrome mobile", "chrome mobile ios", "safari", "mobile safari" ] } ``` ```json "browser": { "allow": false, "browsers": ["facebook", "instagram app", "whatsapp"] } ``` ```json "browser": { "allow": true, "browsers": ["firefox"] } ``` ## ISP (`filters.isp`) [#isp-filtersisp] Empty `isps: []` means no ISP filtering. Match is a **case-insensitive substring** of the GeoIP ISP string — `"claro"` matches `Claro S.A.`. Use names as they appear in GeoIP (carriers, not brand slogans). ```json "isp": { "allow": true, "isps": ["Claro", "Vivo", "Oi", "TIM"] } ``` ```json "isp": { "allow": true, "isps": ["Claro", "Vivo", "Oi", "TIM"] } ``` ```json "isp": { "allow": false, "isps": ["Amazon", "Google", "DigitalOcean", "OVH", "Hetzner"] } ``` ```json "isp": { "allow": true, "isps": ["Comcast"] } ``` ## IP lists (`filters.blacklist` / `filters.whitelist`) [#ip-lists-filtersblacklist--filterswhitelist] Both are arrays of **exact IP strings** (no CIDR). Empty `[]` means no IP list. | Field | Effect | |-------|--------| | `blacklist` | That IP is blocked | | `whitelist` | That IP **always passes**, including when other filters would deny. `GET /v1/campaigns/:id` returns `is_test_mode: true` if your IP is in this list | ```json "blacklist": ["203.0.113.10", "198.51.100.22"], "whitelist": ["192.0.2.80"] ``` ```json "blacklist": ["203.0.113.10", "198.51.100.22"] ``` Add your IP to `whitelist` so you always see the offer while testing. ```json "whitelist": ["192.0.2.80"] ``` `192.0.2.80` still passes because whitelist wins. ```json "blacklist": ["203.0.113.10"], "whitelist": ["192.0.2.80"] ``` ## Required filter keys [#required-filter-keys] On create/update, these top-level keys under `filters` **must** be present: `geolocation`, `referer`, `query`, `domain`, `browser`, `browser_language`, `isp`, `os`, `user_agent`, `blacklist` Optional keys (defaults apply): ad-network flags, `deny_always`, `bots`, `proxy`, `adspy`, `cloakup_ai`, `state`, `city`, `network_type`, `device`, `whitelist`. Full JSON Schema: [Schemas → CampaignFilters](/reference/schemas#campaignfilters). # Production safety (/guides/production-safety) **For AI agents and scripts:** wrong defaults here burn ad spend — every visitor sees only the safe page. ## Review mode [#review-mode] | Field in request | Field in list response | Where in show/create response | Effect when `true` | |------------------|------------------------|-------------------------------|--------------------| | `filters.deny_always` | `in_review` | `filters.deny_always` | **Always shows the safe (white) page.** No offer traffic. Use only while testing. | `CampaignFull` does not expose a top-level `in_review` field. Use `filters.deny_always` on show/create responses. The list endpoint exposes `in_review` as a read-only mirror of `filters.deny_always`. ## Production default [#production-default] Omit `deny_always` or set `"deny_always": false`. **Never** copy `deny_always: true` from examples unless you explicitly want review/test mode. ## Test/review mode only [#testreview-mode-only] Set `"deny_always": true` to force the safe page for every visitor. The list response will show `in_review: true`. After testing, update the campaign with `"deny_always": false` or recreate without the flag before sending paid traffic. # Introduction (/introduction) The Cloakup API lets you manage campaigns and domains with API keys. All public endpoints live under `/v1` on `https://api.cloakup.me`. Create a key, send your first request, and follow the recommended workflow. API key format, scopes, rate limits, and prerequisites. List, create, update, clone, and toggle campaigns. Register, verify, list, and delete domains. ## Base URL [#base-url] ``` https://api.cloakup.me ``` No trailing slash. Set `CLOAKUP_API_URL` in clients and scripts. ## Use with AI [#use-with-ai] Each page has **Copy page**, **View as Markdown**, **Open in ChatGPT**, and **Open in Claude**. ChatGPT and Claude fetch the public Markdown URL, so this works after the docs are deployed. You can also load Markdown directly: - Index of all pages: [/llms.txt](/llms.txt) - Full dump: [/llms-full.txt](/llms-full.txt) - This page: [/raw/index.md](/raw/index.md) ## What you need [#what-you-need] 1. A Cloakup plan with **API keys enabled** 2. An API key created in **Settings → API Keys** in the [dashboard](https://app.cloakup.me) 3. A key with the right **scopes** for each endpoint API keys are shown only once at creation. Store them securely. Revoked or expired keys return `401`. ## Recommended workflow [#recommended-workflow] 1. Register a domain: `POST /v1/domains` → `PATCH /v1/domains/:id/verify` until `verified: true` 2. Create a campaign with the [minimal example](/guides/campaign-create#minimal-example) — keep `deny_always: false` or omit it 3. Confirm with `GET /v1/campaigns/:id` or list (`in_review` must be `false` for production) Read [Production safety](/guides/production-safety) before creating campaigns. Wrong defaults can send all traffic to the safe page. # Quickstart (/quickstart) ## Prerequisites [#prerequisites] - Active **cloakup** subscription with API keys enabled on your plan - An API key with at least `campaigns:list` (or the scopes you need) Create keys in the dashboard: [app.cloakup.me → Settings → API Keys](https://app.cloakup.me/settings/api-keys). ## Authenticate [#authenticate] Every `/v1` request requires: ```http Authorization: Bearer ck_ Content-Type: application/json ``` Keys start with `ck_` followed by 64 hex characters. ## First request [#first-request] ### List campaigns [#list-campaigns] ```bash curl -sS \ -H "Authorization: Bearer ck_YOUR_KEY" \ "https://api.cloakup.me/v1/campaigns?page=1&limit=10" ``` ### Check the response [#check-the-response] A `200` response includes `count` and `data` (array of campaigns). See [Errors](/errors) if you get `401`, `403`, or `429`. ### Create a domain (if needed) [#create-a-domain-if-needed] ```bash curl -sS -X POST \ -H "Authorization: Bearer ck_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"track.example.com"}' \ "https://api.cloakup.me/v1/domains" ``` ### Verify the domain [#verify-the-domain] ```bash curl -sS -X PATCH \ -H "Authorization: Bearer ck_YOUR_KEY" \ "https://api.cloakup.me/v1/domains/DOMAIN_ID/verify" ``` Repeat until `verified: true`. ### Create a campaign [#create-a-campaign] Use the [minimal production-safe body](/guides/campaign-create#minimal-example). Do **not** set `filters.deny_always: true` unless you are testing. ## Anti-patterns [#anti-patterns] Avoid these common mistakes — they cause `422` errors or broken production traffic. - Do **not** set `filters.deny_always: true` unless you explicitly want review/test mode - Do **not** copy the full `filters` object from a GET response into POST/PUT - Do **not** invent country codes or browser languages — follow [Campaign filters](/guides/campaign-filters) - Do **not** send `filters.query.params` values as a plain string — always `string[]` (e.g. `"utm_source": ["google"]`) - Do **not** put `http(s)://`, `www`, or `/` in domain `name` - Do **not** treat PUT as partial PATCH — send a **full** campaign body - Do **not** use `https://api.example.com` — use `https://api.cloakup.me` ## Environment variables [#environment-variables] | Variable | Description | |----------|-------------| | `CLOAKUP_API_URL` | Base URL without trailing slash (e.g. `https://api.cloakup.me`) | | `CLOAKUP_API_KEY` | Full key `ck_...` | ## Next steps [#next-steps] Which permission each endpoint needs. Defaults, business rules, and minimal example. Use Cloakup from Cursor or other MCP clients. Dashboard guide for creating and managing keys. # MCP (/reference/mcp) The Cloakup MCP server exposes API operations as tools for AI agents. ## Auth [#auth] Send your API key as a Bearer token. Keys must start with `ck_`. Create keys in the [dashboard](https://app.cloakup.me/settings/api-keys) — not via the MCP server. ``` Authorization: Bearer ck_ ``` ## Cursor configuration [#cursor-configuration] Add to `~/.cursor/mcp.json` (or project `.cursor/mcp.json`): ```json { "mcpServers": { "cloakup": { "url": "https://your-deployment.vercel.app/mcp", "headers": { "Authorization": "Bearer ck_YOUR_API_KEY" } } } } ``` Local development: ```json { "mcpServers": { "cloakup": { "url": "http://localhost:3000/mcp", "headers": { "Authorization": "Bearer ck_YOUR_API_KEY" } } } } ``` ## Environment [#environment] | Variable | Description | |----------|-------------| | `CLOAKUP_API_URL` | API base URL (e.g. `https://api.cloakup.me`) | ## Tools [#tools] ### Campaigns [#campaigns] - `cloakup_list_campaigns` - `cloakup_create_campaign` - `cloakup_get_campaign` - `cloakup_update_campaign` - `cloakup_delete_campaign` - `cloakup_clone_campaign` - `cloakup_toggle_campaign` ### Domains [#domains] - `cloakup_list_domains` - `cloakup_create_domain` - `cloakup_verify_domain` - `cloakup_delete_domain` ### Safety [#safety] - `cloakup_campaign_safety_notes` — read-only guidance on `deny_always` / `in_review`, query wildcards, and safe defaults See [Quick reference](/reference/quick-reference) for method/path mapping. # Quick reference (/reference/quick-reference) | Tool / operation | Method | Path | Scope | |------------------|--------|------|-------| | List campaigns | `GET` | `/v1/campaigns` | `campaigns:list` | | Create campaign | `POST` | `/v1/campaigns` | `campaigns:create` | | Get campaign | `GET` | `/v1/campaigns/:id` | `campaigns:show` | | Update campaign | `PUT` | `/v1/campaigns/:id` | `campaigns:update` | | Delete campaign | `DELETE` | `/v1/campaigns/:id` | `campaigns:delete` | | Clone campaign | `POST` | `/v1/campaigns/:id/clone` | `campaigns:clone` | | Toggle campaign | `PUT` | `/v1/campaigns/:id/toggle-status` | `campaigns:toggle` | | List domains | `GET` | `/v1/domains` | `domains:list` | | Create domain | `POST` | `/v1/domains` | `domains:create` | | Verify domain | `PATCH` | `/v1/domains/:id/verify` | `domains:verify` | | Delete domain | `DELETE` | `/v1/domains/:id` | `domains:delete` | MCP tool names use the `cloakup_` prefix (e.g. `cloakup_list_campaigns` → `GET /v1/campaigns`). # Schemas (/reference/schemas) JSON Schema-style definitions for API payloads. Validation rules match server-side `campaign-schema`. ## Error [#error] ```json { "$id": "Error", "type": "object", "properties": { "message": { "type": "string" }, "action": { "type": "string" }, "code": { "type": "string" }, "data": { "type": "object", "additionalProperties": true } }, "required": ["message", "action"] } ``` ## CampaignFilters [#campaignfilters] Used in create/update campaign body. See [Campaign filters](/guides/campaign-filters) for allowed values and [Campaign create](/guides/campaign-create) for defaults. **Must send (top-level under `filters`):** `geolocation`, `referer`, `query`, `domain`, `browser`, `browser_language`, `isp`, `os`, `user_agent`, `blacklist`. ```json { "$id": "CampaignFilters", "type": "object", "required": [ "geolocation", "referer", "query", "domain", "browser", "browser_language", "isp", "os", "user_agent", "blacklist" ], "properties": { "facebook": { "type": "boolean", "default": false }, "google": { "type": "boolean", "default": false }, "tiktok": { "type": "boolean", "default": false }, "kwai": { "type": "boolean", "default": false }, "taboola": { "type": "boolean", "default": false }, "pinterest": { "type": "boolean", "default": false }, "yandex": { "type": "boolean", "default": false }, "mgid": { "type": "boolean", "default": false }, "outbrain": { "type": "boolean", "default": false }, "sms": { "type": "boolean", "default": false }, "revcontent": { "type": "boolean", "default": false }, "adskeeper": { "type": "boolean", "default": false }, "newsbreak": { "type": "boolean", "default": false }, "adspy": { "type": "boolean", "default": true }, "deny_always": { "type": "boolean", "default": false, "description": "DANGER: true = in_review mode, always shows safe page." }, "bots": { "type": "boolean", "default": true }, "proxy": { "type": "boolean", "default": true }, "cloakup_ai": { "type": "boolean", "default": true }, "geolocation": { "type": "object", "properties": { "allow": { "type": "boolean", "default": true }, "countries": { "type": "array", "maxItems": 256, "items": { "type": "string", "pattern": "^[A-Z]{2}$" } } } }, "state": { "type": "object", "properties": { "allow": { "type": "boolean" }, "states": { "type": "array", "maxItems": 100, "items": { "type": "string" } } } }, "city": { "type": "object", "properties": { "allow": { "type": "boolean" }, "cities": { "type": "array", "maxItems": 100, "items": { "type": "string" } } } }, "network_type": { "type": "object", "properties": { "allow": { "type": "boolean" }, "types": { "type": "array", "items": { "enum": ["residential", "business", "wireless", "hosting", "unknown"] } } } }, "device": { "type": "object", "properties": { "allow": { "type": "boolean" }, "devices": { "type": "array", "items": { "enum": ["desktop", "smartphone", "tablet", "unknown"] } } } }, "referer": { "type": "object", "required": ["block_null", "domains"], "properties": { "block_null": { "type": "boolean" }, "allow": { "type": "boolean", "default": true }, "domains": { "type": "array", "items": { "type": "string", "maxLength": 255 } } } }, "query": { "type": "object", "required": ["params", "condition", "rules"], "properties": { "allow": { "type": "boolean", "default": true }, "remove_params": { "type": "boolean", "default": false }, "params": { "type": "object", "description": "Param name → string[]. Never a plain string. Example: { \"utm_source\": [\"google\"] }", "additionalProperties": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1, "maxLength": 255 } }, "examples": [{ "utm_source": ["google"] }] }, "condition": { "enum": ["some", "every"] }, "rules": { "type": "object", "additionalProperties": { "enum": ["equals", "contains", "starts_with", "ends_with"] } } } }, "domain": { "type": "object", "properties": { "allow": { "type": "boolean", "default": true }, "domains": { "type": "array", "items": { "type": "string", "maxLength": 255 } } } }, "browser": { "type": "object", "properties": { "allow": { "type": "boolean", "default": true }, "browsers": { "type": "array", "items": { "type": "string", "maxLength": 255 } } } }, "browser_language": { "type": "object", "properties": { "allow": { "type": "boolean", "default": true }, "languages": { "type": "array", "maxItems": 255, "items": { "type": "string", "pattern": "^[a-z]{2}(-[a-z]{2})?$" } } } }, "isp": { "type": "object", "properties": { "allow": { "type": "boolean", "default": true }, "isps": { "type": "array", "items": { "type": "string", "maxLength": 255 } } } }, "os": { "type": "object", "properties": { "allow": { "type": "boolean", "default": true }, "os": { "type": "array", "items": { "type": "string", "maxLength": 255 } } } }, "user_agent": { "type": "object", "properties": { "allow": { "type": "boolean", "default": true }, "user_agents": { "type": "array", "items": { "type": "string", "maxLength": 255 } } } }, "blacklist": { "type": "array", "items": { "type": "string" } }, "whitelist": { "type": "array", "items": { "type": "string" } } } } ``` ## CampaignBody [#campaignbody] Create and update request body. PUT requires the full body. ```json { "$id": "CampaignBody", "type": "object", "required": ["name", "mode", "pages", "filters", "domain", "alias"], "properties": { "name": { "type": "string", "minLength": 3, "maxLength": 255 }, "active": { "type": "boolean", "default": true }, "mode": { "enum": ["advanced", "basic"] }, "domain": { "type": ["string", "null"], "maxLength": 128 }, "alias": { "type": ["string", "null"], "maxLength": 32 }, "pages": { "type": "object", "required": ["white", "offers"], "properties": { "white": { "type": "object", "required": ["type", "content"], "properties": { "type": { "enum": ["content", "redirect", "iframe"] }, "content": { "type": "string", "maxLength": 255 } } }, "offers": { "type": "array", "minItems": 1, "items": { "type": "object", "required": ["type", "content", "share"], "properties": { "type": { "enum": ["content", "redirect", "iframe"] }, "content": { "type": "string", "maxLength": 255 }, "share": { "type": "number" }, "segmentation": { "type": "object", "properties": { "country": { "type": "array", "maxItems": 256, "items": { "type": "string", "pattern": "^[A-Z]{2}$" } } } } } } } } }, "filters": { "$ref": "CampaignFilters" } } } ``` ## CampaignFull [#campaignfull] Single campaign response (show, create, update, clone). ```json { "$id": "CampaignFull", "type": "object", "properties": { "id": { "type": "integer" }, "created_at": { "type": "string", "format": "date-time" }, "updated_at": { "type": "string", "format": "date-time" }, "name": { "type": "string" }, "active": { "type": "boolean" }, "slug": { "type": "string" }, "mode": { "enum": ["advanced", "basic"] }, "domain": { "type": ["string", "null"] }, "alias": { "type": ["string", "null"] }, "network": { "type": "string" }, "show_content_enabled": { "type": "boolean" }, "pages": { "type": "object" }, "filters": { "$ref": "CampaignFilters" }, "is_test_mode": { "type": "boolean", "description": "GET show only: true if client IP is in filters.whitelist" } } } ``` ## CampaignShort [#campaignshort] List item in `GET /v1/campaigns` response. ```json { "$id": "CampaignShort", "type": "object", "properties": { "id": { "type": "integer" }, "name": { "type": "string" }, "active": { "type": "boolean" }, "created_at": { "type": "string", "format": "date-time" }, "updated_at": { "type": "string", "format": "date-time" }, "mode": { "enum": ["advanced", "basic"] }, "network": { "type": "string" }, "in_review": { "type": "boolean", "description": "Read-only mirror of filters.deny_always" } } } ``` ## Domain [#domain] ```json { "$id": "Domain", "type": "object", "properties": { "id": { "type": "integer" }, "name": { "type": "string" }, "verified": { "type": "boolean" }, "migrated": { "type": "boolean" }, "created_at": { "type": "string", "format": "date-time" } } } ```