PWA
Methods for PWA apps: find the ones you need, create a new one, change settings, turn it on or off, clone it and archive it. For what the settings themselves mean, see the "PWA - creating (General tab)" section and the neighbouring guide pages.
| Method | Address | What it does |
|---|---|---|
| GET | /pwas | List PWAs |
| GET | /pwas/{id} | One PWA with all settings |
| POST | /pwas | Create a PWA |
| PUT | /pwas/{id} | Update, turn on or off |
| POST | /pwas/{id}/clone | Clone |
| DELETE | /pwas/{id} | Archive |
Replace YOUR_KEY and panel.example.com with your own.
List PWAs
GET/pwas
PWAs of your teams, new and recently changed first. Archived ones are not shown.
| Parameter | What it filters |
|---|---|
search | Part of the name |
domain | Part of a domain attached to the PWA |
status | active - only enabled, inactive - only disabled |
archived | 1 - only archived |
team_id | Team |
offer_country | Country in the offer links, two-letter code (US, DE); PWAs with an ANY link match too |
group_id | Access group |
cloudflare_id, filter_account_id | Cloudflare or filter account |
page, per_page | Page and its size (100 by default) |
curl "https://panel.example.com/api/pwas?status=active&offer_country=US" \
-H "Authorization: Bearer YOUR_KEY" \
-H "Accept: application/json"<?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,
]);
$response = $api->get('pwas', ['query' => ['status' => 'active', 'offer_country' => 'US']]);
$pwas = json_decode($response->getBody(), true)['data']['pwas'];
foreach ($pwas as $pwa) {
echo "{$pwa['id']} {$pwa['name']}: {$pwa['stat_installs']} installs" . PHP_EOL;
}# pip install requests
import requests
API = "https://panel.example.com/api"
HEADERS = {"Authorization": "Bearer YOUR_KEY", "Accept": "application/json"}
response = requests.get(f"{API}/pwas", headers=HEADERS, params={"status": "active", "offer_country": "US"}, timeout=30)
pwas = response.json()["data"]["pwas"]
for pwa in pwas:
print(pwa["id"], pwa["name"], pwa["stat_installs"], "installs")// Node.js 18+, no dependencies; save as script.mjs
const API = 'https://panel.example.com/api';
const headers = { Authorization: 'Bearer YOUR_KEY', Accept: 'application/json', 'Content-Type': 'application/json' };
const response = await fetch(`${API}/pwas?status=active&offer_country=US`, { headers, signal: AbortSignal.timeout(30000) });
const { data } = await response.json();
for (const pwa of data.pwas) {
console.log(pwa.id, pwa.name, pwa.stat_installs, 'installs');
}Response
{
"data": {
"meta": { "current_page": 1, "per_page": 100, "last_page": 1, "total": 1 },
"pwas": [
{
"id": "WZAX",
"name": "Promo EN",
"active": true,
"archived": false,
"filter_stream_id": null,
"icon_url": "/api/pwas/WZAX/assets/icon-96.png",
"stat_uniques": 1520,
"stat_installs": 410,
"stat_registrations": 96,
"stat_deposits": 21,
"stat_repeat_deposits": 7,
"similar_count": 0,
"team": { "id": "WQby", "name": "Team A" },
"user": { "id": "WZAX", "name": "Buyer", "email": "[email protected]" },
"domains": [ { "id": "Xk9P", "domain": "fit-tracker.example" } ],
"groups": [],
"created_at": "05 Oct 2026 14:03",
"updated_at": "05 Oct 2026 14:03"
}
]
}
}The stat_* counters are all-time: unique visitors, installs, registrations, deposits and repeat deposits. For statistics over a period use the "Statistics" section.
Errors
| Code | message | When |
|---|---|---|
422 | The offer country field format is invalid. | offer_country is not two Latin letters |
One PWA
GET/pwas/{id}
Everything about the app, including the settings in the config field.
curl https://panel.example.com/api/pwas/WZAX \
-H "Authorization: Bearer YOUR_KEY" \
-H "Accept: application/json"<?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,
]);
$response = $api->get('pwas/WZAX');
$pwa = json_decode($response->getBody(), true)['data']['pwa'];
echo $pwa['name'] . ': ' . ($pwa['active'] ? 'enabled' : 'disabled') . PHP_EOL;# pip install requests
import requests
API = "https://panel.example.com/api"
HEADERS = {"Authorization": "Bearer YOUR_KEY", "Accept": "application/json"}
response = requests.get(f"{API}/pwas/WZAX", headers=HEADERS, timeout=30)
pwa = response.json()["data"]["pwa"]
print(pwa["name"], "enabled" if pwa["active"] else "disabled")// Node.js 18+, no dependencies; save as script.mjs
const API = 'https://panel.example.com/api';
const headers = { Authorization: 'Bearer YOUR_KEY', Accept: 'application/json', 'Content-Type': 'application/json' };
const response = await fetch(`${API}/pwas/WZAX`, { headers, signal: AbortSignal.timeout(30000) });
const { data } = await response.json();
console.log(data.pwa.name, data.pwa.active ? 'enabled' : 'disabled');Response (shortened)
{
"data": {
"pwa": {
"id": "WZAX",
"name": "Promo EN",
"active": true,
"cloudflare_id": "WZAX",
"filter_account_id": null,
"filter_stream_id": null,
"domains": [ { "id": "Xk9P", "domain": "fit-tracker.example" } ],
"config": {
"general": { "name": "Fit Tracker", "language": "en", "templateAndroid": "google-v1" },
"offerLinks": {
"US": [ { "lang": "en", "link": "https://offer.example/?sub1={sub1}" } ]
},
"metrics": { "facebook": { "pixelId": null, "events": [] }, "google": { "tagId": null } },
"comments": [],
"translations": []
},
"similar": []
}
}
}The config fields mirror the tabs of the PWA form: general - "General", offerLinks - offer links by country, metrics - pixels, comments - reviews, translations - translations.
In offerLinks the country ANY is the fallback: its link goes to a visitor with no link in their own country (the country is not listed, not detected, or has no link for their language); the language * means any. Updating a PWA with config needs at least one link, and with general.white.type = off - a link under ANY.
Errors
| Code | message | When |
|---|---|---|
403 | This action is unauthorized. | No access to the PWA: it is not in your teams (for a customer - not created by them) and not shared with you through an access group |
404 | Not found | No PWA with this id |
Creating a PWA
POST/pwas
| Field | Required | What it is |
|---|---|---|
name | yes | PWA name in the panel |
cloudflare_id | yes | Cloudflare account, list - GET/cloudflares |
team_id | for team_lead and admin | Team: the id from GET/teams (the "References" section) or team.id of any GET/pwas row; for a customer their own team is used, the value passed is ignored |
active | no | true - turn on right away. By default a PWA is created disabled |
filter_account_id, filter_stream_id | no | Filter account and a stream in it |
config | no | Settings, as in the GET/pwas/{id} response |
curl -X POST https://panel.example.com/api/pwas \
-H "Authorization: Bearer YOUR_KEY" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"name": "Promo EN",
"team_id": "WQby",
"cloudflare_id": "WZAX",
"config": {
"general": { "name": "Fit Tracker", "language": "en", "templateAndroid": "google-v1" },
"offerLinks": { "US": [ { "lang": "en", "link": "https://offer.example/?sub1={sub1}" } ] }
}
}'<?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,
]);
$response = $api->post('pwas', ['json' => [
'name' => 'Promo EN',
'team_id' => 'WQby',
'cloudflare_id' => 'WZAX',
'config' => [
'general' => ['name' => 'Fit Tracker', 'language' => 'en', 'templateAndroid' => 'google-v1'],
'offerLinks' => ['US' => [['lang' => 'en', 'link' => 'https://offer.example/?sub1={sub1}']]],
],
]]);
$pwa = json_decode($response->getBody(), true)['data']['pwa'];
echo "Created PWA {$pwa['id']}" . PHP_EOL;# pip install requests
import requests
API = "https://panel.example.com/api"
HEADERS = {"Authorization": "Bearer YOUR_KEY", "Accept": "application/json"}
response = requests.post(f"{API}/pwas", headers=HEADERS, json={
"name": "Promo EN",
"team_id": "WQby",
"cloudflare_id": "WZAX",
"config": {
"general": {"name": "Fit Tracker", "language": "en", "templateAndroid": "google-v1"},
"offerLinks": {"US": [{"lang": "en", "link": "https://offer.example/?sub1={sub1}"}]},
},
}, timeout=30)
pwa = response.json()["data"]["pwa"]
print("Created PWA", pwa["id"])// Node.js 18+, no dependencies; save as script.mjs
const API = 'https://panel.example.com/api';
const headers = { Authorization: 'Bearer YOUR_KEY', Accept: 'application/json', 'Content-Type': 'application/json' };
const response = await fetch(`${API}/pwas`, {
method: 'POST',
headers,
body: JSON.stringify({
name: 'Promo EN',
team_id: 'WQby',
cloudflare_id: 'WZAX',
config: {
general: { name: 'Fit Tracker', language: 'en', templateAndroid: 'google-v1' },
offerLinks: { US: [{ lang: 'en', link: 'https://offer.example/?sub1={sub1}' }] },
},
}),
signal: AbortSignal.timeout(30000),
});
const { data } = await response.json();
console.log('Created PWA', data.pwa.id);The response is the created PWA in data.pwa, with active, archived, team, user and an empty domains. A domain is attached to the PWA separately, see the "Domains" section.
Errors
| Code | message | When |
|---|---|---|
422 | The name field is required. (and 2 more errors) | name, cloudflare_id or team_id is missing - errors says which |
422 | You do not have access to this team | team_id is a team you are not in |
422 | The cloudflare id field must be an integer. | cloudflare_id not found - a wrong id was passed |
422 | The offer links country "USA" must be a two-letter code or ANY. | A country in config.offerLinks is neither a two-letter code nor ANY |
422 | The link field format is invalid. | An offer link doesn't start with http:// or https:// |
422 | The selected template android is invalid. | Unknown template in config.general.templateAndroid / templateIos |
Cloning is easier
A PWA has a lot of settings, and building config from scratch takes time. It is usually easier to set up one PWA in the panel and make new ones as its clones.
Updating, turning on and off
PUT/pwas/{id}
Changes only the fields passed. Accepts the same fields as creation.
team_id is optional on update: without it the PWA stays in its team, and you only send it to move the PWA to another team (a team_lead - to one of their own). A customer can change only their own PWAs and those shared with them through an access group.
Turn a PWA on:
curl -X PUT https://panel.example.com/api/pwas/WZAX \
-H "Authorization: Bearer YOUR_KEY" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"active": true}'<?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,
]);
$response = $api->put('pwas/WZAX', ['json' => ['active' => true]]);
$pwa = json_decode($response->getBody(), true)['data']['pwa'];
echo $pwa['name'] . ': ' . ($pwa['active'] ? 'enabled' : 'disabled') . PHP_EOL;# pip install requests
import requests
API = "https://panel.example.com/api"
HEADERS = {"Authorization": "Bearer YOUR_KEY", "Accept": "application/json"}
response = requests.put(f"{API}/pwas/WZAX", headers=HEADERS, json={"active": True}, timeout=30)
pwa = response.json()["data"]["pwa"]
print(pwa["name"], "enabled" if pwa["active"] else "disabled")// Node.js 18+, no dependencies; save as script.mjs
const API = 'https://panel.example.com/api';
const headers = { Authorization: 'Bearer YOUR_KEY', Accept: 'application/json', 'Content-Type': 'application/json' };
const response = await fetch(`${API}/pwas/WZAX`, {
method: 'PUT',
headers,
body: JSON.stringify({ active: true }),
signal: AbortSignal.timeout(30000),
});
const { data } = await response.json();
console.log(data.pwa.name, data.pwa.active ? 'enabled' : 'disabled');To turn it off - the same with "active": false. Team members get a notification that the PWA was stopped.
config is replaced as a whole
If the request contains config, it replaces all PWA settings instead of adding to them: everything missing from the request is erased. So first get the current config, change what you need in it and send it back as a whole.
Change the offer link for the US:
# 1. Save the current PWA
curl https://panel.example.com/api/pwas/WZAX \
-H "Authorization: Bearer YOUR_KEY" -H "Accept: application/json" > pwa.json
# 2. Change the link and send config as a whole (needs jq)
jq '{config: (.data.pwa.config
| .offerLinks.US = [{"lang": "en", "link": "https://new-offer.example/?sub1={sub1}"}])}' pwa.json \
| curl -X PUT https://panel.example.com/api/pwas/WZAX \
-H "Authorization: Bearer YOUR_KEY" -H "Accept: application/json" \
-H "Content-Type: application/json" -d @-<?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,
]);
// 1. The current PWA
$pwa = json_decode($api->get('pwas/WZAX')->getBody(), true)['data']['pwa'];
// 2. Change the US link, the rest of config stays as it was
$config = $pwa['config'];
$config['offerLinks']['US'] = [['lang' => 'en', 'link' => 'https://new-offer.example/?sub1={sub1}']];
// 3. Send config as a whole
$response = $api->put('pwas/WZAX', ['json' => ['config' => $config]]);
$updated = json_decode($response->getBody(), true)['data']['pwa'];
echo $updated['config']['offerLinks']['US'][0]['link'] . PHP_EOL;# pip install requests
import requests
API = "https://panel.example.com/api"
HEADERS = {"Authorization": "Bearer YOUR_KEY", "Accept": "application/json"}
# 1. The current PWA
pwa = requests.get(f"{API}/pwas/WZAX", headers=HEADERS, timeout=30).json()["data"]["pwa"]
# 2. Change the US link, the rest of config stays as it was
config = pwa["config"]
config["offerLinks"]["US"] = [{"lang": "en", "link": "https://new-offer.example/?sub1={sub1}"}]
# 3. Send config as a whole
response = requests.put(f"{API}/pwas/WZAX", headers=HEADERS, json={"config": config}, timeout=30)
updated = response.json()["data"]["pwa"]
print(updated["config"]["offerLinks"]["US"][0]["link"])// Node.js 18+, no dependencies; save as script.mjs
const API = 'https://panel.example.com/api';
const headers = { Authorization: 'Bearer YOUR_KEY', Accept: 'application/json', 'Content-Type': 'application/json' };
// 1. The current PWA
const current = await fetch(`${API}/pwas/WZAX`, { headers, signal: AbortSignal.timeout(30000) });
const { pwa } = (await current.json()).data;
// 2. Change the US link, the rest of config stays as it was
const config = pwa.config;
config.offerLinks.US = [{ lang: 'en', link: 'https://new-offer.example/?sub1={sub1}' }];
// 3. Send config as a whole
const response = await fetch(`${API}/pwas/WZAX`, {
method: 'PUT',
headers,
body: JSON.stringify({ config }),
signal: AbortSignal.timeout(30000),
});
const { data } = await response.json();
console.log(data.pwa.config.offerLinks.US[0].link);The response is the PWA after the change in data.pwa.
Errors
| Code | message | When |
|---|---|---|
422 | You do not have access to this team | team_id is a team you are not in |
422 | At least one offer link is required. | config.offerLinks has no link |
422 | The white page is off - at least one offer link with country ANY is required. | general.white.type is off and there is no link under ANY |
403 | This action is unauthorized. | No access to the PWA: it is not in your teams (for a customer - not created by them) and not shared with you through an access group |
Errors in config are the same as on creation.
Cloning
POST/pwas/{id}/clone
Makes copies of a PWA, one per name in names.
A copy gets the original's settings, images, Cloudflare and filter accounts and groups. Domains and the filter stream are not copied, and the copy itself is created disabled: attach a domain, set the stream and turn it on.
curl -X POST https://panel.example.com/api/pwas/WZAX/clone \
-H "Authorization: Bearer YOUR_KEY" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"names": ["Promo EN 2", "Promo EN 3"]}'<?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,
]);
$response = $api->post('pwas/WZAX/clone', ['json' => ['names' => ['Promo EN 2', 'Promo EN 3']]]);
$clones = json_decode($response->getBody(), true)['data']['clones'];
foreach ($clones as $clone) {
echo "{$clone['id']} {$clone['name']}" . PHP_EOL;
}# pip install requests
import requests
API = "https://panel.example.com/api"
HEADERS = {"Authorization": "Bearer YOUR_KEY", "Accept": "application/json"}
response = requests.post(f"{API}/pwas/WZAX/clone", headers=HEADERS, json={"names": ["Promo EN 2", "Promo EN 3"]}, timeout=30)
clones = response.json()["data"]["clones"]
for clone in clones:
print(clone["id"], clone["name"])// Node.js 18+, no dependencies; save as script.mjs
const API = 'https://panel.example.com/api';
const headers = { Authorization: 'Bearer YOUR_KEY', Accept: 'application/json', 'Content-Type': 'application/json' };
const response = await fetch(`${API}/pwas/WZAX/clone`, {
method: 'POST',
headers,
body: JSON.stringify({ names: ['Promo EN 2', 'Promo EN 3'] }),
signal: AbortSignal.timeout(30000),
});
const { data } = await response.json();
for (const clone of data.clones) {
console.log(clone.id, clone.name);
}Response
{
"data": {
"clones": [
{ "id": "WQby", "name": "Promo EN 2", "domains": [] },
{ "id": "Lm3T", "name": "Promo EN 3", "domains": [] }
]
}
}Errors
| Code | message | When |
|---|---|---|
422 | The names field is required. | No names, or it is an empty list |
403 | This action is unauthorized. | No access to the PWA: it is not in your teams (for a customer - not created by them) and not shared with you through an access group |
Archiving
DELETE/pwas/{id}
Archives the PWA together with its flows: the app stops opening, but the statistics are kept. Archived PWAs are shown in the list with archived=1.
curl -X DELETE https://panel.example.com/api/pwas/WZAX \
-H "Authorization: Bearer YOUR_KEY" \
-H "Accept: application/json"<?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,
]);
$response = $api->delete('pwas/WZAX');
echo $response->getStatusCode() === 200 ? 'PWA archived' . PHP_EOL : 'Error' . PHP_EOL;# pip install requests
import requests
API = "https://panel.example.com/api"
HEADERS = {"Authorization": "Bearer YOUR_KEY", "Accept": "application/json"}
response = requests.delete(f"{API}/pwas/WZAX", headers=HEADERS, timeout=30)
print("PWA archived" if response.ok else "Error")// Node.js 18+, no dependencies; save as script.mjs
const API = 'https://panel.example.com/api';
const headers = { Authorization: 'Bearer YOUR_KEY', Accept: 'application/json', 'Content-Type': 'application/json' };
const response = await fetch(`${API}/pwas/WZAX`, { method: 'DELETE', headers, signal: AbortSignal.timeout(30000) });
console.log(response.ok ? 'PWA archived' : 'Error');Response: {"data": true}.
Errors
| Code | message | When |
|---|---|---|
403 | This action is unauthorized. | No access to the PWA: it is not in your teams (for a customer - not created by them) and not shared with you through an access group |