Domains
Methods for domains: attach a domain or subdomain to a PWA or split, turn its DNS off and on, recreate DNS records and delete a domain. For how domains work in the panel, see the "PWA and Split Domains" section of the guide.
| Method | Address | What it does |
|---|---|---|
| GET | /domains | Root domains available to the team |
| POST | /domains | Attach a domain to a PWA or split |
| POST | /domains/{id}/toggle-dns | Turn the domain's DNS off or on |
| POST | /domains/{id}/recreate-dns | Recreate DNS records |
| DELETE | /domains/{id} | Delete a domain |
Replace YOUR_KEY and panel.example.com with your own.
This is the real Cloudflare
Every method except GET changes the Cloudflare account right away: creates a zone, adds and deletes DNS records. This can't be undone through the API - check the domain and the PWA before the request, especially in loops.
Root domains
GET/domains
Second-level domains (example.com) on which you can create subdomains: domains of the PWA's or split's team and shared domains without a team. Subdomains are not in this list. Domains already attached to a specific PWA are in the domains field of GET/pwas/{id}.
| Parameter | Required | What it is |
|---|---|---|
pwa_id or pwa_split_id | yes, one of the two | PWA or split - domains are picked by its team |
curl "https://panel.example.com/api/domains?pwa_id=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('domains', ['query' => ['pwa_id' => 'WZAX']]);
$domains = json_decode($response->getBody(), true)['data']['domains'];
foreach ($domains as $domain) {
echo "{$domain['id']} {$domain['domain']}" . 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}/domains", headers=HEADERS, params={"pwa_id": "WZAX"}, timeout=30)
domains = response.json()["data"]["domains"]
for domain in domains:
print(domain["id"], domain["domain"])// 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}/domains?pwa_id=WZAX`, { headers, signal: AbortSignal.timeout(30000) });
const { data } = await response.json();
for (const domain of data.domains) {
console.log(domain.id, domain.domain);
}Response
{
"data": {
"domains": [
{
"id": "Xk9P",
"domain": "fit-tracker.example",
"domain_level": 2,
"dns_on": true,
"team": { "id": "WQby", "name": "Team A" },
"created_at": "05 Oct 2026 14:23",
"updated_at": "05 Oct 2026 14:23"
}
]
}
}The list is not paginated - it comes in full. dns_on is whether the domain's DNS is on in Cloudflare.
Errors
| Code | message | When |
|---|---|---|
422 | The pwa id field is required when pwa split id is not present. | Neither pwa_id nor pwa_split_id passed, or the id doesn't exist |
403 | This action is unauthorized. | A PWA or split of another team |
Attaching a domain
POST/domains
Adds the domain to Cloudflare and attaches it to a PWA or split.
| Field | Required | What it is |
|---|---|---|
domain | yes | Domain or subdomain, e.g. fit-tracker.example or go.fit-tracker.example |
pwa_id or pwa_split_id | yes, one of the two | Where to attach it |
cloudflare_id | no | Cloudflare account for the zone. Without it the main (Primary) account is used |
The domain's team is taken from the PWA or split; team_id is not needed in the request and is ignored. You can attach a domain only to a PWA or split you have access to.
curl -X POST https://panel.example.com/api/domains \
-H "Authorization: Bearer YOUR_KEY" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"domain": "fit-tracker.example", "pwa_id": "WZAX", "cloudflare_id": "Lm3T"}'<?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('domains', ['json' => [
'domain' => 'fit-tracker.example',
'pwa_id' => 'WZAX',
'cloudflare_id' => 'Lm3T',
]]);
$result = json_decode($response->getBody(), true)['data'];
// NS servers to set at the registrar
echo implode(PHP_EOL, $result['zone']['name_servers']) . 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}/domains", headers=HEADERS, json={
"domain": "fit-tracker.example",
"pwa_id": "WZAX",
"cloudflare_id": "Lm3T",
}, timeout=30)
result = response.json()["data"]
# NS servers to set at the registrar
print("\n".join(result["zone"]["name_servers"]))// 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}/domains`, {
method: 'POST',
headers,
body: JSON.stringify({
domain: 'fit-tracker.example',
pwa_id: 'WZAX',
cloudflare_id: 'Lm3T',
}),
signal: AbortSignal.timeout(30000),
});
const { data } = await response.json();
// NS servers to set at the registrar
console.log(data.zone.name_servers.join('\n'));The response is the domain and the Cloudflare zone it went into:
Response
{
"data": {
"domain": {
"id": "Xk9P",
"domain": "fit-tracker.example",
"domain_level": 2,
"dns_on": true,
"team": { "id": "WQby", "name": "Team A" }
},
"zone": {
"id": "023e105f4ecef8ad9ca31a8372d0c353",
"name": "fit-tracker.example",
"status": "pending",
"name_servers": ["ada.ns.cloudflare.com", "bob.ns.cloudflare.com"]
}
}
}What happens in Cloudflare:
- A new second-level domain - a zone is created with records that point the domain to the panel's server. Until the NS from
zone.name_serversare set at the registrar, the zone ispendingand the domain doesn't open. NS are set at the registrar - see the "Domains and Cloudflare" section. - A subdomain (
go.fit-tracker.example) - a record for the subdomain is added to the root domain's zone. The root domain must already be in the panel, otherwise you get404.
The same account as the PWA
Pass the cloudflare_id of the same account as the PWA's (the cloudflare_id field in GET/pwas/{id}). Without it the zone is created in the main account, and the domain may end up in a different account than the app.
A shared root domain (without a team) becomes bound to the PWA's or split's team once a subdomain is added on it.
A repeated POST with an already added domain and a different pwa_id re-attaches the domain to the new PWA. A domain of another team can't be taken this way - you get 403.
Domain limit
The number of domains is limited by the license - all domains and subdomains of the panel count. When the limit is used up you get 403 with the text Limit reached: max_domains (50) - on any POST/domains, even on re-attaching an already added domain.
Errors
| Code | message | When |
|---|---|---|
422 | The domain field is required. | domain not passed |
422 | The domain field format is invalid. | domain is not a domain name |
422 | The pwa id field is required when pwa split id is not present. | Neither pwa_id nor pwa_split_id passed, or the id doesn't exist |
422 | You do not have access to this resource | A PWA or split you have no access to; team_id then also gets You do not have access to this team |
422 | The cloudflare id field must be an integer. | cloudflare_id doesn't exist |
403 | This action is unauthorized. | The domain was already added by another team |
403 | Limit reached: max_domains (50) | The license domain limit is used up |
404 | Not found | A subdomain whose root domain is not in the panel |
Turning DNS off and on
POST/domains/{id}/toggle-dns
If the domain's DNS is on, deletes its records in Cloudflare (the domain stops opening but stays attached); if it is off - creates them again.
curl -X POST https://panel.example.com/api/domains/Xk9P/toggle-dns \
-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->post('domains/Xk9P/toggle-dns');
$domain = json_decode($response->getBody(), true)['data']['domain'];
echo ($domain['dns_on'] ? 'DNS on' : 'DNS off') . 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}/domains/Xk9P/toggle-dns", headers=HEADERS, timeout=30)
domain = response.json()["data"]["domain"]
print("DNS on" if domain["dns_on"] else "DNS off")// 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}/domains/Xk9P/toggle-dns`, { method: 'POST', headers, signal: AbortSignal.timeout(30000) });
const { data } = await response.json();
console.log(data.domain.dns_on ? 'DNS on' : 'DNS off');The response is the domain in data.domain, the new state in dns_on.
Errors
| Code | message | When |
|---|---|---|
404 | Not found | No domain with this id |
403 | This action is unauthorized. | A domain of another team |
Recreating DNS
POST/domains/{id}/recreate-dns
Brings the domain's records in Cloudflare to the right state: creates missing ones, fixes records with a wrong server address, creates the zone if needed. Calling it again breaks nothing. Useful if the records were deleted or changed manually in Cloudflare.
curl -X POST https://panel.example.com/api/domains/Xk9P/recreate-dns \
-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->post('domains/Xk9P/recreate-dns');
$domain = json_decode($response->getBody(), true)['data']['domain'];
echo "DNS of {$domain['domain']} recreated" . 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}/domains/Xk9P/recreate-dns", headers=HEADERS, timeout=30)
domain = response.json()["data"]["domain"]
print("DNS of", domain["domain"], "recreated")// 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}/domains/Xk9P/recreate-dns`, { method: 'POST', headers, signal: AbortSignal.timeout(30000) });
const { data } = await response.json();
console.log('DNS of', data.domain.domain, 'recreated');The response is the domain in data.domain with dns_on: true.
Errors
| Code | message | When |
|---|---|---|
404 | Not found | No domain with this id |
403 | This action is unauthorized. | A domain of another team |
Deleting a domain
DELETE/domains/{id}
Deletes the domain's DNS records in Cloudflare and the domain itself from the panel. The zone in Cloudflare stays. Subdomains of a deleted root domain stay attached - delete them separately.
curl -X DELETE https://panel.example.com/api/domains/Xk9P \
-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('domains/Xk9P');
echo $response->getStatusCode() === 200 ? 'Domain deleted' . 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}/domains/Xk9P", headers=HEADERS, timeout=30)
print("Domain deleted" 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}/domains/Xk9P`, { method: 'DELETE', headers, signal: AbortSignal.timeout(30000) });
console.log(response.ok ? 'Domain deleted' : 'Error');Response: {"data": true}.
Errors
| Code | message | When |
|---|---|---|
404 | Not found | No domain with this id |
403 | This action is unauthorized. | A domain of another team |