Recipes
Ready-made scripts for common media-buying tasks. Each one is built from the methods described on the neighbouring pages - copy it, fill in your values at the top of the script and run it.
In every recipe, replace YOUR_KEY and panel.example.com with your own. How to get a key - see the "API introduction" section.
Dates are in UTC
Stats and conversions are counted by UTC days. "Yesterday" in the scripts below is yesterday in UTC, not in your time zone.
Yesterday's stats to Telegram
Every morning, sends yesterday's totals to Telegram: visits, installs, registrations, deposits, payouts and conversion rate.
What you need:
- Your own bot: send @BotFather the
/newbotcommand and copy the token. - The chat ID to send to: send the bot any message, open
https://api.telegram.org/bot<TOKEN>/getUpdatesand takechat.idfrom the response. For a group - add the bot to the group, post a message there and take the group'schat.id(it is negative).
# pip install requests
import datetime
import requests
API = "https://panel.example.com/api"
HEADERS = {"Authorization": "Bearer YOUR_KEY", "Accept": "application/json"}
BOT_TOKEN = "BOT_TOKEN_HERE"
CHAT_ID = "CHAT_ID_HERE"
day = (datetime.datetime.now(datetime.timezone.utc).date() - datetime.timedelta(days=1)).isoformat()
response = requests.get(f"{API}/stats/dashboard", headers=HEADERS, params={
"date_from": day,
"date_to": day,
}, timeout=30)
response.raise_for_status()
metrics = response.json()["data"]["metrics"]
payouts = ", ".join(f'{a["amount"]} {a["currency"]}' for a in metrics["total_deposits"]["amounts"]) or "0"
text = "\n".join([
f"PWA Hub for {day}",
f"Visits: {metrics['total_visits']['value']} ({metrics['total_visits']['trend']}%)",
f"Installs: {metrics['total_installs']['value']} ({metrics['total_installs']['trend']}%)",
f"Registrations: {metrics['total_registrations']['value']}",
f"Deposits: {metrics['total_deposits']['value']} - {payouts}",
f"CR: {metrics['avg_cr']['value']}%",
])
requests.post(f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage", json={
"chat_id": CHAT_ID,
"text": text,
}, timeout=30).raise_for_status()
print("Sent:\n" + text)// Node.js 18+, no dependencies; save as stats-telegram.mjs
const API = 'https://panel.example.com/api';
const headers = { Authorization: 'Bearer YOUR_KEY', Accept: 'application/json' };
const BOT_TOKEN = 'BOT_TOKEN_HERE';
const CHAT_ID = 'CHAT_ID_HERE';
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
const day = yesterday.toISOString().slice(0, 10);
const response = await fetch(`${API}/stats/dashboard?${new URLSearchParams({ date_from: day, date_to: day })}`, {
headers,
signal: AbortSignal.timeout(30000),
});
if (!response.ok) throw new Error(`PWA Hub responded ${response.status}`);
const { metrics } = (await response.json()).data;
const payouts = metrics.total_deposits.amounts.map((a) => `${a.amount} ${a.currency}`).join(', ') || '0';
const text = [
`PWA Hub for ${day}`,
`Visits: ${metrics.total_visits.value} (${metrics.total_visits.trend}%)`,
`Installs: ${metrics.total_installs.value} (${metrics.total_installs.trend}%)`,
`Registrations: ${metrics.total_registrations.value}`,
`Deposits: ${metrics.total_deposits.value} - ${payouts}`,
`CR: ${metrics.avg_cr.value}%`,
].join('\n');
const sent = await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id: CHAT_ID, text }),
signal: AbortSignal.timeout(30000),
});
if (!sent.ok) throw new Error(`Telegram responded ${sent.status}`);
console.log(`Sent:\n${text}`);<?php
// composer require guzzlehttp/guzzle
require 'vendor/autoload.php';
$api = new GuzzleHttp\Client([
'base_uri' => 'https://panel.example.com/api/',
'headers' => ['Authorization' => 'Bearer YOUR_KEY', 'Accept' => 'application/json'],
'timeout' => 30,
]);
$botToken = 'BOT_TOKEN_HERE';
$chatId = 'CHAT_ID_HERE';
$day = (new DateTimeImmutable('yesterday', new DateTimeZone('UTC')))->format('Y-m-d');
$response = $api->get('stats/dashboard', ['query' => ['date_from' => $day, 'date_to' => $day]]);
$metrics = json_decode($response->getBody(), true)['data']['metrics'];
$payouts = implode(', ', array_map(
fn($a) => "{$a['amount']} {$a['currency']}",
$metrics['total_deposits']['amounts']
)) ?: '0';
$text = implode("\n", [
"PWA Hub for {$day}",
"Visits: {$metrics['total_visits']['value']} ({$metrics['total_visits']['trend']}%)",
"Installs: {$metrics['total_installs']['value']} ({$metrics['total_installs']['trend']}%)",
"Registrations: {$metrics['total_registrations']['value']}",
"Deposits: {$metrics['total_deposits']['value']} - {$payouts}",
"CR: {$metrics['avg_cr']['value']}%",
]);
(new GuzzleHttp\Client(['timeout' => 30]))->post("https://api.telegram.org/bot{$botToken}/sendMessage", [
'json' => ['chat_id' => $chatId, 'text' => $text],
]);
echo "Sent:\n{$text}" . PHP_EOL;On a schedule. On a Linux server, add a line with crontab -e - the script will run every day at 07:00 server time:
0 7 * * * /usr/bin/python3 /home/user/stats-telegram.pyFor Node.js - /usr/bin/node /home/user/stats-telegram.mjs, for PHP - /usr/bin/php /home/user/stats-telegram.php. Run it no earlier than 00:10 UTC: fresh events reach the stats with a delay of up to 5 minutes.
trend is the change against the previous day, in percent. The metrics are explained in the "Statistics" section.
Clone a PWA, attach a domain and turn it on
Makes a new PWA from a ready one in a single run: clones it, attaches a domain to the copy and turns the copy on. Handy when every campaign setup needs its own app with its own domain.
What you need: the id of the template PWA (from the PWA list or the panel's address bar) and a domain for the copy.
These are real actions in Cloudflare
Attaching a domain creates a zone and DNS records in your Cloudflare account. A subdomain (go.example.com) can be attached only if its root domain (example.com) is already added to the panel - otherwise you get 404. Details - in the "Domains" section.
# pip install requests
import requests
API = "https://panel.example.com/api"
HEADERS = {"Authorization": "Bearer YOUR_KEY", "Accept": "application/json"}
SOURCE_ID = "WZAX" # template PWA
NAME = "Promo EN - setup 12" # name of the copy
DOMAIN = "fit-tracker-12.example" # domain for the copy
def call(method, path, **kwargs):
response = requests.request(method, f"{API}/{path}", headers=HEADERS, timeout=30, **kwargs)
if not response.ok:
raise SystemExit(f"{method} /{path}: {response.status_code} {response.json().get('message')}")
return response.json()["data"]
# The template's team is needed for PUT (team_lead and admin always send team_id)
team_id = call("GET", f"pwas/{SOURCE_ID}")["pwa"]["team"]["id"]
clone = call("POST", f"pwas/{SOURCE_ID}/clone", json={"names": [NAME]})["clones"][0]
print("Copy:", clone["id"], clone["name"])
domain = call("POST", "domains", json={"domain": DOMAIN, "pwa_id": clone["id"]})["domain"]
print("Domain:", domain["domain"])
call("PUT", f"pwas/{clone['id']}", json={"active": True, "team_id": team_id})
print("PWA turned on")// Node.js 18+, no dependencies; save as clone-pwa.mjs
const API = 'https://panel.example.com/api';
const headers = { Authorization: 'Bearer YOUR_KEY', Accept: 'application/json', 'Content-Type': 'application/json' };
const SOURCE_ID = 'WZAX'; // template PWA
const NAME = 'Promo EN - setup 12'; // name of the copy
const DOMAIN = 'fit-tracker-12.example'; // domain for the copy
async function call(method, path, body) {
const response = await fetch(`${API}/${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(30000),
});
const json = await response.json();
if (!response.ok) throw new Error(`${method} /${path}: ${response.status} ${json.message}`);
return json.data;
}
// The template's team is needed for PUT (team_lead and admin always send team_id)
const teamId = (await call('GET', `pwas/${SOURCE_ID}`)).pwa.team.id;
const [clone] = (await call('POST', `pwas/${SOURCE_ID}/clone`, { names: [NAME] })).clones;
console.log('Copy:', clone.id, clone.name);
const { domain } = await call('POST', 'domains', { domain: DOMAIN, pwa_id: clone.id });
console.log('Domain:', domain.domain);
await call('PUT', `pwas/${clone.id}`, { active: true, team_id: teamId });
console.log('PWA turned on');<?php
// composer require guzzlehttp/guzzle
require 'vendor/autoload.php';
$api = new GuzzleHttp\Client([
'base_uri' => 'https://panel.example.com/api/',
'headers' => ['Authorization' => 'Bearer YOUR_KEY', 'Accept' => 'application/json'],
'timeout' => 30,
'http_errors' => false,
]);
$sourceId = 'WZAX'; // template PWA
$name = 'Promo EN - setup 12'; // name of the copy
$domainName = 'fit-tracker-12.example'; // domain for the copy
function call(GuzzleHttp\Client $api, string $method, string $path, ?array $body = null): array
{
$response = $api->request($method, $path, $body === null ? [] : ['json' => $body]);
$json = json_decode($response->getBody(), true);
if ($response->getStatusCode() >= 400) {
exit("{$method} /{$path}: {$response->getStatusCode()} {$json['message']}" . PHP_EOL);
}
return $json['data'];
}
// The template's team is needed for PUT (team_lead and admin always send team_id)
$teamId = call($api, 'GET', "pwas/{$sourceId}")['pwa']['team']['id'];
$clone = call($api, 'POST', "pwas/{$sourceId}/clone", ['names' => [$name]])['clones'][0];
echo "Copy: {$clone['id']} {$clone['name']}" . PHP_EOL;
$domain = call($api, 'POST', 'domains', ['domain' => $domainName, 'pwa_id' => $clone['id']])['domain'];
echo "Domain: {$domain['domain']}" . PHP_EOL;
call($api, 'PUT', "pwas/{$clone['id']}", ['active' => true, 'team_id' => $teamId]);
echo 'PWA turned on' . PHP_EOL;The copy gets the template's settings, images and accounts, but not its filtering stream - if you need it, set filter_stream_id in the same PUT (see the "PWA" section). The new PWA starts receiving traffic within a few minutes after it is turned on.
Stop PWAs without deposits
Goes through all active PWAs and turns off those that collected enough installs over the last 7 days but not a single deposit.
What you need: decide how many installs count as enough (MIN_INSTALLS). By default the script runs as a dry run (DRY_RUN = True) and only prints what it would turn off. Make sure the list is right, then switch it to False.
# pip install requests
import datetime
import time
import requests
API = "https://panel.example.com/api"
HEADERS = {"Authorization": "Bearer YOUR_KEY", "Accept": "application/json"}
MIN_INSTALLS = 50 # fewer installs - too early to judge
DRY_RUN = True # True - only show, False - turn off
today = datetime.datetime.now(datetime.timezone.utc).date()
date_from = (today - datetime.timedelta(days=7)).isoformat()
date_to = (today - datetime.timedelta(days=1)).isoformat()
def get(path, params=None):
response = requests.get(f"{API}/{path}", headers=HEADERS, params=params, timeout=30)
if response.status_code == 401:
raise SystemExit("The key was rejected - check it in your profile")
response.raise_for_status()
return response.json()["data"]
pwas, page = [], 1
while True:
data = get("pwas", {"status": "active", "page": page, "excludes": "stats"})
pwas += data["pwas"]
if page >= data["meta"]["last_page"]:
break
page += 1
for pwa in pwas:
totals = get("stats/funnel", {"date_from": date_from, "date_to": date_to, "pwa_id": pwa["id"]})["funnel"]["totals"]
time.sleep(0.25) # no more than 300 requests per minute
if totals["installs"] < MIN_INSTALLS or totals["deps"] > 0:
continue
print(f"{pwa['id']} {pwa['name']}: {totals['installs']} installs, 0 deposits")
if not DRY_RUN:
requests.put(f"{API}/pwas/{pwa['id']}", headers=HEADERS, json={
"active": False,
"team_id": pwa["team"]["id"],
}, timeout=30).raise_for_status()
print(" turned off")// Node.js 18+, no dependencies; save as stop-pwas.mjs
const API = 'https://panel.example.com/api';
const headers = { Authorization: 'Bearer YOUR_KEY', Accept: 'application/json', 'Content-Type': 'application/json' };
const MIN_INSTALLS = 50; // fewer installs - too early to judge
const DRY_RUN = true; // true - only show, false - turn off
const day = (daysAgo) => new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const dateFrom = day(7);
const dateTo = day(1);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function get(path, params = {}) {
const response = await fetch(`${API}/${path}?${new URLSearchParams(params)}`, {
headers,
signal: AbortSignal.timeout(30000),
});
if (response.status === 401) throw new Error('The key was rejected - check it in your profile');
if (!response.ok) throw new Error(`${path}: ${response.status}`);
return (await response.json()).data;
}
const pwas = [];
for (let page = 1; ; page++) {
const data = await get('pwas', { status: 'active', page, excludes: 'stats' });
pwas.push(...data.pwas);
if (page >= data.meta.last_page) break;
}
for (const pwa of pwas) {
const { totals } = (await get('stats/funnel', { date_from: dateFrom, date_to: dateTo, pwa_id: pwa.id })).funnel;
await sleep(250); // no more than 300 requests per minute
if (totals.installs < MIN_INSTALLS || totals.deps > 0) continue;
console.log(`${pwa.id} ${pwa.name}: ${totals.installs} installs, 0 deposits`);
if (!DRY_RUN) {
const response = await fetch(`${API}/pwas/${pwa.id}`, {
method: 'PUT',
headers,
body: JSON.stringify({ active: false, team_id: pwa.team.id }),
signal: AbortSignal.timeout(30000),
});
if (!response.ok) throw new Error(`PUT /pwas/${pwa.id}: ${response.status}`);
console.log(' turned off');
}
}<?php
// composer require guzzlehttp/guzzle
require 'vendor/autoload.php';
$api = new GuzzleHttp\Client([
'base_uri' => 'https://panel.example.com/api/',
'headers' => ['Authorization' => 'Bearer YOUR_KEY', 'Accept' => 'application/json'],
'timeout' => 30,
]);
$minInstalls = 50; // fewer installs - too early to judge
$dryRun = true; // true - only show, false - turn off
$today = new DateTimeImmutable('today', new DateTimeZone('UTC'));
$dateFrom = $today->modify('-7 days')->format('Y-m-d');
$dateTo = $today->modify('-1 day')->format('Y-m-d');
$pwas = [];
for ($page = 1; ; $page++) {
$response = $api->get('pwas', ['query' => ['status' => 'active', 'page' => $page, 'excludes' => 'stats']]);
$data = json_decode($response->getBody(), true)['data'];
$pwas = [...$pwas, ...$data['pwas']];
if ($page >= $data['meta']['last_page']) {
break;
}
}
foreach ($pwas as $pwa) {
$response = $api->get('stats/funnel', ['query' => [
'date_from' => $dateFrom,
'date_to' => $dateTo,
'pwa_id' => $pwa['id'],
]]);
$totals = json_decode($response->getBody(), true)['data']['funnel']['totals'];
usleep(250000); // no more than 300 requests per minute
if ($totals['installs'] < $minInstalls || $totals['deps'] > 0) {
continue;
}
echo "{$pwa['id']} {$pwa['name']}: {$totals['installs']} installs, 0 deposits" . PHP_EOL;
if (!$dryRun) {
$api->put("pwas/{$pwa['id']}", ['json' => ['active' => false, 'team_id' => $pwa['team']['id']]]);
echo ' turned off' . PHP_EOL;
}
}The script makes one stats request per PWA and pauses for 0.25 s so it does not hit the limit of 300 requests per minute. Installs and deposits here are counted by event date, as in the funnel (see the "Statistics" section). The PWA's team members get a notification about every app that is turned off.
Stats in Google Sheets
Every morning, appends a row with yesterday's totals to a Google Sheet. In a month you have a ready table for charts and summaries - no servers or cron, everything runs inside Google.
Setup:
- Open the Google Sheet and choose Extensions → Apps Script.
- Paste the script below over the editor's contents and replace
panel.example.comwith your panel's address. - On the left, open Project Settings (gear) → Script Properties → Add script property: name
PWA_HUB_KEY, value - your key. This way the key is not stored in the script text. - At the top, choose the
appendYesterdayfunction and click Run. Google asks for permissions - confirm. APWA Hubsheet with the first row appears in the spreadsheet. - On the left, open Triggers (alarm clock) → Add Trigger: function
appendYesterday, source Time-driven, Day timer, time - for example 7am to 8am. Save.
// Google Sheet → Extensions → Apps Script
const API = 'https://panel.example.com/api';
const SHEET = 'PWA Hub';
function appendYesterday() {
const key = PropertiesService.getScriptProperties().getProperty('PWA_HUB_KEY');
if (!key) throw new Error('Add PWA_HUB_KEY to Script Properties');
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
const day = Utilities.formatDate(yesterday, 'UTC', 'yyyy-MM-dd');
const response = UrlFetchApp.fetch(`${API}/stats/dashboard?date_from=${day}&date_to=${day}`, {
headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' },
muteHttpExceptions: true,
});
if (response.getResponseCode() !== 200) {
throw new Error(`PWA Hub responded ${response.getResponseCode()}: ${response.getContentText()}`);
}
const metrics = JSON.parse(response.getContentText()).data.metrics;
const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
let sheet = spreadsheet.getSheetByName(SHEET);
if (!sheet) {
sheet = spreadsheet.insertSheet(SHEET);
sheet.appendRow(['Date', 'Visits', 'Uniques', 'Installs', 'Registrations', 'Deposits', 'CR, %']);
}
sheet.appendRow([
day,
metrics.total_visits.value,
metrics.total_uniques.value,
metrics.total_installs.value,
metrics.total_registrations.value,
metrics.total_deposits.value,
metrics.avg_cr.value,
]);
}A second run adds a second row
The script simply appends a row. Run it twice in a day and yesterday will be in the sheet twice; delete the extra row by hand.
If the panel is unreachable or the key was revoked, the triggered run fails and Google emails you about it - no row appears for that day. To fill in a missed day, change yesterday to the date you need and run the function by hand.