API & Webhooks
API Access requires a Pro or Agency plan
Programmatic access to your cabinets — read stats and, when you allow it, manage campaigns — plus HMAC-signed webhooks for status changes and hourly stats.
See plansYour API keys
Each key is a long-lived secret. Use it in the X-API-Key header. A key is read-only until you turn on “Manage campaigns” for it.
Save these now — they are shown only once
Closing this banner removes the values forever. You can rotate the webhook secret later, but the API key cannot be recovered.
Your inbound postback URL — shown only once
Paste this into your tracker's postback URL. The token is write-only — it can post conversions but cannot read your data. Rotating it invalidates the old URL.
Team keys
Keys your teammates created. Each key reaches only the cabinets you shared with that person, at their level. Revoke a key here; change what a person can reach on the Team page.
Documentation
Everything you need to integrate. Base URL: https://app.adsly.pro/api
Full documentation with examples, plus a version for AI assistants: adsly.pro/docs/api
1. Make your first request
Click + New key, pick which cabinets it should see, and copy the key. Then:
curl -H "X-API-Key: adsly_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
https://app.adsly.pro/api/v1/account/info
You'll get back a JSON array of your accounts. From there:
GET /v1/campaigns?cursor=— paginate every campaign (100 per page, cursor-based — scales to millions)GET /v1/stats/total?period=1d— aggregated metrics per account for the last dayGET /v1/stats?period=7d— per-campaign metrics
2. Loop through all campaigns (cursor pagination)
Treat meta.next_cursor as opaque. Keep calling while it's non-null:
let cursor = '';
do {
const r = await fetch(`https://app.adsly.pro/api/v1/campaigns?limit=100&cursor=${cursor}`,
{ headers: { 'X-API-Key': KEY } });
const { data, meta } = await r.json();
for (const c of data) handleCampaign(c);
cursor = meta.next_cursor || '';
} while (cursor);
3. Or get postbacks instead of polling
Set a Postback URL on a key and we'll POST signed JSON to it whenever a campaign status changes (real-time) and once an hour with a digest of every account's spend, views, and clicks. No polling needed.
PostgreSQL returns spent, budget, cpm, cpc, cpa, ctr as JSON strings (e.g. "6.221000") — preserves precision. Always parseFloat() before arithmetic. views, clicks, actions, opens are real numbers.
Read endpoints (every key)
?accountId= when the same ad_id exists in several accounts.
Campaign management (keys with “Manage campaigns”)
Create, copy, bulk and budget calls need an Idempotency-Key header (a fresh UUID per operation) so a retry never runs twice. Full reference with examples: adsly.pro/docs/api.
Filtering /v1/campaigns
Add any of these query params — they combine (AND). bot_username, promote_domain and type switch the endpoint to cursor pagination (follow meta.next_cursor).
| Parameter | Returns |
|---|---|
bot_username=name | Telegram-ads of one bot/channel — matches the username in tme_path. |
promote_domain=host | Website-ads whose landing page (promote_url) is on that host. Pass a bare domain or a full URL — scheme, www., path, query and port are ignored, case-insensitive. Exact host by default (example.com excludes sub.example.com). |
promote_domain_mode=exact|suffix | Default exact. Use suffix to also include subdomains of promote_domain. |
type=bot|channel|site | Filter by what the ad promotes: a bot/mini-app, a channel/group, or an external website. |
Response shape
Every response is { success: true, data: ..., meta?: { ... } } on success, { success: false, error: "..." } on failure.
Rate limit
60 read requests and 30 write requests per minute per key — a typical sync is 3–4 requests. Conversions you send to us run on a separate budget and never eat into either (see Receiving). Every response carries the live state so you can back off before you hit a 429:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 47
X-RateLimit-Reset: 1779188400 <-- unix seconds when the window resets
Retry-After: 23 <-- only on 429, seconds until you can retry
HTTP status codes
| Code | When | What to do |
|---|---|---|
| 200 | OK — request succeeded | — |
| 400 | Invalid parameter (bad cursor or filter value, or no accountId when the same ad_id exists in several accounts). An unrecognised period is not an error — it is read as the last 24 hours. | Read error field and fix the query |
| 401 | Missing or invalid X-API-Key (key revoked, expired, never existed) | Rotate the key in the dashboard |
| 404 | Resource not visible to this key (campaign in another cabinet, accountId not in scope) | Verify scope on the dashboard |
| 429 | Rate limit exceeded | Wait Retry-After seconds, then retry |
| 500 | Server bug — already logged on our side | Retry with exponential backoff; if persistent, contact support |
Campaign status values
The status field on every campaign (and on every status_change webhook) is one of:
| Status | What it means |
|---|---|
| Active | Running, accumulating views/clicks/spend |
| In Review | Waiting for Telegram's review. Not shown until approved. |
| Declined | Telegram rejected the ad (creative/policy). Won't run until edited and resubmitted. |
| Paused | Rare. Paused on Telegram's side for the whole cabinet, not by a switch on this ad. |
| Stopped | Budget used up, nothing is being shown. Add budget to run it again. |
| On Hold | Switched off (paused) — by you or by an automation rule. Switch it back on to resume. |
| Deleted | Removed. Excluded from listings by default. Historical analytics still reference the ad_id. |
Important — ad_id is not globally unique
Telegram reuses ad_id across cabinets. Always pair ad_id with account_id when storing or joining data on your side.
Two delivery channels
- status_change — fired when a campaign's status changes (Active / On Hold / In Review / Declined / Stopped…): one POST per account per sync, and right away when you switch campaigns on or off in the panel.
- hourly_digest — fired 5 minutes past every hour with views/clicks/spend/actions per campaign for the closed hour.
- ping — manual test from the "Test" button on a key card.
Headers we send
Content-Type: application/json
User-Agent: Adsly-Webhook/1.0 (+https://adsly.pro)
X-Adsly-Event: status_change | hourly_digest | ping
X-Adsly-Delivery: <uuid, same on the retry — dedupe on it>
X-Adsly-Timestamp: <unix seconds>
X-Adsly-Signature: sha256=<hex>
Verify the signature (Node.js)
const crypto = require('crypto');
function verify(req, secret) {
const ts = req.header('X-Adsly-Timestamp');
const sig = req.header('X-Adsly-Signature');
if (!ts || !sig) return false;
// Reject replays older than 5 minutes
if (Math.abs(Date.now()/1000 - parseInt(ts,10)) > 300) return false;
const expected = 'sha256=' +
crypto.createHmac('sha256', secret)
.update(ts + '.' + req.rawBody) // raw body, NOT JSON.parse
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}
Verify the signature (Python / Flask)
import hmac, hashlib, time
def verify(req, secret):
ts = req.headers.get('X-Adsly-Timestamp', '')
sig = req.headers.get('X-Adsly-Signature', '')
if not ts or not sig: return False
if abs(time.time() - int(ts)) > 300: return False
expected = 'sha256=' + hmac.new(
secret.encode(),
(ts + '.' + req.get_data(as_text=True)).encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(sig, expected)
Example payloads
status_change:
{
"event": "status_change",
"delivered_at": "2026-05-18T14:23:11.123Z",
"account_id": 1234,
"changes": [
{ "ad_id": 9876, "tme_path": "yourchannel", "old_status": "In Review", "new_status": "Active" }
]
}
hourly_digest:
{
"event": "hourly_digest",
"delivered_at": "2026-05-18T14:05:00.000Z",
"period": { "start": "2026-05-18T13:00:00.000Z", "end": "2026-05-18T14:00:00.000Z" },
"account_id": 1234,
"totals": { "views": 18420, "clicks": 137, "spent": 12.456, "actions": 9 },
"campaigns": [
{ "ad_id": 9876, "tme_path": "yourchannel", "title": "Crypto wallet promo",
"status": "Active", "views": 9100, "clicks": 73, "spent": 6.221, "actions": 4,
"cpm": "0.68", "budget": "100.00" }
]
}
Retries
We retry once after 750ms if the request fails or returns 5xx. 4xx responses are not retried — fix the receiver, then trigger another delivery with the Test button. The retry carries the same X-Adsly-Delivery UUID as the first attempt, so dedupe on it.
If a cabinet had no views, no clicks, no spend, and no status changes in the closed hour, no digest is sent. That's intentional — empty pings would pollute your logs. If you're not getting webhooks, first check the dashboard: if there's no activity, there's nothing to send.
If a cabinet has more than 200 active campaigns in the hour, the digest includes the top 200 by spend and adds a truncated block with the omitted count. totals are always computed over the full set. To get the long tail, pull /v1/stats when you see the truncated field.
Minimal Express receiver (copy/paste runnable)
const express = require('express');
const crypto = require('crypto');
const app = express();
const SECRET = process.env.ADSLY_WEBHOOK_SECRET; // copy from the dashboard
// CRITICAL: HMAC is computed over the RAW body. express.json() would re-stringify
// and break the signature. Use express.raw() and parse manually after verifying.
app.post('/adsly-webhook',
express.raw({ type: 'application/json', limit: '2mb' }),
(req, res) => {
const ts = req.header('X-Adsly-Timestamp');
const sig = req.header('X-Adsly-Signature');
if (!ts || !sig) return res.status(400).end();
// Replay protection — reject anything older than 5 minutes
if (Math.abs(Date.now()/1000 - parseInt(ts, 10)) > 300) return res.status(400).end();
const expected = 'sha256=' +
crypto.createHmac('sha256', SECRET)
.update(ts + '.' + req.body.toString('utf8'))
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString('utf8'));
console.log(event.event, event.account_id);
// …your business logic here…
res.status(200).end(); // ALWAYS respond 2xx fast; queue work asynchronously
}
);
app.listen(3000);
Receiver requirements
- Public HTTPS endpoint (http:// is rejected). Private/loopback IPs are blocked.
- Respond with any 2xx within 10 seconds.
- Reject requests where the signature doesn't match — this is how you know the request is from us.
- Reject requests where
|now − X-Adsly-Timestamp| > 5 min— prevents replay attacks.
Receive conversions from your tracker
When a user who clicked your Telegram ad converts (registers, deposits, buys), send that event back to us so it shows up on the campaign. We match it to the campaign by the ref carried in the ad's link: the ?start=/?startapp= payload for bot ads, or your tracker's own parameter for website ads (?rfids=, ?subid= — any name works). Channel invite links are matched too. Two ways to send:
On the key above, toggle Receive postbacks. For the tracker URL method, also click Generate tracker URL — it's shown once.
Option A — your own backend (POST + header)
Best when your bot/backend fires the event itself. Authenticate with the API key in the header.
curl -X POST https://app.adsly.pro/api/v1/postback \
-H "X-API-Key: adsly_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"ref":"abc123","event":"purchase","amount":"49.90","currency":"USD","txid":"order-7781"}'
Sending many at once
Put up to 1000 events in one request with events. Each event is charged separately against your budget, so a batch saves round-trips, not quota. One bad event does not sink the rest — you get a result per event and retry only what failed:
curl -X POST https://app.adsly.pro/api/v1/postback \
-H "X-API-Key: adsly_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"events":[
{"ref":"abc123","event":"lead","txid":"lead-551"},
{"ref":"def456","event":"purchase","amount":"49.90","currency":"USD","txid":"order-7781"}
]}'
# 202 Accepted
{"success":true,"data":{"accepted":2,"failed":0,"results":[
{"index":0,"ok":true,"id":"91021","ad_id":20941,"account_id":848,"event_type":"lead","deduped":false},
{"index":1,"ok":true,"id":"91022","ad_id":19978,"account_id":848,"event_type":"purchase","deduped":false}
]}}
Option B — your tracker (GET URL)
Keitaro, RedTrack, Binom, Voluum and others fire a plain GET. Paste the generated URL into the tracker's postback field — the token is write-only (it can post events but cannot read your data):
https://app.adsly.pro/api/v1/ingest/<token>?ref={REF}&event=purchase&amount={SUM}¤cy=USD&txid={TXID}
Parameters
| Parameter | Accepted aliases | Required | Meaning |
|---|---|---|---|
ref | subid, clickid, click_id, cnv_id, cid | yes* | The value carried in the ad's link: the ?start=/?startapp= payload (bot ads) or your tracker parameter's value (website ads, e.g. ?rfids=abc123). |
invite_link | link | yes* | Alternative to ref for channels: the unique invite link we created. |
event | type, goal, event_type | yes | One of lead / conversion / purchase (aliases mapped — see below). |
txid | tid, transaction_id, order_id, rdtk_event_id | yes | Your transaction id. Same txid updates the event; a new txid records a new one. Prevents double-counting on retries. |
amount | payout, sum, revenue, value | no | Revenue. Decimals; negatives allowed (chargebacks). |
currency | cur | no | ISO-4217 (USD, EUR, …). Optional: an amount sent without it is recorded as USD. Revenue is summed per currency — we never convert it. |
status | — | no | Your network's own status word (approved/pending/…). Stored verbatim. |
account_id | — | only if asked | Needed only when one key spans several cabinets that reused the same ref (you'll get a 409 telling you). |
* Send either ref or invite_link. The token (Option B) or the API key (Option A) is the auth — no extra signature needed.
Event types
- lead — a sign-up / registration / opt-in. Aliases: signup, registration, reg, optin, trial.
- conversion — a qualifying action / deposit / install / subscribe. Aliases: deposit, ftd, install, subscribe, join.
- purchase — a sale / payment (carries revenue). Aliases: sale, buy, payment, order, rebill, upsell.
How fast you can send
20 events per second per key sustained, with a burst of 1000 held in reserve — a spike of conversions goes through instead of bouncing. Reading your stats runs on its own separate budget, so a tracker firing leads never slows your dashboard down. Need more throughput? Ask us and we raise it on your key. If you ever do get a 429, wait the Retry-After seconds and re-send: nothing is counted twice, the txid takes care of that.
Ready-to-paste tracker templates
Put your tracker's own click-id macro into ref and its payout/status macros into amount/status. The click-id must be the exact value carried in the ad's link — the ?start= payload or your tracker param's value.
# Keitaro (Postback URL → custom)
https://app.adsly.pro/api/v1/ingest/<token>?ref={subid}&event={status}&amount={payout}¤cy={currency}&txid={subid}_{status}
# RedTrack (Offer source → Postback)
https://app.adsly.pro/api/v1/ingest/<token>?ref={clickid}&event={type}&status={status}&amount={sum}&txid={rdtk_event_id}
# Binom (Campaign → S2S/Postback)
https://app.adsly.pro/api/v1/ingest/<token>?ref={clickid}&event={status}&amount={payout}&txid={clickid}
# Voluum (Postback URL)
https://app.adsly.pro/api/v1/ingest/<token>?ref={clickid}&event={et}&amount={payout}¤cy={currency}&txid={txid}
- Reuse one
refacross several campaigns? The conversion is attributed to all of them that share it — Telegram can't tell which creative was clicked. - An unknown
refreturns 404 — we never invent a conversion for a click we didn't run. - Revenue you send is your own reported number — it's shown on your campaigns and never mixed into our billing.
- Rate limit: 20 events/sec per key with a burst of 1,000, on a budget of its own — separate from the read API's 60 requests/min.
Threat model
- Keys are read-only by default. Only a key with “Manage campaigns” turned on can create, edit, pause or delete campaigns and move budget between the cabinet balance and its campaigns. No key can take money out of a cabinet.
- Keys are hashed (SHA-256) in our database. We can't recover a lost key — only revoke and issue a new one.
- Webhook URLs must be HTTPS. We refuse internal hostnames, loopback, private and link-local IPs.
- Webhook payloads are HMAC-SHA256 signed with a per-key secret. Always verify before trusting the body.
Best practices
- One key per integration. Revoke the key, not the whole service, when an integration goes away.
- Store keys in env vars / secret managers. Never commit them to git or paste in chats.
- Rotate the webhook secret if you suspect the receiver's storage was compromised — the API key keeps working.
- Build dedupe on
X-Adsly-Deliveryif you're processing events more than once (rare, but possible during our retry).