# 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" }
}
}
```