> Adsly docs index: https://adsly.pro/docs/llms.txt · every page in one file: https://adsly.pro/docs/llms-full.txt · OpenAPI: https://adsly.pro/docs/api/openapi.yaml # Adsly API — overview and quickstart What the Adsly API does, the base URL, and a five-minute path from a new key to your first campaign created and paused from code. The Adsly API gives your code the same control over your Telegram Ads cabinets that you have in the Adsly panel. One key, one base URL, the same calls for Euro, TON and Stars cabinets. | You want to… | Use | |---|---| | Pull campaigns and stats into a dashboard or BI | [Campaigns](https://adsly.pro/docs/api/campaigns.md), [Stats](https://adsly.pro/docs/api/stats.md) | | Launch campaigns from a script, a bot or your CRM | [Create campaigns](https://adsly.pro/docs/api/create-campaigns.md) | | Raise bids, pause losers, top up winners automatically | [Manage campaigns](https://adsly.pro/docs/api/manage-campaigns.md) | | Know the moment a campaign is approved, declined or runs out of budget | [Webhooks](https://adsly.pro/docs/api/webhooks.md) | | Count leads and sales from your tracker against each campaign | [Conversions](https://adsly.pro/docs/api/conversions.md) | ## Base URL ``` https://app.adsly.pro/api/v1 ``` Every request carries your key in the `X-API-Key` header. Request and response bodies are JSON. All times are UTC. ## Quickstart ### 1. Create a key In the panel open **API & Webhooks** → **+ New key** (Pro and Agency plans). Choose which cabinets the key reaches. To create and change campaigns, tick **Allow managing campaigns** — without it the key only reads. Copy the key: it is shown once. ```bash export ADSLY_API_KEY="adsly_…" ``` ### 2. List the cabinets the key sees ```bash curl -s https://app.adsly.pro/api/v1/account/info \ -H "X-API-Key: $ADSLY_API_KEY" ``` ```json { "success": true, "data": [ { "id": 53, "name": "Main cabinet", "cabinet_type": "euro", "is_active": true, "access_level": "owner", "currency": "EUR", "balance": "250.00", "balance_updated_at": "2026-10-08T09:40:12.000Z", "min_budget": 1, "features": { "ad_button": true, "crypto_filters": true } } ] } ``` Keep `id` — it is the `account_id` every other call uses. All money in a cabinet is in its `currency`. ### 3. Read campaigns ```bash curl -s "https://app.adsly.pro/api/v1/campaigns?account_id=53&status=Active&limit=50" \ -H "X-API-Key: $ADSLY_API_KEY" ``` ### 4. Create a campaign (switched off, so nothing is spent) ```bash curl -s -X POST https://app.adsly.pro/api/v1/campaigns \ -H "X-API-Key: $ADSLY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "account_id": 53, "title": "API test", "text": "Join the channel for daily market notes", "promote_url": "https://t.me/yourchannel", "cpm": 1.5, "budget": 0, "active": false, "target_type": "channels", "channels": ["@durov"] }' ``` Creating talks to Telegram, so it runs in the background. You get `202 Accepted` and a task: ```json { "success": true, "data": { "id": 81234, "type": "create", "status": "pending", "account_id": 53, "total": 1, "ad_ids": [] } } ``` ### 5. Wait for the task ```bash curl -s https://app.adsly.pro/api/v1/tasks/81234 -H "X-API-Key: $ADSLY_API_KEY" ``` When `status` is `completed`, `ad_ids` holds the new campaign's id. Now you can fund it and switch it on: ```bash curl -s -X POST https://app.adsly.pro/api/v1/campaigns/90412/budget \ -H "X-API-Key: $ADSLY_API_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "account_id": 53, "action": "add", "amount": 5 }' curl -s -X POST "https://app.adsly.pro/api/v1/campaigns/90412/resume?account_id=53" \ -H "X-API-Key: $ADSLY_API_KEY" ``` Telegram reviews every new ad before it runs; the campaign's `status` shows where it is (see [statuses](https://adsly.pro/docs/api/campaigns.md)). A [webhook](https://adsly.pro/docs/api/webhooks.md) tells you the moment it changes. ## Five rules every integration follows 1. **`ad_id` is unique only inside a cabinet.** Telegram reuses ad ids across cabinets. Store and look up campaigns by the pair `(account_id, ad_id)`, and pass `account_id` whenever a key sees more than one cabinet. 2. **Send an `Idempotency-Key` on create, copy, bulk and budget calls** — a fresh UUID per operation, the same one when you retry it. A retry then never creates a second campaign or moves money twice. See [Idempotency](https://adsly.pro/docs/api/idempotency.md). 3. **Create, copy and bulk return a task.** Poll `GET /v1/tasks/{id}` until it finishes. See [Tasks](https://adsly.pro/docs/api/tasks.md). 4. **Money fields are strings** (`"spent": "3.541000"`) to keep exact decimals. Parse them as decimals before doing arithmetic. 5. **Branch on `code`, show `error`.** Every error has a stable machine-readable `code` and a human sentence in `error`. See [Errors](https://adsly.pro/docs/api/errors.md). ## Response shape ```json { "success": true, "data": { … }, "meta": { … } } { "success": false, "error": "Human-readable message", "code": "MACHINE_CODE", "request_id": "…" } ``` ## Where to go next - [Keys and access](https://adsly.pro/docs/api/authentication.md) — read-only vs campaign-managing keys, agency teams. - [Build with Claude and other AI assistants](https://adsly.pro/docs/api/ai-assistants.md) — let a coding agent write the integration. - [OpenAPI spec](https://adsly.pro/docs/api/openapi.yaml) — import into Postman, Insomnia or a client generator. --- Page: https://adsly.pro/docs/api/ · Updated: 2026-10-08 > Adsly docs index: https://adsly.pro/docs/llms.txt · every page in one file: https://adsly.pro/docs/llms-full.txt · OpenAPI: https://adsly.pro/docs/api/openapi.yaml # Keys and access How API keys work — read-only and campaign-managing keys, which cabinets a key reaches, how agency owners and team members use the API, and how to keep keys safe. ## Send the key Every request carries the key in a header: ``` X-API-Key: adsly_0f3c… (54 characters, starts with adsly_) ``` There is no OAuth flow and no token exchange — the key is the credential. Keep it on your server; never ship it to a browser or a mobile app. ## Get a key In the panel: **API & Webhooks** → **+ New key**. Keys are part of the **Pro** and **Agency** plans; each person can hold up to 5 active keys. A key is shown **once**, right after you create it — we store only its SHA-256 hash and cannot show it again. Lost it? Revoke it and create a new one. ## What a key can do | Permission | How you get it | What it allows | |---|---|---| | **Read** | every key | campaigns, stats, conversions, tasks, targeting codes, webhooks, sending conversions to us | | **Manage campaigns** | tick **Allow managing campaigns** when you create the key, or switch it on later on the key's card | everything in Read, plus create, edit, pause/resume, budget, copy, bulk actions, delete, media upload | A read-only key that calls a write endpoint gets `403 READ_ONLY_KEY`. Give dashboards and reporting tools a read-only key; give a manage key only to code you trust to change campaigns. No key — read or manage — can take money out of a cabinet: budget moves only between the cabinet balance and its campaigns. Writes also need an active Pro or Agency plan at the moment of the call (`403 FEATURE_NOT_AVAILABLE` otherwise) and the same per-feature plan rights the panel checks. ## Which cabinets a key reaches When you create a key you choose its scope: | Scope | The key reaches | |---|---| | All my cabinets (default) | every cabinet you can open in the panel, including ones added later | | Several | the cabinets you ticked | | One cabinet | that cabinet only | The scope only ever **narrows** what you can open in the panel — it never widens it. It is re-checked on every request: when you lose access to a cabinet, your keys lose it in the same moment. `GET /v1/account/info` always lists exactly what the key reaches right now. If a key reaches several cabinets, pass `account_id` on calls that act on one of them (`400 ACCOUNT_ID_REQUIRED` tells you when it's missing). ## Agencies and team members API access follows the access you have in the panel, cabinet by cabinet: | Who you are | Cabinets your key reaches | Can manage campaigns in | |---|---|---| | Account owner (regular user) | your own cabinets | all of them | | Agency owner | your personal and agency cabinets | all of them | | Agency team member | the cabinets the agency shared with you | cabinets shared with **Manager** access; **Viewer** cabinets stay read-only | - Team members create their own keys on their own **API & Webhooks** page. The agency's plan covers them. - The agency owner sees every key the team created under **API & Webhooks → Team keys** — whose it is, whether it can manage campaigns, how many cabinets it reaches, when it was last used — and can revoke any of them. - `GET /v1/account/info` returns `access_level` for each cabinet: `owner`, `manager` or `viewer`. A write to a `viewer` cabinet returns `403 VIEW_ONLY_ACCESS`. - If the owner removes a member, lowers them to Viewer, or the agency plan ends, the member's keys follow immediately — nothing to revoke by hand. What the roles mean in the panel: **Manager** — full campaign management in that cabinet; **Viewer** — campaigns and stats only. The owner can also let a member add cabinets, invite teammates or share their own cabinets (never above their own level). ## Revoke and rotate - **Revoke** a key on its card in the panel. It stops working immediately. - **Rotate** = create a new key, deploy it, then revoke the old one. Two keys can be active at once, so there is no downtime. - The **webhook secret** (for verifying [webhooks](https://adsly.pro/docs/api/webhooks.md)) is separate from the key and can be rotated on its own. ## Keep keys safe - One key per integration, so you can revoke one without breaking the others. - Store keys in environment variables or a secret manager. Never commit them, never paste them into chats or tickets. - Prefer read-only keys; add **Manage campaigns** only where the code changes campaigns. - Narrow the scope to the cabinets an integration needs. - If a key may have leaked, revoke it first and investigate second. Every change made through the API appears in the campaign's history in the panel marked **🔌 API**. ## Errors you can get here | HTTP | `code` | Meaning | |---|---|---| | 401 | `API_KEY_MISSING` | no `X-API-Key` header | | 401 | `API_KEY_INVALID` | wrong, revoked or expired key | | 401 | `NO_ACCOUNTS` | the key reaches no active cabinet right now | | 403 | `READ_ONLY_KEY` | a write with a key that doesn't have Manage campaigns | | 403 | `VIEW_ONLY_ACCESS` | a write to a cabinet shared with you as Viewer | | 403 | `FEATURE_NOT_AVAILABLE` | the plan doesn't include this action | | 403 | `WRITE_NOT_ALLOWED` | this kind of key (issued by Adsly for partners) can't write | Full list: [Errors](https://adsly.pro/docs/api/errors.md). --- Page: https://adsly.pro/docs/api/authentication/ · Updated: 2026-10-08 > Adsly docs index: https://adsly.pro/docs/llms.txt · every page in one file: https://adsly.pro/docs/llms-full.txt · OpenAPI: https://adsly.pro/docs/api/openapi.yaml # Build with Claude and other AI assistants Hand the Adsly API docs to Claude Code or any coding agent — the files to point it at, ready-to-paste prompts, and the rules to put in your project's CLAUDE.md so the integration is safe. These docs are written to be read by coding agents as well as people. Every page exists as plain Markdown, the whole set is available as one file, and the API is described by an OpenAPI spec. Point your assistant at them and it has everything it needs. ## Files for agents | File | What it is | When to use it | |---|---|---| | [`/docs/llms.txt`](https://adsly.pro/docs/llms.txt) | Index of every page, with one-line summaries ([llms.txt format](https://llmstxt.org/)) | Let the agent pick the pages it needs | | [`/docs/llms-full.txt`](https://adsly.pro/docs/llms-full.txt) | All pages in one Markdown file (about 80 KB) | Tools that load a large file whole; web-fetch tools may cut it short, so prefer `llms.txt` + pages there | | [`/docs/api/openapi.yaml`](https://adsly.pro/docs/api/openapi.yaml) | OpenAPI 3.1 spec: endpoints, parameters, schemas, error codes | Typed clients, request validation, Postman | | `.md` | The page as Markdown, e.g. [`/docs/api/create-campaigns.md`](https://adsly.pro/docs/api/create-campaigns.md) | A single topic | Every page also has **Copy page as Markdown** at the top — paste it straight into a chat. ## Prompt: integrate the API (Claude Code) Paste this into Claude Code (or another coding agent) in the project that should talk to Adsly. Edit the last paragraph to say what you're building. ```text Integrate the Adsly API into this project. Docs: fetch https://adsly.pro/docs/llms.txt, then fetch every page it lists that this task touches (each is a short .md file) before writing code. If you can load a large file in one go, https://adsly.pro/docs/llms-full.txt has all pages. Machine-readable spec: https://adsly.pro/docs/api/openapi.yaml Rules: - Base URL https://app.adsly.pro/api/v1; auth header X-API-Key from the ADSLY_API_KEY environment variable. Never hard-code or log the key. - Store campaigns by (account_id, ad_id) — ad_id alone is not unique. - Send a fresh UUID as Idempotency-Key on every POST to /campaigns, /campaigns/bulk, /campaigns/{id}/copy and /campaigns/{id}/budget, and reuse the same UUID when retrying that same operation. - POST /campaigns, /copy and /bulk return 202 + a task: poll GET /tasks/{id} (honour Retry-After) until status is completed or failed. - Money fields are decimal strings — parse them, don't compare them as text. - On 429 wait Retry-After seconds. On 5xx after a write, GET the campaign to see whether the change happened before retrying with a new Idempotency-Key. - Branch on the `code` field of errors; show `error` to people. - Write a thin client module with typed methods and tests that mock HTTP. What I need: . ``` ## Prompt: one-off task in a chat For a question rather than a codebase change (Claude.ai, ChatGPT or any chat with web access): ```text Read https://adsly.pro/docs/llms.txt and the pages it lists for campaigns and stats. Using the Adsly API, write a Python script that lists every Active campaign in my cabinets with its spend over the last 7 days, sorted by spend. Read the key from ADSLY_API_KEY. ``` ## Rules for your project's CLAUDE.md If your project has a `CLAUDE.md` (Claude Code reads it at the start of every session), add this block so every future change to the integration follows the same rules: ```markdown ## Adsly API integration - Docs: https://adsly.pro/docs/llms.txt (fetch the page you need as .md); spec: https://adsly.pro/docs/api/openapi.yaml. Check them before changing any Adsly call — don't guess endpoints or fields. - Key: ADSLY_API_KEY env var. Never commit, print or log it. - Campaign identity is (account_id, ad_id). Never look up by ad_id alone. - Every POST that creates or moves money sends Idempotency-Key (UUID per operation, reused on retry). - Create / copy / bulk are async: wait for GET /v1/tasks/{id}. - IMPORTANT: changes to live campaigns (budget, pause, delete) go through one module with tests; never call them from ad-hoc scripts against production without asking first. ``` ## Working safely with an agent - **Develop with a read-only key.** The agent can explore real data and nothing can change. Switch to a key with **Manage campaigns** only when the write code is reviewed. - **Create campaigns with `"active": false` and `"budget": 0` while testing.** Nothing runs and nothing is spent; delete them afterwards. - **Narrow the key's scope** to a test cabinet while the integration is new. - **Let the agent read errors.** Every error says what to fix (`error`) and carries a stable `code` and the `param` that was wrong — agents fix their own requests from these without guessing. - Everything the API changes shows in the panel's campaign history marked **🔌 API**, so you can review what the agent did. --- Page: https://adsly.pro/docs/api/ai-assistants/ · Updated: 2026-10-08 > Adsly docs index: https://adsly.pro/docs/llms.txt · every page in one file: https://adsly.pro/docs/llms-full.txt · OpenAPI: https://adsly.pro/docs/api/openapi.yaml # Errors The error format, every error code the API returns, what each means and what to do about it. ## Format Every error has the same shape: ```json { "success": false, "error": "cpm must be greater than 0", "code": "VALIDATION_ERROR", "param": "campaigns[2].cpm", "request_id": "6f1c2a7e-3b0e-4f39-9a52-0c8d7f1e2b44" } ``` | Field | Always | Meaning | |---|---|---| | `error` | yes | A sentence a person can read. Show it; don't parse it — wording can improve. | | `code` | yes on all v1 errors | Stable, machine-readable. Branch on this. | | `param` | on input errors | The field that was wrong, as a path: `cpm`, `campaigns[2].channels[0]`, `account_id`. | | `request_id` | on most errors | Same as the `X-Request-Id` response header. Quote it to support. | Some errors add context: `min_cpm` and `currency` when Telegram refuses a bid as too low, `missing` with `UNKNOWN_AD_IDS`, `limit` with `QUEUE_FULL`. ## HTTP status codes | Status | Meaning | Retry? | |---|---|---| | 200 | Done | — | | 202 | Accepted, running in the background — poll the [task](https://adsly.pro/docs/api/tasks.md) | — | | 400 | The request is wrong (field, value, missing `account_id`) — or Telegram refused the change | No — fix the request | | 401 | No key, bad key, or the key reaches no cabinet | No | | 403 | The key or plan isn't allowed to do this | No | | 404 | Not found among the cabinets this key reaches | No | | 409 | Conflict with the current state (cabinet locked, Telegram session expired, same request in progress) | Sometimes — see the code | | 413 | File too large | No | | 422 | `Idempotency-Key` reused for a different request | No — use a new key | | 429 | Rate limit or queue full | Yes, after `Retry-After` seconds | | 500 | Our bug — already logged | Read the state first, then retry with backoff | | 503 | Temporarily unavailable | Yes, after `Retry-After` | ## Codes ### Authentication and access | Code | HTTP | What to do | |---|---|---| | `API_KEY_MISSING` | 401 | Send `X-API-Key`. | | `API_KEY_INVALID` | 401 | The key is wrong, revoked or expired. Create a new one. | | `NO_ACCOUNTS` | 401 | The key reaches no active cabinet right now (cabinet deactivated, access removed). | | `FORBIDDEN` | 403 | This key type isn't allowed for its owner. | | `READ_ONLY_KEY` | 403 | Turn on **Manage campaigns** for the key in the panel. | | `WRITE_NOT_ALLOWED` | 403 | This key type (issued by Adsly for partners) never writes. | | `VIEW_ONLY_ACCESS` | 403 | Your access to this cabinet is Viewer. Ask the agency owner for Manager. | | `FEATURE_NOT_AVAILABLE` | 403 | The plan doesn't include this action (the response names the `feature`). | | `SUBSCRIPTION_REQUIRED` | 403 | The cabinet's owner has no active subscription. | | `ACCOUNT_INACTIVE` | 403 | The cabinet is deactivated. | ### Finding things | Code | HTTP | What to do | |---|---|---| | `ACCOUNT_ID_REQUIRED` | 400 | The key reaches several cabinets (or this `ad_id` exists in several) — pass `account_id`. | | `ACCOUNT_NOT_FOUND` | 404 | That `account_id` isn't reachable with this key. Check `GET /v1/account/info`. | | `CAMPAIGN_NOT_FOUND` | 404 | No such campaign in the key's cabinets (or it is deleted). | | `TASK_NOT_FOUND` | 404 | No such task for this key's owner. | | `NOT_FOUND` | 404 | No stats for this campaign and period. | | `UNKNOWN_AD_IDS` | 400 | Some `ad_ids` in a bulk call aren't campaigns of that cabinet; `missing` lists them. | ### Input | Code | HTTP | What to do | |---|---|---| | `VALIDATION_ERROR` | 400 | `param` names the field; `error` says what's wrong. Unknown fields are rejected too — check spelling. | | `UNSUPPORTED_MEDIA` | 400 | Upload JPG, PNG, GIF, WEBP or MP4 in the `file` field. | | `FILE_TOO_LARGE` | 413 | Upload a smaller file (10 MB at most). | ### Telegram and cabinet state | Code | HTTP | What to do | |---|---|---| | `PLATFORM_REJECTED` | 400 | Telegram refused the change; `error` carries its reason. With a low bid you also get `param: "cpm"`, `min_cpm` and `currency`. | | `BUDGET_UNCHANGED` | 400 | Telegram accepted the top-up but the campaign's budget didn't move. Check the cabinet balance (`GET /v1/account/info`). | | `INSUFFICIENT_AD_BALANCE` | 400 | You asked to withdraw more than the campaign has. Withdraw less, or use `withdraw_all`. | | `CABINET_AUTH_EXPIRED` | 409 | The cabinet's Telegram session expired. The owner reconnects it in the panel; then retry. | | `CABINET_LOCKED` | 409 | The cabinet is under automatic management; manual changes are blocked. | ### Limits and retries | Code | HTTP | What to do | |---|---|---| | `RATE_LIMITED` | 429 | Wait `Retry-After` seconds. See [Rate limits](https://adsly.pro/docs/api/rate-limits.md). | | `QUEUE_FULL` | 429 | The cabinet already has 5 operations queued. Wait for a task to finish, then retry (same `Idempotency-Key` is fine). | | `IDEMPOTENCY_KEY_REQUIRED` | 400 | Add an `Idempotency-Key` header. | | `IDEMPOTENCY_KEY_INVALID` | 400 | Use 8–255 characters; a UUID is ideal. | | `IDEMPOTENCY_KEY_REUSED` | 422 | That key was used for a different request — new operation, new key. | | `IDEMPOTENCY_IN_PROGRESS` | 409 | The first request with this key is still running. Retry in a few seconds. | | `IDEMPOTENCY_UNAVAILABLE` | 503 | Nothing was done. Retry with the same key. | | `INGEST_DISABLED` | 403 | Turn on **Receive postbacks** for the key ([Conversions](https://adsly.pro/docs/api/conversions.md)). | | `INTERNAL_ERROR` | 500 | Our side. Check the state with a GET before retrying a write; contact [@adsly_pro](https://t.me/adsly_pro) with the `request_id` if it persists. | ## Retrying safely - **4xx** — don't retry unchanged; fix the request. (A 4xx never consumes your `Idempotency-Key`, so you can retry the fixed request with the same key.) - **429 / 503** — retry after `Retry-After`. - **5xx after a write** — the change may or may not have reached Telegram. Retrying with the **same** `Idempotency-Key` returns the same 5xx (by design — it prevents a double action). `GET` the campaign to see what happened, then decide; if you retry, use a new key. - **Network timeout on a write** — retry with the **same** `Idempotency-Key`. If the first attempt went through you get its result; if it didn't, the operation runs now. --- Page: https://adsly.pro/docs/api/errors/ · Updated: 2026-10-08 > Adsly docs index: https://adsly.pro/docs/llms.txt · every page in one file: https://adsly.pro/docs/llms-full.txt · OpenAPI: https://adsly.pro/docs/api/openapi.yaml # Rate limits How many requests a key can make — separate budgets for reads, writes and incoming conversions — the headers that show your remaining budget, and how to back off. Each key has three independent budgets. Using one never eats into another — a dashboard polling stats can't starve its own pause button, and a burst of conversions from your tracker can't block either. | Budget | Applies to | Limit | |---|---|---| | **Read** | every `GET` | 60 requests per minute | | **Write** | every `POST`, `PATCH`, `DELETE` except conversions | 30 requests per minute | | **Conversions in** | `POST /v1/postback`, `GET /v1/ingest/{token}` | 20 events per second, bursts up to 1,000 | The limits are token buckets, not fixed windows: the budget refills continuously, and a short burst up to the full minute's allowance is fine. A typical sync — campaigns, stats, totals — is 3–4 read requests. Need more for a specific integration? Ask [@adsly_pro](https://t.me/adsly_pro) — limits can be raised per key. ## Headers Every response says where you stand: ``` X-RateLimit-Limit: 60 the budget for this kind of request, per minute X-RateLimit-Remaining: 47 requests you can make right now X-RateLimit-Reset: 1791364800 unix time when the budget is full again Retry-After: 23 only on 429 — seconds to wait ``` ## Doing more with fewer calls - **Bulk instead of loops.** Pausing 300 campaigns is one `POST /v1/campaigns/bulk`, not 300 pause calls. See [Manage campaigns](https://adsly.pro/docs/api/manage-campaigns.md). - **Create in batches.** `POST /v1/campaigns` takes up to 100 campaigns. - **Webhooks instead of polling.** Status changes arrive the moment they happen; hourly stats arrive by themselves. See [Webhooks](https://adsly.pro/docs/api/webhooks.md). - **Batch conversions.** `POST /v1/postback` accepts up to 1,000 events per request (each event still counts against the events budget — batching saves round trips, not quota). ## Other limits | What | Limit | |---|---| | Queued or running operations per cabinet | 5 creates + 5 other operations (copy, bulk). The 6th returns `429 QUEUE_FULL`. | | Campaigns per create request | 100 | | Copies per copy request | 100 | | Campaigns per bulk request | 1,000 | | Uploaded file | 10 MB | | Page size for `GET /v1/campaigns` | 500 | ## Backing off On `429`, wait `Retry-After` seconds and retry. For anything automated, add jitter so many workers don't retry at the same instant: ```js async function call(url, init, attempt = 0) { const res = await fetch(url, init); if (res.status !== 429 || attempt >= 5) return res; const wait = Number(res.headers.get('Retry-After') || 1) * 1000; await new Promise(r => setTimeout(r, wait + Math.random() * 500)); return call(url, init, attempt + 1); } ``` --- Page: https://adsly.pro/docs/api/rate-limits/ · Updated: 2026-10-08 > Adsly docs index: https://adsly.pro/docs/llms.txt · every page in one file: https://adsly.pro/docs/llms-full.txt · OpenAPI: https://adsly.pro/docs/api/openapi.yaml # Idempotency How the Idempotency-Key header makes retries safe — no duplicate campaigns, no double top-ups — and exactly how the API treats repeated, changed and concurrent requests. Networks fail in the worst place: after your request reached us, before the answer reached you. Retrying a top-up blindly could move money twice; retrying a create could launch the same campaign twice. An `Idempotency-Key` makes the retry safe. ## Where it is required | Endpoint | Header | |---|---| | `POST /v1/campaigns` | required | | `POST /v1/campaigns/{ad_id}/copy` | required | | `POST /v1/campaigns/bulk` | required | | `POST /v1/campaigns/{ad_id}/budget` | required | | Other writes (`PATCH`, pause/resume, `DELETE`, media) | optional — they are safe to repeat on their own | Without the header these return `400 IDEMPOTENCY_KEY_REQUIRED`. ## How to use it Generate a new random value — a UUID — **for each operation**, and send the **same** value every time you retry that operation. ```bash KEY=$(uuidgen) curl -X POST https://app.adsly.pro/api/v1/campaigns/90412/budget \ -H "X-API-Key: $ADSLY_API_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: $KEY" \ -d '{"account_id":53,"action":"add","amount":5}' # timed out? run exactly the same command again, with the same $KEY ``` ```python import uuid, requests def add_budget(ad_id, account_id, amount): key = str(uuid.uuid4()) # one per operation for attempt in range(3): try: return requests.post( f"https://app.adsly.pro/api/v1/campaigns/{ad_id}/budget", headers={"X-API-Key": API_KEY, "Idempotency-Key": key}, json={"account_id": account_id, "action": "add", "amount": amount}, timeout=30, ) except requests.Timeout: continue # same key → never added twice ``` ## What happens on a repeat | Situation | Response | |---|---| | First request with this key | Runs normally. | | Same key, same request, first one finished | The **stored** response, unchanged, with the header `Idempotent-Replayed: true`. Nothing runs again. | | Same key, same request, first one still running | `409 IDEMPOTENCY_IN_PROGRESS` + `Retry-After`. Retry shortly. | | Same key, different body or endpoint | `422 IDEMPOTENCY_KEY_REUSED`. A new operation needs a new key. | | First request ended in a 4xx (bad input, plan, queue full…) | The key is released: fix the request and send it with the **same** key. | | First request ended in a 5xx | The 5xx is stored and replayed — the change may have reached Telegram, and the key exists to stop it running twice. Check the campaign with `GET`, then use a new key if you still want to act. | "Same request" means the same method, path and JSON body (key order doesn't matter). ## Details - Keys are scoped to your API key — two integrations can't collide. - Keys are kept for 24 hours. After that the same value starts a new operation. - Any string of 8–255 characters works; a UUID is the right default. - If we can't record the key, the operation doesn't run and you get `503 IDEMPOTENCY_UNAVAILABLE` — retry with the same key. --- Page: https://adsly.pro/docs/api/idempotency/ · Updated: 2026-10-08 > Adsly docs index: https://adsly.pro/docs/llms.txt · every page in one file: https://adsly.pro/docs/llms-full.txt · OpenAPI: https://adsly.pro/docs/api/openapi.yaml # Tasks (async operations) Creating, copying and bulk changes run in the background. How to follow a task with GET /v1/tasks/{id}, what its statuses mean and how to read its result. Some operations talk to Telegram once per campaign, so they can take longer than an HTTP request should wait. These return `202 Accepted` with a **task** instead of the final result: - `POST /v1/campaigns` — create - `POST /v1/campaigns/{ad_id}/copy` — copy - `POST /v1/campaigns/bulk` — pause, resume, budget, CPM or delete many Single-campaign changes (`PATCH`, pause, resume, budget, delete) answer directly — no task. ## The 202 response ```http HTTP/1.1 202 Accepted Location: /api/v1/tasks/81234 Retry-After: 2 ``` ```json { "success": true, "data": { "id": 81234, "type": "create", "status": "pending", "account_id": 53, "progress": 0, "total": 3, "succeeded": null, "failed": null, "ad_ids": [], "errors": [], "paused_reason": null, "error": null, "created_at": "2026-10-08T10:15:02.411Z", "started_at": null, "completed_at": null } } ``` ## GET /v1/tasks/{id} ```bash curl -s https://app.adsly.pro/api/v1/tasks/81234 -H "X-API-Key: $ADSLY_API_KEY" ``` Same shape as above. Poll it until `status` is final; while a task is `pending` or `running` the response carries `Retry-After: 2` — wait that long between polls. Any key of the same owner can read the task. | Field | Meaning | |---|---| | `type` | `create`, `copy`, `status` (bulk pause/resume), `budget`, `cpm`, `delete` | | `status` | see below | | `progress` / `total` | campaigns processed so far / in total | | `succeeded` / `failed` | counts, once known | | `ad_ids` | campaigns the task created (create, copy) or changed (bulk) | | `errors[]` | up to 50 failures: `{ ad_id, index, error }` — `index` is the position in your `campaigns` array for creates | | `paused_reason` | why a task stopped to wait, e.g. the cabinet ran out of balance | | `error` | why the whole task failed | ### Statuses | Status | Meaning | Final? | |---|---|---| | `pending` | queued — the cabinet runs one create and one other operation at a time | no | | `running` | in progress | no | | `paused` | stopped to wait — usually the cabinet balance ran out mid-way. Top up the cabinet; resume it from the panel | no | | `completed` | finished. Check `failed` and `errors` — a completed task can contain individual failures | yes | | `failed` | the task as a whole failed — see `error` | yes | | `cancelled` | cancelled from the panel | yes | ## Things to know about creates - New campaign ids appear in `ad_ids` when the task completes (they are found by syncing the cabinet after the last campaign is created). - If the **first** campaign of a batch fails, the task stops there rather than repeat the same mistake 100 times. Fix the input and send again. - If Telegram can't resolve any of a campaign's channels or bots, that campaign fails with `No targets found: …` listing them. - Telegram reviews every new ad. A created campaign usually starts `In Review`; a [webhook](https://adsly.pro/docs/api/webhooks.md) tells you when that changes. ## A polling loop ```js async function waitForTask(id) { for (;;) { const res = await fetch(`https://app.adsly.pro/api/v1/tasks/${id}`, { headers: { 'X-API-Key': process.env.ADSLY_API_KEY }, }); const { data } = await res.json(); if (['completed', 'failed', 'cancelled'].includes(data.status)) return data; if (data.status === 'paused') throw new Error(`Task paused: ${data.paused_reason}`); await new Promise(r => setTimeout(r, Number(res.headers.get('Retry-After') || 2) * 1000)); } } ``` ```python import time, requests def wait_for_task(task_id): while True: r = requests.get(f"https://app.adsly.pro/api/v1/tasks/{task_id}", headers={"X-API-Key": API_KEY}, timeout=30) task = r.json()["data"] if task["status"] in ("completed", "failed", "cancelled"): return task if task["status"] == "paused": raise RuntimeError(f"Task paused: {task['paused_reason']}") time.sleep(int(r.headers.get("Retry-After", 2))) ``` --- Page: https://adsly.pro/docs/api/tasks/ · Updated: 2026-10-08 > Adsly docs index: https://adsly.pro/docs/llms.txt · every page in one file: https://adsly.pro/docs/llms-full.txt · OpenAPI: https://adsly.pro/docs/api/openapi.yaml # Pagination How to walk through every campaign of large cabinets with cursor pagination, and when the older offset mode is still fine. `GET /v1/campaigns` is the only list that pages. It has two modes. ## Cursor mode (use this) Add `cursor=` (empty on the first call) and follow `meta.next_cursor` until it is `null`: ```bash curl -s "https://app.adsly.pro/api/v1/campaigns?limit=500&cursor=" -H "X-API-Key: $ADSLY_API_KEY" ``` ```json { "success": true, "data": [ { "ad_id": 90412, "account_id": 53, "…": "…" } ], "meta": { "limit": 500, "cursor": null, "next_cursor": "eyJhIjo1MywiaSI6OTAzMTB9", "hasMore": true } } ``` ```python def all_campaigns(): cursor = "" while True: r = requests.get("https://app.adsly.pro/api/v1/campaigns", params={"limit": 500, "cursor": cursor}, headers={"X-API-Key": API_KEY}, timeout=60).json() yield from r["data"] cursor = r["meta"]["next_cursor"] if not cursor: break ``` - Treat the cursor as opaque. It is only valid with the same filters. - Order is fixed (cabinet, then newest `ad_id` first), so pages stay stable while campaigns are added. - No upper limit — this is how you read a cabinet with 100,000 campaigns. - Filters `bot_username`, `promote_domain` and `type` always use cursor mode. - Cursor mode leaves out a few heavy fields (targeting summary, media, last action). Use [`GET /v1/campaigns/{ad_id}`](https://adsly.pro/docs/api/campaigns.md) for the full record of one campaign. ## Offset mode (small cabinets) Without `cursor`, the endpoint pages with `limit` and `offset` and can sort (`sortBy`, `sortOrder`). It reads at most **500 campaigns per cabinet**: past that `meta.truncated` is `true` and `meta.next_step` tells you to switch to cursor mode. ```json "meta": { "total": 1240, "limit": 100, "offset": 0, "hasMore": true, "truncated": true, "next_step": "Use ?cursor= for full traversal beyond 500 campaigns per cabinet" } ``` ## Page size `limit` is 1–500, default 100. --- Page: https://adsly.pro/docs/api/pagination/ · Updated: 2026-10-08 > Adsly docs index: https://adsly.pro/docs/llms.txt · every page in one file: https://adsly.pro/docs/llms-full.txt · OpenAPI: https://adsly.pro/docs/api/openapi.yaml # Cabinets (accounts) GET /v1/account/info — the cabinets a key reaches, with currency, balance, minimum budget, your access level and which optional ad fields each cabinet supports. A **cabinet** (account) is one Telegram Ads ad account in Adsly. Every campaign belongs to exactly one cabinet, identified by `account_id`. ## GET /v1/account/info The cabinets this key reaches, right now. ```bash curl -s https://app.adsly.pro/api/v1/account/info -H "X-API-Key: $ADSLY_API_KEY" ``` ```json { "success": true, "data": [ { "id": 53, "name": "Main cabinet", "cabinet_type": "euro", "is_active": true, "access_level": "owner", "currency": "EUR", "balance": "250.00", "balance_updated_at": "2026-10-08T09:40:12.000Z", "min_budget": 1, "features": { "ad_button": true, "crypto_filters": true } }, { "id": 61, "name": "Client B", "cabinet_type": "ton", "is_active": true, "access_level": "viewer", "currency": "TON", "balance": "84.20", "balance_updated_at": "2026-10-08T09:35:51.000Z", "min_budget": 1, "features": { "ad_button": false, "crypto_filters": false } } ] } ``` | Field | Meaning | |---|---| | `id` | The `account_id` to pass to other calls. | | `cabinet_type` | `euro`, `ton` or `stars`. | | `currency` | `EUR`, `TON` or `STARS` — the currency of **every** money field in this cabinet: balance, budget, spent, CPM. Never add up money across cabinets of different currencies. | | `access_level` | Your access: `owner`, `manager` (agency member who can change campaigns) or `viewer` (agency member, read-only). Writes need `owner` or `manager`. | | `balance` | Money on the cabinet, not yet given to campaigns (decimal string). Refreshed when the cabinet syncs — see `balance_updated_at`. | | `min_budget` | The smallest non-zero `budget` a new campaign in this cabinet can have. | | `features.ad_button` | The cabinet's ad form has a custom button (`button` field on create/edit). | | `features.crypto_filters` | The cabinet's ad form has crypto-channel filters. | `balance` is `null` for special partner keys that show adjusted figures. ## Notes - `GET /v1/account/info` costs one read request — call it at start-up and when a write returns `ACCOUNT_NOT_FOUND`, not before every call. - A cabinet disappears from the list when it is deactivated or your access to it is removed. - Cabinets are added in the Adsly panel; the API manages campaigns inside them. --- Page: https://adsly.pro/docs/api/accounts/ · Updated: 2026-10-08 > Adsly docs index: https://adsly.pro/docs/llms.txt · every page in one file: https://adsly.pro/docs/llms-full.txt · OpenAPI: https://adsly.pro/docs/api/openapi.yaml # Campaigns — list and read The campaign object, its statuses, GET /v1/campaigns with filters by status, bot, website domain and ad type, GET /v1/campaigns/{ad_id} for one campaign with its targeting and schedule. A campaign is one Telegram ad. It is identified by **`(account_id, ad_id)`** — Telegram reuses `ad_id` across cabinets, so `ad_id` alone is not unique. ## The campaign object `GET /v1/campaigns/{ad_id}` returns: ```json { "ad_id": 90412, "account_id": 53, "title": "API test", "text": "Join the channel for daily market notes", "status": "Active", "active": true, "promote_url": "https://t.me/yourchannel", "tme_path": "yourchannel", "target_type": "channels", "targets": ["durov"], "cpm": "1.50", "budget": "3.54100", "spent": "1.45900", "daily_budget": null, "views": 1043, "opens": 0, "clicks": 12, "actions": 4, "ctr": "1.15", "cvr": "33.33", "cpc": "0.1215833333", "cpa": "0.3647500000", "views_per_user": 1, "schedule": { "days": { "mon": [9, 10, 11, 12, 13, 14, 15, 16, 17], "tue": [9, 10, 11, 12, 13, 14, 15, 16, 17], "wed": [9, 10, 11, 12, 13, 14, 15, 16, 17], "thu": [9, 10, 11, 12, 13, 14, 15, 16, 17], "fri": [9, 10, 11, 12, 13, 14, 15, 16, 17], "sat": [], "sun": [] }, "timezone": "+03:00" }, "has_media": false, "media_type": null, "button": null, "website_name": null, "group_id": 7, "auto_cpm_enabled": false, "date": "1791364800", "synced_at": "2026-10-08T10:29:41.000Z", "status_changed_at": "2026-10-08T10:31:07.112Z" } ``` | Field | Meaning | |---|---| | `title` | Your internal name for the ad. | | `text` | The ad text people see. | | `status` | What Telegram is doing with the ad — see [Statuses](#statuses). | | `active` | What you asked for: `true` = should run. A campaign with `active: true` and status `Stopped` resumes as soon as it has budget again. | | `promote_url` / `tme_path` | What the ad leads to: a website URL, or a `t.me/` link (channel, bot, mini-app, post). | | `target_type` | `channels`, `bots`, `search` or `users` — see [Create campaigns](https://adsly.pro/docs/api/create-campaigns.md). | | `targets` | Usernames the ad targets (channels or bots), when known. | | `cpm` | Bid per 1,000 views, in the cabinet's currency. | | `budget` | Money **left** on the campaign. `budget + spent` is everything it was given. | | `spent` | Money spent so far. | | `daily_budget` | Daily spending cap, if one is set. | | `views`, `clicks`, `actions`, `opens` | Lifetime counters. `actions` are platform actions (joins, bot starts) — not your conversions; for those see [Conversions](https://adsly.pro/docs/api/conversions.md). | | `ctr`, `cvr` | Percentages: `"1.15"` means 1.15 %. | | `cpc`, `cpa` | Cost per click and per action. | | `views_per_user` | How many times one person can see the ad (1–4). | | `schedule` | Weekdays and hours the ad is shown, or `null` — it runs around the clock. See [Schedule](https://adsly.pro/docs/api/manage-campaigns.md). | | `group_id` | The campaign's group in the panel, if any. | | `auto_cpm_enabled` | Adsly's Auto CPM manages the bid. | | `date` | When the ad was created: unix seconds, as a string. | | `status_changed_at` | Last status change. Not touched by stats updates — good for "what changed since". | | `synced_at` | When the ad's own settings — `schedule`, `text`, `views_per_user`, targeting — were last read from Telegram. See [Freshness](#freshness). | **Money fields are decimal strings** (`cpm`, `budget`, `spent`, `ctr`, `cvr`, `cpc`, `cpa`) so decimals stay exact. Counters (`views`, `clicks`, `actions`, `opens`) are numbers. All money is in the cabinet's currency (see [Cabinets](https://adsly.pro/docs/api/accounts.md)). ## Statuses | Status | Meaning | What you can do | |---|---|---| | `Active` | Running. | Pause, change bid, add budget. | | `In Review` | Waiting for Telegram's review; not shown yet. | Wait — a webhook tells you when it changes. | | `Declined` | Telegram rejected the ad. | Edit the text or link (resubmits), or create a new one. | | `On Hold` | Switched off — by you or an automation rule. | Resume. | | `Stopped` | Budget used up. | Add budget; it runs again if `active` is `true`. | | `Paused` | Paused by Telegram for the whole cabinet (rare). | Contact [@adsly_pro](https://t.me/adsly_pro). | | `Deleted` | Removed. Kept in history, hidden from lists. | — | ## GET /v1/campaigns Campaigns of every cabinet the key reaches. ```bash curl -s "https://app.adsly.pro/api/v1/campaigns?account_id=53&status=Active,On%20Hold&limit=100&cursor=" \ -H "X-API-Key: $ADSLY_API_KEY" ``` | Parameter | Default | Meaning | |---|---|---| | `account_id` | — | Only this cabinet. (`accountId` also works.) | | `status` | all but Deleted | One status or several, comma-separated. | | `title` | — | Title contains this text. | | `bot_username` | — | Ads of one bot or channel: matches the username in `tme_path`, case-insensitive, no `@`. `foo` does not match `foobar`. | | `promote_domain` | — | Website ads whose link is on this host. Accepts `example.com` or a full URL; `www.`, scheme, path and port are ignored. | | `promote_domain_mode` | `exact` | `suffix` also matches subdomains (`x.example.com`). | | `type` | — | `bot` (bots and mini-apps), `channel` (channels, groups, posts) or `site` (external websites). | | `limit` | 100 | 1–500. | | `cursor` | — | Pagination — see [Pagination](https://adsly.pro/docs/api/pagination.md). Recommended. | | `offset`, `sortBy`, `sortOrder` | — | Offset pagination (≤ 500 per cabinet). | Filters combine with AND. An unknown bot or domain returns an empty list, not an error. ```json { "success": true, "data": [ { "ad_id": 90412, "account_id": 53, "title": "API test", "text": "…", "status": "Active", "tme_path": "yourchannel", "promote_url": null, "views": 1043, "opens": 0, "clicks": 12, "actions": 4, "spent": "1.45900", "budget": "3.54100", "cpm": "1.50", "cpc": "0.12", "cpa": "0.36", "ctr": "1.15", "cvr": "33.33", "date": "1791364800", "group_id": 7, "status_changed_at": "2026-10-08T10:31:07.112Z" } ], "meta": { "limit": 100, "cursor": null, "next_cursor": null, "hasMore": false } } ``` ## Get one campaign `GET /v1/campaigns/{ad_id}` — the full object above, including targeting, creative fields and `schedule` (the list endpoint leaves those out to stay fast). Deleted campaigns are returned too, with `status: "Deleted"`. ```bash curl -s "https://app.adsly.pro/api/v1/campaigns/90412?account_id=53" -H "X-API-Key: $ADSLY_API_KEY" ``` `account_id` is needed only when the same `ad_id` exists in several of your cabinets (`400 ACCOUNT_ID_REQUIRED` tells you). ### Freshness Counters and status are refreshed for every campaign every hour. The ad's own settings — `schedule`, `text`, `views_per_user`, targeting — come from reading the ad itself, so: - `GET /v1/campaigns/{ad_id}` re-reads the ad from Telegram when our copy is older than **5 minutes**, before answering. A change someone made directly in Telegram's ad interface shows up here within that window. - Running campaigns (`Active`, `In Review`, `On Hold`) are also re-read in the background about every 3 hours. - Changes made through Adsly — the panel or this API — are in the copy at once. If Telegram doesn't answer in time, you get the stored copy; `synced_at` tells you how old it is. ## GET /v1/campaigns/{ad_id}/history One campaign's totals over a period — a single aggregate, not hourly points. ```bash curl -s "https://app.adsly.pro/api/v1/campaigns/90412/history?account_id=53&period=7d" -H "X-API-Key: $ADSLY_API_KEY" ``` ```json { "success": true, "data": { "views": 640, "clicks": 7, "spent": 0.96, "ctr": 1.09, "cpm": 1.5, "cpa": 0.32 }, "meta": { "adId": 90412, "accountId": 53, "period": "7d" } } ``` `period`: `24h` (default), `7d`, `30d` or `custom` with `startDate` / `endDate` (`YYYY-MM-DD`, UTC) and optional `startHour` / `endHour` (0–23). No data for the period → `404 NOT_FOUND`. ## Keeping a local copy in sync 1. Page through `GET /v1/campaigns?cursor=` and upsert by `(account_id, ad_id)`. 2. Subscribe to [webhooks](https://adsly.pro/docs/api/webhooks.md) for status changes and hourly stats instead of polling every minute. 3. Re-sync fully once a day to catch anything a webhook missed. --- Page: https://adsly.pro/docs/api/campaigns/ · Updated: 2026-10-08 > Adsly docs index: https://adsly.pro/docs/llms.txt · every page in one file: https://adsly.pro/docs/llms-full.txt · OpenAPI: https://adsly.pro/docs/api/openapi.yaml # Create campaigns POST /v1/campaigns — create one or up to 100 Telegram ads in a call, targeting channels, bots, search queries or audiences by country, language and topic; upload images and video with POST /v1/media. `POST /v1/campaigns` creates campaigns in one cabinet. It needs a key with **Manage campaigns**, an `Idempotency-Key`, and returns `202` with a [task](https://adsly.pro/docs/api/tasks.md) — the campaigns appear when the task completes. ## A first example ```bash curl -s -X POST https://app.adsly.pro/api/v1/campaigns \ -H "X-API-Key: $ADSLY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "account_id": 53, "title": "Crypto channels — notes", "text": "Daily market notes in 3 minutes. No noise.", "promote_url": "https://t.me/yourchannel", "cpm": 1.5, "budget": 20, "target_type": "channels", "channels": ["@durov", "t.me/telegram"] }' ``` ```json { "success": true, "data": { "id": 81234, "type": "create", "status": "pending", "account_id": 53, "total": 1, "ad_ids": [] } } ``` ## Many at once Put the campaigns in `campaigns` (up to 100). `account_id` and the group fields stay at the top level and apply to all of them. ```json { "account_id": 53, "new_group_name": "October test", "campaigns": [ { "title": "A", "text": "Variant A", "promote_url": "https://t.me/yourchannel", "cpm": 1.5, "budget": 10, "target_type": "channels", "channels": ["@durov"] }, { "title": "B", "text": "Variant B", "promote_url": "https://t.me/yourchannel", "cpm": 1.5, "budget": 10, "target_type": "channels", "channels": ["@telegram"] } ] } ``` Campaigns are created one after another. If the first one fails, the task stops — fix the input rather than repeat the same error 100 times. Each failure is listed in the task's `errors` with its `index` in your array. ## Fields | Field | Required | Meaning | |---|---|---| | `title` | yes | Internal name, shown in the panel. | | `text` | yes | Ad text people see. Telegram's own length and content rules apply — a refusal comes back in the task's `errors`. | | `promote_url` | yes | What the ad opens: `https://t.me/channel`, `t.me/bot?start=ref`, `@channel` or a website URL. | | `cpm` | yes | Bid per 1,000 views in the cabinet's currency, greater than 0. Too low for the targeting → Telegram's minimum comes back in the error. | | `budget` | yes | Money moved from the cabinet balance to the campaign: `0`, or at least the cabinet's `min_budget` ([Cabinets](https://adsly.pro/docs/api/accounts.md)). With `0` the ad is created but can't run until you add budget. | | `target_type` | yes | `channels`, `bots`, `search` or `users` — see below. | | `active` | no | `false` creates it switched off. Default `true`. | | `daily_budget` | no | Daily spending cap. | | `views_per_user` | no | How many times one person can see the ad, 1–4. | | `media` | no | Image or video token from [`POST /v1/media`](#upload-media). | | `picture` | no | `true` shows the promoted channel's avatar in the ad. | | `button` | no | Button under the ad: `subscribe`, `view`, `read`, `learn_more`, `download`, `open`, `sign_up`, `buy`, `order`, `play`, `try`, `leave_request`. Only where the cabinet's `features.ad_button` is `true`. | | `website_name` | no | Brand name shown above a website ad. | | `schedule` | no | Show the ad only on chosen weekdays and hours, e.g. weekdays 9:00–18:00. Format: [Schedule](https://adsly.pro/docs/api/manage-campaigns.md). Without it the ad runs around the clock. | Top level only: `account_id` (needed when the key reaches several cabinets), `group_id` (an existing [group](https://adsly.pro/docs/api/targeting.md)) or `new_group_name` (creates the group, or reuses one with that name). Unknown fields are refused with `400 VALIDATION_ERROR` and the field name in `param` — a typo never silently drops a setting. ## Targeting ### `channels` — show the ad in specific channels ```json { "target_type": "channels", "channels": ["@durov", "t.me/telegram"], "languages": ["en"], "topics": [7, 26], "exclude_topics": [2], "add_similar_channels": true } ``` | Field | Meaning | |---|---| | `channels` | 1–100 public channels: `@name`, `name` or `t.me/name`. Required. | | `languages`, `topics`, `exclude_topics` | Narrow by channel language and topic ([codes](https://adsly.pro/docs/api/targeting.md)). | | `add_similar_channels` | Also add channels similar to these. | ### `bots` — show the ad in bots ```json { "target_type": "bots", "bots": ["@somebot"] } ``` `bots`: 1–100 public bots. Required. ### `search` — show the ad for Telegram search queries ```json { "target_type": "search", "search_keywords": ["crypto wallet", "bitcoin"] } ``` `search_keywords`: 1–100 phrases, up to 64 characters each. Required. ### `users` — show the ad to an audience ```json { "target_type": "users", "countries": ["DE", "AT"], "languages": ["de"], "topics": [26], "exclude_topics": [2], "audience_channels": ["@somechannel"] } ``` | Field | Meaning | |---|---| | `countries` | ISO country codes. | | `languages` | Language codes. | | `topics`, `exclude_topics` | Interests by topic id. | | `audience_channels` | People who read these channels. | At least one of these is required. Codes: [Targeting codes](https://adsly.pro/docs/api/targeting.md). Channels, bots and keywords are resolved to Telegram's own ids when the task runs. Ones Telegram can't find are skipped; if none are found, that campaign fails — `No targets found: …` for channels and bots, `No valid search queries found` for keywords — and the task's `errors` says so. ## After creating 1. Poll [`GET /v1/tasks/{id}`](https://adsly.pro/docs/api/tasks.md) until `completed`. `ad_ids` holds the new campaigns. 2. Telegram reviews each new ad: it starts `In Review`, then becomes `Active` (or `Declined`). A [webhook](https://adsly.pro/docs/api/webhooks.md) tells you when. 3. Change, fund or pause them with [Manage campaigns](https://adsly.pro/docs/api/manage-campaigns.md). ## Upload media `POST /v1/media` uploads an image or video into a cabinet and returns a `media` token for `POST /v1/campaigns`, `PATCH` and copy. The token belongs to that cabinet — upload into the cabinet you'll create in. ```bash curl -s -X POST "https://app.adsly.pro/api/v1/media?account_id=53" \ -H "X-API-Key: $ADSLY_API_KEY" \ -F "file=@banner.jpg" ``` ```json { "success": true, "data": { "media": "BQACAgIAAx0…", "account_id": 53 } } ``` - JPG, PNG, GIF, WEBP or MP4, up to 10 MB, sent as `multipart/form-data` in the field `file`. - Some cabinets accept only 16:9 images at least 640 px wide; if a file doesn't fit, Telegram's reason comes back in `error`. ## Errors | Code | When | |---|---| | `VALIDATION_ERROR` | A field is missing, unknown, of the wrong type, or doesn't belong to this `target_type` — `param` says which. | | `IDEMPOTENCY_KEY_REQUIRED` | No `Idempotency-Key`. | | `ACCOUNT_ID_REQUIRED` / `ACCOUNT_NOT_FOUND` | Say which cabinet / that cabinet isn't reachable. | | `VIEW_ONLY_ACCESS` | You have Viewer access to this cabinet. | | `QUEUE_FULL` | 5 creates already queued in this cabinet. | | `CABINET_LOCKED`, `SUBSCRIPTION_REQUIRED` | The cabinet can't take manual changes right now. | Individual campaign failures (Telegram refused a text, a bid, a link) don't fail the request — they appear in the task's `errors`. --- Page: https://adsly.pro/docs/api/create-campaigns/ · Updated: 2026-10-08 > Adsly docs index: https://adsly.pro/docs/llms.txt · every page in one file: https://adsly.pro/docs/llms-full.txt · OpenAPI: https://adsly.pro/docs/api/openapi.yaml # Manage campaigns Change a campaign's text, link, bid and daily cap; show it only on chosen days and hours; pause and resume; add or withdraw budget; copy; delete — one campaign at a time or up to 1,000 with a bulk call. All calls here need a key with **Manage campaigns** and Manager (or owner) access to the cabinet. They act on one campaign, `(account_id, ad_id)`: pass `account_id` in the body or query when the key reaches several cabinets. Every change appears in the campaign's history in the panel marked **🔌 API**. | Call | What it does | Answers | |---|---|---| | `PATCH /v1/campaigns/{ad_id}` | change text, link, bid, cap, schedule, on/off | right away | | `POST /v1/campaigns/{ad_id}/pause` | switch off | right away | | `POST /v1/campaigns/{ad_id}/resume` | switch on | right away | | `POST /v1/campaigns/{ad_id}/budget` | add or withdraw budget | right away | | `DELETE /v1/campaigns/{ad_id}` | delete | right away | | `POST /v1/campaigns/{ad_id}/copy` | 1–100 copies | 202 + [task](https://adsly.pro/docs/api/tasks.md) | | `POST /v1/campaigns/bulk` | one action on up to 1,000 campaigns | 202 + task | Direct answers return the updated [campaign object](https://adsly.pro/docs/api/campaigns.md) in `data`. ## Edit `PATCH /v1/campaigns/{ad_id}` — send only what changes. ```bash curl -s -X PATCH https://app.adsly.pro/api/v1/campaigns/90412 \ -H "X-API-Key: $ADSLY_API_KEY" -H "Content-Type: application/json" \ -d '{ "account_id": 53, "cpm": 1.8, "text": "Daily market notes in 3 minutes." }' ``` | Field | Meaning | |---|---| | `title`, `text`, `promote_url` | Creative and link. | | `cpm` | New bid, greater than 0. | | `daily_budget` | Daily cap. | | `views_per_user` | 1–4. | | `active` | `true` / `false` — same as resume / pause. | | `schedule` | Days and hours to show the ad; `null` removes it — see [Schedule](#schedule). | | `media`, `picture`, `button`, `website_name` | As in [create](https://adsly.pro/docs/api/create-campaigns.md). | **What goes to Telegram's review.** Changing `cpm`, `active` or `schedule` doesn't — they are applied at once and the campaign keeps running. Any other field (`title`, `text`, `promote_url`, `media`, `picture`, `button`, `website_name`, `views_per_user`, `daily_budget`) is saved through Telegram's full ad form, and Telegram reviews the ad again: it shows `In Review` until approved. Send bid, on/off and schedule changes on their own, not together with those fields. Targeting (`channels`, `countries`, …) can't change on an existing ad — Telegram doesn't allow it. Create a new campaign with the new targeting, or [copy](#copy) and adjust. `meta` in the response: | Field | Meaning | |---|---| | `status_warning` | The fields saved, but Telegram didn't apply the on/off switch (for example, no budget). | | `status_intent_only` | Your on/off choice is stored and applies when Telegram can (in review, no budget). | | `status_already_set` | The campaign was already in the state you asked for. | A bid below Telegram's minimum for this targeting returns `400 PLATFORM_REJECTED` with `param: "cpm"`, `min_cpm` and `currency`. ## Pause and resume ```bash curl -s -X POST "https://app.adsly.pro/api/v1/campaigns/90412/pause?account_id=53" -H "X-API-Key: $ADSLY_API_KEY" curl -s -X POST "https://app.adsly.pro/api/v1/campaigns/90412/resume?account_id=53" -H "X-API-Key: $ADSLY_API_KEY" ``` `meta.intent_only: true` means Telegram can't act on it yet — the campaign is in review, declined, or out of budget — and your choice is stored: it runs as soon as it can. When the status really changes, your [status webhook](https://adsly.pro/docs/api/webhooks.md) fires. ## Schedule Show a campaign only on chosen weekdays and hours — for example weekdays 9:00–18:00, or evenings only. Outside those hours Telegram doesn't show the ad. Setting, changing or removing a schedule doesn't switch the campaign on or off — `status` and `active` stay as they are. It works on every cabinet type. ```bash curl -s -X PATCH https://app.adsly.pro/api/v1/campaigns/90412 \ -H "X-API-Key: $ADSLY_API_KEY" -H "Content-Type: application/json" \ -d '{ "account_id": 53, "schedule": { "days": { "mon": [9,10,11,12,13,14,15,16,17], "tue": [9,10,11,12,13,14,15,16,17], "wed": [9,10,11,12,13,14,15,16,17], "thu": [9,10,11,12,13,14,15,16,17], "fri": [9,10,11,12,13,14,15,16,17] }, "timezone": "+03:00" } }' ``` | Field | Meaning | |---|---| | `days` | `mon`, `tue`, `wed`, `thu`, `fri`, `sat`, `sun` — each holds the hours (0–23) the ad runs that day. `9` means 09:00–09:59, so `[9, …, 17]` is 9:00–18:00. A day you leave out, or send as `[]`, doesn't run. At least one hour in total. | | `timezone` | Whose clock the hours follow. `"viewer"` — each viewer's own local time; only for audience targeting (`target_type: "users"`). Otherwise a UTC offset: `"+03:00"`, `"-05:00"`, `"+05:30"`. The offsets Telegram offers: whole hours from `-12:00` to `+14:00`, plus `-09:30`, `-03:30`, `-02:30`, `+03:30`, `+04:30`, `+05:30`, `+05:45`, `+06:30`, `+08:45`, `+09:30`, `+10:30`, `+12:45`, `+13:45`. `"UTC+3"` and `"+3"` are accepted too. | `"schedule": null` removes the schedule — the ad runs around the clock again. **A campaign with no budget can't take a schedule.** Telegram accepts a schedule only on a campaign that can run, so on a `Stopped` campaign with nothing left you get `400 PLATFORM_REJECTED` with `param: "schedule"` and nothing is changed — add budget first, then set the schedule. A paused (`On Hold`) campaign takes it fine. The campaign object returns the schedule in the same shape, always with all seven days and the offset written as `"+03:00"` — so you can read it, change a day and send it back. `GET /v1/campaigns/{ad_id}` gives the current schedule even if it was changed directly in Telegram — see [Freshness](https://adsly.pro/docs/api/campaigns.md). Hours outside 0–23, an unknown day, an empty schedule, `"viewer"` on a channel/bot/search ad or an offset Telegram doesn't offer → `400 VALIDATION_ERROR` with the exact field in `param` (for example `schedule.days.mon`). [Copies](#copy) and recreated campaigns keep the schedule of the campaign they come from. To put one schedule on many campaigns at once, use the bulk action `set_schedule`. ## Budget `POST /v1/campaigns/{ad_id}/budget` moves money between the cabinet balance and the campaign. Requires an `Idempotency-Key`. ```bash curl -s -X POST https://app.adsly.pro/api/v1/campaigns/90412/budget \ -H "X-API-Key: $ADSLY_API_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "account_id": 53, "action": "add", "amount": 10 }' ``` | `action` | `amount` | Effect | |---|---|---| | `add` | required | Cabinet balance → campaign. | | `withdraw` | required | Campaign → cabinet balance. More than the campaign has → `400 INSUFFICIENT_AD_BALANCE`. | | `withdraw_all` | — | Everything left on the campaign → cabinet balance. | `meta.moved` is what actually moved; `meta.partial` is `true` when less than asked could be withdrawn. Money never leaves the cabinet through the API. ## Copy `POST /v1/campaigns/{ad_id}/copy` creates copies with the same targeting; anything you send overrides the original. Requires an `Idempotency-Key`. Returns a task whose `ad_ids` are the copies. ```json { "account_id": 53, "copies": 3, "text": "Variant B — shorter", "cpm": 2, "budget": 5 } ``` | Field | Meaning | |---|---| | `copies` | 1–100, default 1. | | `title`, `text`, `promote_url`, `cpm`, `budget`, `media`, `picture`, `views_per_user` | Overrides for every copy. `budget`: `0` or at least the cabinet's `min_budget`. | | `schedule` | Copies keep the original's [schedule](#schedule); send one to replace it, or `null` for none. | | `group_id` / `new_group_name` | Put the copies in a group. | ## Delete ```bash curl -s -X DELETE "https://app.adsly.pro/api/v1/campaigns/90412?account_id=53" -H "X-API-Key: $ADSLY_API_KEY" ``` ```json { "success": true, "data": { "ad_id": 90412, "account_id": 53, "deleted": true } } ``` A deleted campaign that had views stays in your stats with `status: "Deleted"`; one that never ran is removed. Deleting again returns `404 CAMPAIGN_NOT_FOUND`. ## Bulk `POST /v1/campaigns/bulk` applies one action to up to 1,000 campaigns of one cabinet. Requires an `Idempotency-Key`. Returns a task. ```bash curl -s -X POST https://app.adsly.pro/api/v1/campaigns/bulk \ -H "X-API-Key: $ADSLY_API_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "account_id": 53, "action": "set_cpm", "cpm": 1.2, "ad_ids": [90412, 90413, 90414] }' ``` | `action` | Extra field | Effect | |---|---|---| | `pause` | — | switch off | | `resume` | — | switch on | | `add_budget` | `amount` | add to each campaign | | `withdraw_budget` | `amount` | withdraw from each campaign | | `withdraw_all_budget` | — | withdraw everything from each campaign | | `set_cpm` | `cpm` | set the same bid on each | | `set_schedule` | `schedule` | set the same [schedule](#schedule) on each; `"schedule": null` removes it | | `delete` | — | delete each | Every `ad_id` must be a live campaign of that cabinet; otherwise nothing is queued and you get `400 UNKNOWN_AD_IDS` with the offenders in `missing`. Duplicates are ignored. The task's `errors` lists campaigns Telegram refused — with `set_schedule`, also campaigns with no budget and campaigns where `"timezone": "viewer"` can't apply because they don't target an audience. ## Recipe: pause campaigns whose cost per action is too high ```python import os, uuid, requests API = "https://app.adsly.pro/api/v1" H = {"X-API-Key": os.environ["ADSLY_API_KEY"]} ACCOUNT, MAX_CPA = 53, 4.0 stats = requests.get(f"{API}/stats", params={"period": "1d"}, headers=H, timeout=60).json()["data"] # cost per action = spend / actions, computed here so the rule means exactly that expensive = [int(ad_id) for ad_id, s in stats.get(str(ACCOUNT), {}).items() if s["actions"] > 0 and s["spent"] / s["actions"] > MAX_CPA] if expensive: r = requests.post(f"{API}/campaigns/bulk", headers={**H, "Idempotency-Key": str(uuid.uuid4())}, json={"account_id": ACCOUNT, "action": "pause", "ad_ids": expensive}, timeout=60) print(r.status_code, r.json()["data"]["id"]) ``` --- Page: https://adsly.pro/docs/api/manage-campaigns/ · Updated: 2026-10-08 > Adsly docs index: https://adsly.pro/docs/llms.txt · every page in one file: https://adsly.pro/docs/llms-full.txt · OpenAPI: https://adsly.pro/docs/api/openapi.yaml # Stats GET /v1/stats per campaign, /v1/stats/total for cabinet totals and charts, /v1/stats/analytics for currency split and top campaigns — periods, custom date and hour ranges. Three endpoints, from detailed to summary. All return data per cabinet, for every cabinet the key reaches. | Endpoint | Returns | |---|---| | `GET /v1/stats` | every campaign's numbers for the period | | `GET /v1/stats/total` | each cabinet's totals plus chart points | | `GET /v1/stats/analytics` | totals split by currency, chart, top campaigns | One campaign over a period: [`GET /v1/campaigns/{ad_id}/history`](https://adsly.pro/docs/api/campaigns.md). ## Periods | `period` | Window | |---|---| | `1d` | last 24 hours (default for `/stats` and `/stats/total`) | | `7d` | last 7 days | | `30d` | last 30 days (default for `/stats/analytics`) | | `all` | all time | | `custom` | `startDate` and `endDate` (`YYYY-MM-DD`), optionally `startHour` and `endHour` (0–23) | Any `h` or `d` also works (`6h`, `14d`, up to 365 days). An unrecognised value is read as the last 24 hours — it doesn't return an error, so check your spelling. All times are UTC. ```bash # 14:00–15:59 UTC on 8 October curl -s "https://app.adsly.pro/api/v1/stats?period=custom&startDate=2026-10-08&endDate=2026-10-08&startHour=14&endHour=15" \ -H "X-API-Key: $ADSLY_API_KEY" ``` ## GET /v1/stats Per campaign, nested by cabinet: `data[account_id][ad_id]`. ```json { "success": true, "data": { "53": { "90412": { "views": 640, "clicks": 7, "actions": 3, "spent": 0.96, "ctr": 1.09, "cpm": 1.5, "cpa": 0.32, "group_id": 7 } } }, "meta": { "period": "7d" } } ``` Keys are strings; values here are **numbers** (unlike the campaign object). `cpa` is spend per action; `ctr` is a percentage. ## GET /v1/stats/total The dashboard endpoint: totals and a chart for each cabinet. ```json { "success": true, "data": { "accounts": { "53": { "chart": [ { "label": "2026-10-08 12:00", "views": 52, "clicks": 1, "spent": 0.83, "budget": 4.95, "ctr": 1.92, "cpc": 0.83, "cpm": 16 } ], "totals": { "views": 121, "clicks": 3, "spent": 1.81, "ctr": 2.48, "cpc": 0.6, "cpm": 14.95, "campaigns": 4, "active": 2, "stopped": 2, "declined": 0, "totalBudget": 5.21, "totalSpentAllTime": 2.79 }, "cabinetType": "euro" } }, "period": "1d", "lastUpdated": "2026-10-08T10:02:54.016Z" }, "meta": { "period": "1d" } } ``` `chart` points are hourly or daily depending on the period. `cabinetType` gives the currency (`euro` → EUR, `ton` → TON, `stars` → Stars). ## GET /v1/stats/analytics Like `/stats/total`, plus spend split by currency and the top campaigns. ```json { "success": true, "data": { "accounts": { "53": { "chart": [ { "label": "2026-10-08 12:00", "views": 52, "clicks": 1, "spent": 0.83, "spent_ton": 0, "spent_euro": 0.83, "spent_stars": 0, "ctr": 1.92, "cpc": 0.83, "cpm": 16 } ], "totals": { "views": 121, "clicks": 3, "spent": 1.81, "spentByCurrency": { "ton": 0, "euro": 1.81, "stars": 0 }, "ctr": 2.48, "cpc": 0.6, "cpm": 14.95 }, "topCampaigns": [ { "ad_id": 90412, "account_id": 53, "title": "API test", "status": "Active", "views": 58, "clicks": 1, "spent": 0.85, "ctr": 1.72, "cpc": 0.85 } ] } } }, "meta": { "period": "30d" } } ``` ## Notes - Stats follow Telegram's own counters, synced to Adsly regularly; the newest hour fills in as syncs arrive. - Never add up `spent` across cabinets with different currencies. - For conversions you track yourself (leads, deposits, sales) see [Conversions](https://adsly.pro/docs/api/conversions.md) — Telegram's `actions` are joins and bot starts, not your conversions. - Prefer the [hourly digest webhook](https://adsly.pro/docs/api/webhooks.md) over polling stats every few minutes. --- Page: https://adsly.pro/docs/api/stats/ · Updated: 2026-10-08 > Adsly docs index: https://adsly.pro/docs/llms.txt · every page in one file: https://adsly.pro/docs/llms-full.txt · OpenAPI: https://adsly.pro/docs/api/openapi.yaml # Targeting codes and groups GET /v1/targeting lists the country, language and topic codes and ad button values campaigns accept; GET /v1/groups lists campaign groups. ## GET /v1/targeting The codes [`POST /v1/campaigns`](https://adsly.pro/docs/api/create-campaigns.md) accepts. The lists are the ones the Adsly create form uses, so a code from here is never refused as unknown. ```bash curl -s https://app.adsly.pro/api/v1/targeting -H "X-API-Key: $ADSLY_API_KEY" ``` ```json { "success": true, "data": { "countries": [ { "code": "DE", "name": "Germany" }, { "code": "US", "name": "United States" } ], "languages": [ { "code": "en", "name": "English" }, { "code": "zh-hans", "name": "Chinese (Simplified)" } ], "topics": [ { "id": 7, "name": "Cryptocurrencies" }, { "id": 26, "name": "Investments" } ], "buttons": [ "subscribe", "view", "read", "learn_more", "download", "open", "sign_up", "buy", "order", "play", "try", "leave_request" ] } } ``` | List | Used in | Notes | |---|---|---| | `countries[].code` | `countries` | ISO 3166 two-letter codes, plus Telegram's own `FT` (anonymous numbers). | | `languages[].code` | `languages` | Telegram's language keys — e.g. `pt-br` and `pt-pt`, not `pt`. | | `topics[].id` | `topics`, `exclude_topics` | Numbers; the ids are not consecutive. | | `buttons` | `button` | Only in cabinets whose `features.ad_button` is `true`. | The lists change rarely — fetch them once and cache them for a day. ## Groups Groups are folders for campaigns in the panel. Put new campaigns or copies into one with `group_id` (existing) or `new_group_name` (created, or reused if the name exists). ### GET /v1/groups ```bash curl -s "https://app.adsly.pro/api/v1/groups?account_id=53" -H "X-API-Key: $ADSLY_API_KEY" ``` ```json { "success": true, "data": [ { "id": 7, "account_id": 53, "name": "October test", "color": "#6366f1" } ] } ``` Without `account_id` you get the groups of every cabinet the key reaches. A group belongs to one cabinet — `group_id` from another cabinet is refused. --- Page: https://adsly.pro/docs/api/targeting/ · Updated: 2026-10-08 > Adsly docs index: https://adsly.pro/docs/llms.txt · every page in one file: https://adsly.pro/docs/llms-full.txt · OpenAPI: https://adsly.pro/docs/api/openapi.yaml # Webhooks Signed HTTPS callbacks from Adsly — status_change the moment a campaign changes status, hourly_digest with every hour's numbers — headers, payloads, signature verification in Node.js and Python, retries. Instead of polling, let Adsly call you. Set a **Postback URL** on a key (panel → API & Webhooks → the key's card) and every event for the cabinets that key reaches is POSTed there, signed with the key's webhook secret. ## Events | Event | When | |---|---| | `status_change` | A campaign's status changed (In Review → Active, Active → Stopped…). Sent after each sync of the cabinet, and immediately when campaigns are switched on or off in the panel or through the API. | | `hourly_digest` | At 5 minutes past each hour, with the numbers of the hour that just closed — one POST per cabinet. Hours with no views, clicks or spend are skipped. | | `ping` | When you press **Test** on the key. | ### status_change ```json { "event": "status_change", "delivered_at": "2026-10-08T12:00:05.123Z", "account_id": 53, "changes": [ { "ad_id": 90412, "tme_path": "yourchannel", "old_status": "In Review", "new_status": "Active" }, { "ad_id": 90388, "tme_path": "yourbot?start=promo", "old_status": "Active", "new_status": "Stopped" } ] } ``` ### hourly_digest ```json { "event": "hourly_digest", "delivered_at": "2026-10-08T14:05:00.000Z", "period": { "start": "2026-10-08T13:00:00.000Z", "end": "2026-10-08T14:00:00.000Z" }, "account_id": 53, "totals": { "views": 18420, "clicks": 137, "spent": 12.456, "actions": 9 }, "campaigns": [ { "ad_id": 90412, "tme_path": "yourchannel", "title": "Crypto channels — notes", "status": "Active", "views": 9100, "clicks": 73, "spent": 6.221, "actions": 4, "cpm": "0.68", "budget": "100.00" } ] } ``` `campaigns` holds at most the top 200 by spend; when more campaigns moved, a `truncated` block says how many were left out. `totals` always cover all of them. Money is in the cabinet's currency. ## Headers ``` Content-Type: application/json User-Agent: Adsly-Webhook/1.0 (+https://adsly.pro) X-Adsly-Event: status_change | hourly_digest | ping X-Adsly-Delivery: X-Adsly-Timestamp: X-Adsly-Signature: sha256= ``` ## Verify the signature The signature is HMAC-SHA256 of `.` with the key's webhook secret (shown when the key is created or the secret rotated). Verify it over the **raw** body — parsing and re-serialising the JSON changes the bytes. Reject requests older than 5 minutes. ### Node.js (Express) ```js const express = require('express'); const crypto = require('crypto'); const app = express(); const SECRET = process.env.ADSLY_WEBHOOK_SECRET; app.post('/adsly-webhook', express.raw({ type: 'application/json', limit: '2mb' }), (req, res) => { const ts = req.get('X-Adsly-Timestamp'); const sig = req.get('X-Adsly-Signature') || ''; if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400); const expected = 'sha256=' + crypto.createHmac('sha256', SECRET) .update(`${ts}.${req.body.toString('utf8')}`) .digest('hex'); const ok = sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)); if (!ok) return res.sendStatus(401); const event = JSON.parse(req.body.toString('utf8')); res.sendStatus(200); // answer fast, then do the work queue.push(event); // e.g. hand off to a job queue }); ``` ### Python (Flask) ```python import hmac, hashlib, time, os from flask import Flask, request, abort app = Flask(__name__) SECRET = os.environ["ADSLY_WEBHOOK_SECRET"].encode() @app.post("/adsly-webhook") def adsly_webhook(): ts = request.headers.get("X-Adsly-Timestamp", "") sig = request.headers.get("X-Adsly-Signature", "") if not ts or abs(time.time() - int(ts)) > 300: abort(400) body = request.get_data() # raw bytes expected = "sha256=" + hmac.new(SECRET, ts.encode() + b"." + body, hashlib.sha256).hexdigest() if not hmac.compare_digest(sig, expected): abort(401) event = request.get_json() # … queue the work … return "", 200 ``` ## Delivery - Your endpoint must be public **HTTPS**. `http://`, localhost and private addresses are refused. - Answer with any `2xx` within **10 seconds**. Do slow work after answering. - On a network error or a `5xx` we retry **once**, 750 ms later, with the same `X-Adsly-Delivery`. A `4xx` is not retried. - Redirects are not followed. - The key's card in the panel shows the last delivery's time, status and error. ## Which cabinets A key receives events for the cabinets it reaches ([Keys and access](https://adsly.pro/docs/api/authentication.md)). An agency team member's key receives them for the cabinets shared with them, and stops the moment that access ends. ## Testing Press **Test** on the key's card: you receive a `ping` event signed like the real ones. ```json { "event": "ping", "delivered_at": "2026-10-08T12:00:00.000Z", "message": "This is a test ping from Adsly…", "key_id": 41 } ``` --- Page: https://adsly.pro/docs/api/webhooks/ · Updated: 2026-10-08 > Adsly docs index: https://adsly.pro/docs/llms.txt · every page in one file: https://adsly.pro/docs/llms-full.txt · OpenAPI: https://adsly.pro/docs/api/openapi.yaml # Conversions (postbacks in) Send leads, deposits and sales back to Adsly from your backend or tracker (Keitaro, RedTrack, Binom, Voluum) so every campaign shows its real results; read them back with GET /v1/conversions. Telegram counts views, clicks and joins. Your backend or tracker knows what happened next — a lead, a deposit, a sale. Send those events to Adsly and each campaign shows its conversions and revenue in the panel, in reports and in automation rules. ## Turn it on On the key's card in the panel, switch on **Receive postbacks**. Without it, sending returns `403 INGEST_DISABLED`. For a tracker, also press **Generate tracker URL** — you get a write-only URL (shown once) that can post conversions but can't read anything. ## How a conversion finds its campaign Each ad links somewhere with an identifier you control: - a bot or mini-app: `t.me/yourbot?start=REF` or `?startapp=REF` - a website: your tracker's click id or sub id in the link, e.g. `https://site.com/?subid=REF` When you send a conversion, include that `REF` as `ref`. Adsly finds the campaign whose link carries it, in the cabinets the key reaches. If several campaigns of one cabinet share the same `ref`, the conversion counts for each of them. ## From your backend: POST /v1/postback ```bash curl -s -X POST https://app.adsly.pro/api/v1/postback \ -H "X-API-Key: $ADSLY_API_KEY" -H "Content-Type: application/json" \ -d '{ "ref": "promo_oct", "event": "purchase", "amount": 49.90, "currency": "USD", "txid": "order-10087" }' ``` ```json { "success": true, "data": { "id": 551203, "ad_id": 90412, "account_id": 53, "event_type": "purchase", "deduped": false } } ``` Up to 1,000 events at once: `{ "events": [ {…}, {…} ] }` (or a bare array). The response reports each one — `{ "accepted": 998, "failed": 2, "results": [ … ] }` — so one bad event never sinks the batch. ## From a tracker: GET /v1/ingest/{token} Paste the tracker URL into your tracker's postback settings, with its macros: ```text # Keitaro https://app.adsly.pro/api/v1/ingest/?ref={subid}&event={status}&amount={payout}¤cy={currency}&txid={subid}_{status} # RedTrack https://app.adsly.pro/api/v1/ingest/?ref={clickid}&event={type}&status={status}&amount={sum}&txid={rdtk_event_id} # Binom https://app.adsly.pro/api/v1/ingest/?ref={clickid}&event={status}&amount={payout}&txid={clickid} # Voluum https://app.adsly.pro/api/v1/ingest/?ref={clickid}&event={et}&amount={payout}¤cy={currency}&txid={txid} ``` Check the macro names against your tracker's own documentation. The answer is plain text: `OK`, `OK (deduped)`, or an error message with its HTTP status (4xx — don't retry, 5xx — retry). ## Fields | Field | Also accepted as | Required | Meaning | |---|---|---|---| | `ref` | `subid`, `sub_id`, `clickid`, `click_id`, `cnv_id`, `cid` | yes¹ | The identifier from the ad's link. Up to 512 characters. | | `invite_link` | `invitelink`, `link` | yes¹ | For channel ads: the unique invite link Adsly created. | | `event` | `event_type`, `type`, `goal`, `cnv_status` | yes | Becomes `lead`, `conversion` or `purchase` (words like `signup`, `ftd`, `deposit`, `sale` are understood). Unknown words are refused. | | `txid` | `tid`, `transaction_id`, `order_id`, `rdtk_event_id`, header `X-Idempotency-Key` | yes | Your id for this event. Sending the same `txid` again updates the event instead of counting it twice. Up to 128 characters. | | `amount` | `payout`, `sum`, `revenue`, `value` | no | Number; negative for chargebacks. | | `currency` | `cur`, `cnv_currency` | no | ISO 4217 code. Without one, an amount is recorded as USD. | | `status` | — | no | Your network's own status word (approved, pending…), stored as is. | | `event_time` | `timestamp`, `date` | no | ISO or unix time; kept within the last 90 days. | | `account_id` | `accountId` | sometimes | Needed when the same `ref` exists in several of your cabinets (`409` otherwise). | | `meta` | — | no | Your own JSON object, up to 4 KB. | ¹ `ref` or `invite_link`. ## Limits and retries 20 events per second per key, with bursts up to 1,000 — separate from the read and write budgets. On `429` wait `Retry-After`; retrying is always safe because `txid` deduplicates. ## GET /v1/conversions Totals per campaign, counted once per event: ```bash curl -s "https://app.adsly.pro/api/v1/conversions?days=30" -H "X-API-Key: $ADSLY_API_KEY" ``` ```json { "success": true, "data": [ { "account_id": 53, "ad_id": 90412, "leads": 12, "conversions": 3, "purchases": 1, "revenue": { "USD": 49.9 } } ] } ``` | Parameter | Meaning | |---|---| | `adId` | One campaign. | | `days` | Only events received in the last 1–365 days. | - `revenue` is per currency — amounts are never converted. - `ad_id` is `null` for events matched by a `ref` that several campaigns share. - Amounts you send are your own reporting. They are not billed and don't change anything you pay Adsly. --- Page: https://adsly.pro/docs/api/conversions/ · Updated: 2026-10-08 > Adsly docs index: https://adsly.pro/docs/llms.txt · every page in one file: https://adsly.pro/docs/llms-full.txt · OpenAPI: https://adsly.pro/docs/api/openapi.yaml # Versioning and changelog How the Adsly API changes without breaking integrations — what counts as a breaking change, how new fields are added — and every change so far. ## Versioning The version is in the path: `/api/v1`. Within `v1` we only make **additive** changes: | Additive (can happen any time) | Breaking (only in a new version) | |---|---| | new endpoints | removing or renaming an endpoint or field | | new optional request fields | a new required field | | new response fields | changing a field's type or meaning | | new values in enums (`status`, error `code`) | removing an enum value | | new error codes | making a valid request invalid | So write clients that **ignore fields they don't know** and **handle unknown enum values and error codes** gracefully. A breaking change would ship as `/api/v2`, announced here first, with `v1` kept running alongside it. ## Changelog ### 2026-10-08 — ad schedule - **New: `schedule`** — show a campaign only on chosen weekdays and hours. Set it on create, `PATCH /v1/campaigns/{ad_id}`, copy, or many campaigns at once with the bulk action **`set_schedule`**; `null` removes it. `GET /v1/campaigns/{ad_id}` returns it. Format: [Schedule](https://adsly.pro/docs/api/manage-campaigns.md). It doesn't switch the campaign on or off. - Pausing, resuming and editing a campaign keep its schedule; copies and recreated campaigns inherit it. - **Fix:** `PATCH /v1/campaigns/{ad_id}` with only `cpm`, `active` and/or `schedule` sent the campaign back to Telegram's review. These now go through Telegram's own bid and status forms and never trigger a review; other fields still do (Telegram's rule) — see [Edit](https://adsly.pro/docs/api/manage-campaigns.md). - **Fix:** setting a schedule on a campaign with no budget could send it back to review. Such a campaign now answers `400` with `param: "schedule"` and is left untouched — add budget first, then set the schedule. - **`GET /v1/campaigns/{ad_id}` is now current:** it re-reads the ad from Telegram when our copy is older than 5 minutes, so `schedule`, `text` and `views_per_user` reflect changes made directly in Telegram. New field **`synced_at`** says when the ad was last read. See [Freshness](https://adsly.pro/docs/api/campaigns.md). ### 2026-10-08 — campaign management - **New: write access.** Keys can now carry **Manage campaigns**: `POST /v1/campaigns` (create, up to 100 per call), `PATCH /v1/campaigns/{ad_id}`, `POST …/pause`, `…/resume`, `…/budget`, `…/copy`, `POST /v1/campaigns/bulk`, `DELETE /v1/campaigns/{ad_id}`, `POST /v1/media`. Existing keys stay read-only until you switch it on. - **New reads:** `GET /v1/campaigns/{ad_id}` (one campaign with targeting), `GET /v1/tasks/{id}`, `GET /v1/groups`, `GET /v1/targeting`. - **Agency team members** can create keys; a key reaches the cabinets shared with the member, at their access level. The agency owner sees all team keys (API & Webhooks → Team keys) and can revoke them. - **`Idempotency-Key`** required on create, copy, bulk and budget. - **Separate write rate limit:** 30 requests per minute, apart from the 60 reads. - **`GET /v1/account/info`** adds `access_level`, `currency`, `balance`, `balance_updated_at`, `min_budget`, `features`. - **Errors** add `code` and `request_id` next to the unchanged `error` text. A `500` no longer includes internal error text. - `account_id` is accepted everywhere `accountId` was (both work). - **Fix:** `GET /v1/stats?period=all` returned cost per click as `cpa`; it is now cost per action, like every other period. ### 2026-10-04 - URL filters apply to both webhooks; `status_change` only reaches keys whose owner can see the cabinet. ### 2026-09-18 - Conversions get their own budget (20 events/s) apart from the read limit; batches of up to 1,000 events. ### 2026-06-30 - Inbound conversions: `POST /v1/postback`, `GET /v1/ingest/{token}`, `GET /v1/conversions`. ### Earlier - `GET /v1/campaigns` with cursor pagination and the `bot_username`, `promote_domain` and `type` filters; stats endpoints; `status_change` and `hourly_digest` webhooks. --- Page: https://adsly.pro/docs/api/changelog/ · Updated: 2026-10-08