Building, plugins and themes
Upload a built site, set the home page, install and test plugins and themes, and the sites you publish to other hosts.
One external site
GET/api/v1/external-sites/{ext_site_id}Scope extdeploy:read
One site on a third-party host, as its page shows it: state, address, source, build settings, what its provider supports (can), its custom domains with the DNS records each needs, and the recent deploys.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
ext_site_id | path | integer | yes | The external site id. |
Example
curl -s "https://app.pbn.ltd/api/v1/external-sites/9" \
-H "Authorization: Bearer $PBN_API_KEY"
import os
import requests
headers = {"Authorization": "Bearer " + os.environ["PBN_API_KEY"]}
r = requests.get("https://app.pbn.ltd/api/v1/external-sites/9", headers=headers, timeout=120)
print(r.status_code, r.json())
const res = await fetch("https://app.pbn.ltd/api/v1/external-sites/9", {
method: "GET",
headers: {Authorization: `Bearer ${process.env.PBN_API_KEY}`}
});
console.log(res.status, await res.json());
Response
200
{
"data": {
"id": 9,
"name": "Landing page",
"provider": "netlify",
"provider_name": "Netlify",
"project": "my-landing",
"state": "live",
"state_message": null,
"url": "https://my-landing.netlify.app",
"domain": "landing.example.com",
"source": "v1",
"source_site_id": 123,
"connection_id": 3,
"build_settings": {
"branch": "main"
},
"can": {
"upload": true,
"rollback": true,
"domains": true,
"logs": true,
"env": true,
"delete": true
},
"online": true,
"indexed": true,
"last_deploy_at": "2026-09-19T14:22:00Z",
"domains": [
{
"id": 4,
"hostname": "landing.example.com",
"state": "pending",
"https": false,
"message": "Add the DNS record shown.",
"records": [
{
"type": "CNAME",
"name": "landing",
"value": "my-landing.netlify.app"
}
]
}
],
"deployments": [
{
"id": 551,
"kind": "deploy",
"state": "done",
"remote_state": "ready",
"message": "",
"files": 12,
"bytes": 284113,
"started_at": "2026-09-19T14:20:00Z",
"finished_at": "2026-09-19T14:22:00Z"
}
],
"created_at": "2026-08-02T09:00:00Z"
}
}
Errors: not_found, payment_required, rate_limited, scope_missing, unauthorized
The log of one deploy
GET/api/v1/external-sites/deployments/{deployment_id}/logScope extdeploy:read
What happened during one deploy, line by line (the page's "Log").
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
deployment_id | path | integer | yes | The deploy id. |
Example
curl -s "https://app.pbn.ltd/api/v1/external-sites/deployments/deployment_id/log" \
-H "Authorization: Bearer $PBN_API_KEY"
import os
import requests
headers = {"Authorization": "Bearer " + os.environ["PBN_API_KEY"]}
r = requests.get("https://app.pbn.ltd/api/v1/external-sites/deployments/deployment_id/log", headers=headers, timeout=120)
print(r.status_code, r.json())
const res = await fetch("https://app.pbn.ltd/api/v1/external-sites/deployments/deployment_id/log", {
method: "GET",
headers: {Authorization: `Bearer ${process.env.PBN_API_KEY}`}
});
console.log(res.status, await res.json());
Response
200
{
"data": {
"id": 551,
"kind": "deploy",
"state": "done",
"log": "Uploading 12 files..."
}
}
Errors: not_found, payment_required, rate_limited, scope_missing, unauthorized
Remove an external site
DELETE/api/v1/external-sites/{ext_site_id}Scope extdeploy:write · destructive
Stops managing the site here. With delete_remote the project is deleted at the provider too, and whatever it serves goes offline.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
ext_site_id | path | integer | yes | The external site id. |
delete_remote | body | boolean | no | true = also delete the project at the provider (where the provider allows it). Default: leave it there. Default: False. |
confirm | body | boolean | yes | Must be true. |
Example
curl -s -X DELETE "https://app.pbn.ltd/api/v1/external-sites/9" \
-H "Authorization: Bearer $PBN_API_KEY"
import os
import requests
headers = {"Authorization": "Bearer " + os.environ["PBN_API_KEY"]}
r = requests.delete("https://app.pbn.ltd/api/v1/external-sites/9", headers=headers, timeout=120)
print(r.status_code, r.json())
const res = await fetch("https://app.pbn.ltd/api/v1/external-sites/9", {
method: "DELETE",
headers: {Authorization: `Bearer ${process.env.PBN_API_KEY}`}
});
console.log(res.status, await res.json());
Response
200
{
"data": {
"removed": true,
"deleted_at_provider": false
}
}
Errors: conflict, not_found, payment_required, rate_limited, scope_missing, unauthorized, validation_failed
Attach a custom domain
POST/api/v1/external-sites/{ext_site_id}/domainsScope extdeploy:write
Attaches the domain at the provider and returns the DNS records to publish wherever that domain's DNS is answered.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
ext_site_id | path | integer | yes | The external site id. |
hostname | body | string | yes | e.g. landing.example.com |
Example
curl -s -X POST "https://app.pbn.ltd/api/v1/external-sites/9/domains" \
-H "Authorization: Bearer $PBN_API_KEY"
import os
import requests
headers = {"Authorization": "Bearer " + os.environ["PBN_API_KEY"]}
r = requests.post("https://app.pbn.ltd/api/v1/external-sites/9/domains", headers=headers, timeout=120)
print(r.status_code, r.json())
const res = await fetch("https://app.pbn.ltd/api/v1/external-sites/9/domains", {
method: "POST",
headers: {Authorization: `Bearer ${process.env.PBN_API_KEY}`}
});
console.log(res.status, await res.json());
Response
201
{
"data": {
"id": 4,
"hostname": "landing.example.com",
"state": "pending",
"https": false,
"message": "Add the DNS record shown.",
"records": [
{
"type": "CNAME",
"name": "landing",
"value": "my-landing.netlify.app"
}
]
}
}
Errors: conflict, not_found, payment_required, rate_limited, scope_missing, unauthorized
Check a custom domain again
POST/api/v1/external-sites/{ext_site_id}/domains/{domain_id}/refreshScope extdeploy:write
Asks the provider again whether the domain's DNS and certificate are in place.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
ext_site_id | path | integer | yes | The external site id. |
domain_id | path | integer | yes | The domain id. |
Example
curl -s -X POST "https://app.pbn.ltd/api/v1/external-sites/9/domains/domain_id/refresh" \
-H "Authorization: Bearer $PBN_API_KEY"
import os
import requests
headers = {"Authorization": "Bearer " + os.environ["PBN_API_KEY"]}
r = requests.post("https://app.pbn.ltd/api/v1/external-sites/9/domains/domain_id/refresh", headers=headers, timeout=120)
print(r.status_code, r.json())
const res = await fetch("https://app.pbn.ltd/api/v1/external-sites/9/domains/domain_id/refresh", {
method: "POST",
headers: {Authorization: `Bearer ${process.env.PBN_API_KEY}`}
});
console.log(res.status, await res.json());
Response
200
{
"data": {
"id": 4,
"hostname": "landing.example.com",
"state": "pending",
"https": false,
"message": "Add the DNS record shown.",
"records": [
{
"type": "CNAME",
"name": "landing",
"value": "my-landing.netlify.app"
}
]
}
}
Errors: not_found, payment_required, rate_limited, scope_missing, unauthorized
Detach a custom domain
DELETE/api/v1/external-sites/{ext_site_id}/domains/{domain_id}Scope extdeploy:write · destructive
Detaches the domain at the provider (its DNS records are yours to remove).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
ext_site_id | path | integer | yes | The external site id. |
domain_id | path | integer | yes | The domain id. |
confirm | body | boolean | yes | Must be true. |
Example
curl -s -X DELETE "https://app.pbn.ltd/api/v1/external-sites/9/domains/domain_id" \
-H "Authorization: Bearer $PBN_API_KEY"
import os
import requests
headers = {"Authorization": "Bearer " + os.environ["PBN_API_KEY"]}
r = requests.delete("https://app.pbn.ltd/api/v1/external-sites/9/domains/domain_id", headers=headers, timeout=120)
print(r.status_code, r.json())
const res = await fetch("https://app.pbn.ltd/api/v1/external-sites/9/domains/domain_id", {
method: "DELETE",
headers: {Authorization: `Bearer ${process.env.PBN_API_KEY}`}
});
console.log(res.status, await res.json());
Response
200
{
"data": {
"removed": true
}
}
Errors: conflict, not_found, payment_required, rate_limited, scope_missing, unauthorized, validation_failed
Your connected hosting accounts
GET/api/v1/external-connectionsScope extdeploy:read
The accounts at GitHub, GitLab, Cloudflare, Netlify, Vercel, Render, AWS or Azure you have connected, and whether each still works. The credentials are never returned; connecting an account is done on the connections page (https://app.pbn.ltd/extdeploy/connections).
Example
curl -s "https://app.pbn.ltd/api/v1/external-connections" \
-H "Authorization: Bearer $PBN_API_KEY"
import os
import requests
headers = {"Authorization": "Bearer " + os.environ["PBN_API_KEY"]}
r = requests.get("https://app.pbn.ltd/api/v1/external-connections", headers=headers, timeout=120)
print(r.status_code, r.json())
const res = await fetch("https://app.pbn.ltd/api/v1/external-connections", {
method: "GET",
headers: {Authorization: `Bearer ${process.env.PBN_API_KEY}`}
});
console.log(res.status, await res.json());
Response
200
{
"data": [
{
"id": 3,
"provider": "netlify",
"provider_name": "Netlify",
"label": "Work account",
"account": "[email protected]",
"status": "ok",
"status_message": "",
"last_checked_at": "2026-09-26T08:00:00Z",
"sites": 2,
"created_at": "2026-08-02T09:00:00Z"
}
]
}
Errors: payment_required, rate_limited, scope_missing, unauthorized
Test a connected account
POST/api/v1/external-connections/{connection_id}/testScope extdeploy:write
Asks the provider whether the stored credentials still work (the page's Test button).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
connection_id | path | integer | yes | The connection id. |
Example
curl -s -X POST "https://app.pbn.ltd/api/v1/external-connections/connection_id/test" \
-H "Authorization: Bearer $PBN_API_KEY"
import os
import requests
headers = {"Authorization": "Bearer " + os.environ["PBN_API_KEY"]}
r = requests.post("https://app.pbn.ltd/api/v1/external-connections/connection_id/test", headers=headers, timeout=120)
print(r.status_code, r.json())
const res = await fetch("https://app.pbn.ltd/api/v1/external-connections/connection_id/test", {
method: "POST",
headers: {Authorization: `Bearer ${process.env.PBN_API_KEY}`}
});
console.log(res.status, await res.json());
Response
200
{
"data": {
"id": 3,
"provider": "netlify",
"status": "ok",
"message": "connected"
}
}
Errors: conflict, not_found, payment_required, rate_limited, scope_missing, unauthorized
Disconnect a hosting account
DELETE/api/v1/external-connections/{connection_id}Scope extdeploy:write · destructive
Forgets the account's credentials here. Nothing is changed at the provider. Refused while sites still publish through it.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
connection_id | path | integer | yes | The connection id. |
confirm | body | boolean | yes | Must be true. |
Example
curl -s -X DELETE "https://app.pbn.ltd/api/v1/external-connections/connection_id" \
-H "Authorization: Bearer $PBN_API_KEY"
import os
import requests
headers = {"Authorization": "Bearer " + os.environ["PBN_API_KEY"]}
r = requests.delete("https://app.pbn.ltd/api/v1/external-connections/connection_id", headers=headers, timeout=120)
print(r.status_code, r.json())
const res = await fetch("https://app.pbn.ltd/api/v1/external-connections/connection_id", {
method: "DELETE",
headers: {Authorization: `Bearer ${process.env.PBN_API_KEY}`}
});
console.log(res.status, await res.json());
Response
200
{
"data": {
"removed": true
}
}
Errors: conflict, not_found, payment_required, rate_limited, scope_missing, unauthorized, validation_failed
Upload a built site (zip)
POST/api/v1/sites/{site_id}/files/archiveScope files:write
Unpacks a zip of a built website (or a plugin or theme) into the site. Every file goes through the same path a single upload uses, so the same rules hold for each one: inside the site folder only, symbolic links are never followed and the platform's own folders are refused. Up to 60 files and 25 MB in one call - send a bigger site in parts.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
site_id | path | integer | yes | The site id (see GET /sites). |
zip_base64 | body | string | yes | The .zip file, base64. |
path | body | string | no | Folder inside the site to unpack into (default: the site root). |
strip_top_folder | body | boolean | no | Drop the single top folder the zip may have ("mysite/index.html" -> "index.html"). Default: False. |
overwrite | body | boolean | no | Replace files that already exist. Default: False. |
Example
curl -s -X POST "https://app.pbn.ltd/api/v1/sites/123/files/archive" \
-H "Authorization: Bearer $PBN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"zip_base64": "UEsDBAoAAAAAA...", "path": "", "strip_top_folder": true, "overwrite": true}'
import os
import requests
headers = {"Authorization": "Bearer " + os.environ["PBN_API_KEY"]}
r = requests.post("https://app.pbn.ltd/api/v1/sites/123/files/archive", headers=headers, json={"zip_base64": "UEsDBAoAAAAAA...", "path": "", "strip_top_folder": True, "overwrite": True}, timeout=120)
print(r.status_code, r.json())
const res = await fetch("https://app.pbn.ltd/api/v1/sites/123/files/archive", {
method: "POST",
headers: {Authorization: `Bearer ${process.env.PBN_API_KEY}`, "Content-Type": "application/json"},
body: JSON.stringify({"zip_base64": "UEsDBAoAAAAAA...", "path": "", "strip_top_folder": true, "overwrite": true})
});
console.log(res.status, await res.json());
Response
201
{
"data": {
"site_id": 123,
"folder": "/",
"written": 12,
"failed": 0,
"bytes": 284113,
"files": [
{
"path": "index.html",
"bytes": 4201
}
],
"problems": []
}
}
Errors: conflict, forbidden, not_found, rate_limited, scope_missing, too_large, unauthorized, validation_failed
Set the home page
POST/api/v1/sites/{site_id}/home-pageScope sites:write · destructive
Makes a page the front page of the site. On WordPress this sets the site's own "front page" setting; on the other site types the file you name is copied over index.html (or index.php), which replaces what is there now.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
site_id | path | integer | yes | The site id (see GET /sites). |
page | body | string | yes | WordPress: the page id or its exact title. Other site types: the file to use, e.g. "home.html". |
Example
curl -s -X POST "https://app.pbn.ltd/api/v1/sites/123/home-page" \
-H "Authorization: Bearer $PBN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"page": "home.html"}'
import os
import requests
headers = {"Authorization": "Bearer " + os.environ["PBN_API_KEY"]}
r = requests.post("https://app.pbn.ltd/api/v1/sites/123/home-page", headers=headers, json={"page": "home.html"}, timeout=120)
print(r.status_code, r.json())
const res = await fetch("https://app.pbn.ltd/api/v1/sites/123/home-page", {
method: "POST",
headers: {Authorization: `Bearer ${process.env.PBN_API_KEY}`, "Content-Type": "application/json"},
body: JSON.stringify({"page": "home.html"})
});
console.log(res.status, await res.json());
Response
200
{
"data": {
"site_id": 123,
"home": "page 42",
"url": "https://example.com/"
}
}
Errors: conflict, not_found, rate_limited, scope_missing, unauthorized, validation_failed
Fetch a page and check it
POST/api/v1/sites/{site_id}/verifyScope sites:read
Fetches a page of the site and says exactly what came back: the HTTP status, how long it took, its size, its title and any redirect. The page is always fetched on the site's own server (so it works even before the domain points at us) and, when the address allows it, from the internet as well. The newest PHP errors are read at the same time. This is the honest test after changing anything - a plugin, a theme, a file or a setting.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
site_id | path | integer | yes | The site id (see GET /sites). |
path | body | string | no | The page to fetch, e.g. "/about". Default: /. |
public | body | boolean | no | Also try the page from the internet. Default: True. |
errors | body | boolean | no | Also read the PHP error log afterwards. Default: True. |
Example
curl -s -X POST "https://app.pbn.ltd/api/v1/sites/123/verify" \
-H "Authorization: Bearer $PBN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"path": "/"}'
import os
import requests
headers = {"Authorization": "Bearer " + os.environ["PBN_API_KEY"]}
r = requests.post("https://app.pbn.ltd/api/v1/sites/123/verify", headers=headers, json={"path": "/"}, timeout=120)
print(r.status_code, r.json())
const res = await fetch("https://app.pbn.ltd/api/v1/sites/123/verify", {
method: "POST",
headers: {Authorization: `Bearer ${process.env.PBN_API_KEY}`, "Content-Type": "application/json"},
body: JSON.stringify({"path": "/"})
});
console.log(res.status, await res.json());
Response
200
{
"data": {
"site_id": 123,
"url": "https://example.com/",
"path": "/",
"from_server": {
"status": 200,
"seconds": 0.31,
"bytes": 51233,
"redirect_to": null,
"title": "Example - Home",
"error": null
},
"from_internet": {
"status": 200,
"content_type": "text/html",
"bytes": 51233,
"title": "Example - Home",
"error": null
},
"looks_ok": true,
"php_errors": [],
"php_errors_total": 0
}
}
Errors: conflict, not_found, rate_limited, scope_missing, unauthorized, validation_failed
Plugins on this site
GET/api/v1/sites/{site_id}/pluginsScope sites:read
Every plugin installed on a WordPress site, whether it is active, its version and whether an update is waiting.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
site_id | path | integer | yes | The site id (see GET /sites). |
Example
curl -s "https://app.pbn.ltd/api/v1/sites/123/plugins" \
-H "Authorization: Bearer $PBN_API_KEY"
import os
import requests
headers = {"Authorization": "Bearer " + os.environ["PBN_API_KEY"]}
r = requests.get("https://app.pbn.ltd/api/v1/sites/123/plugins", headers=headers, timeout=120)
print(r.status_code, r.json())
const res = await fetch("https://app.pbn.ltd/api/v1/sites/123/plugins", {
method: "GET",
headers: {Authorization: `Bearer ${process.env.PBN_API_KEY}`}
});
console.log(res.status, await res.json());
Response
200
{
"data": {
"site_id": 123,
"plugins": [
{
"slug": "my-plugin",
"title": "My plugin",
"status": "active",
"version": "1.0.0",
"update": "none",
"auto_update": "off"
}
],
"count": 1
}
}
Errors: conflict, not_found, rate_limited, scope_missing, unauthorized
Install a plugin
POST/api/v1/sites/{site_id}/pluginsScope sites:write · destructive
Installs a plugin on a WordPress site, from wordpress.org or from a zip you send. A plugin that is on our blocked list is refused. Activating a plugin can break a site: install first, then activate, then check the site with sites.verify and read the error log.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
site_id | path | integer | yes | The site id (see GET /sites). |
slug | body | string | no | A wordpress.org plugin slug, e.g. "classic-editor". |
zip_base64 | body | string | no | Or your own plugin as a .zip file, base64. |
activate | body | boolean | no | Switch it on straight away. Test the site afterwards with sites.verify. Default: False. |
Example
curl -s -X POST "https://app.pbn.ltd/api/v1/sites/123/plugins" \
-H "Authorization: Bearer $PBN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"slug": "classic-editor", "activate": false}'
import os
import requests
headers = {"Authorization": "Bearer " + os.environ["PBN_API_KEY"]}
r = requests.post("https://app.pbn.ltd/api/v1/sites/123/plugins", headers=headers, json={"slug": "classic-editor", "activate": False}, timeout=120)
print(r.status_code, r.json())
const res = await fetch("https://app.pbn.ltd/api/v1/sites/123/plugins", {
method: "POST",
headers: {Authorization: `Bearer ${process.env.PBN_API_KEY}`, "Content-Type": "application/json"},
body: JSON.stringify({"slug": "classic-editor", "activate": false})
});
console.log(res.status, await res.json());
Response
201
{
"data": {
"site_id": 123,
"installed": true,
"activated": false,
"output": "Plugin installed successfully.",
"plugins": [
{
"slug": "my-plugin",
"title": "My plugin",
"status": "active",
"version": "1.0.0",
"update": "none",
"auto_update": "off"
}
]
}
}
Errors: conflict, forbidden, not_found, rate_limited, scope_missing, too_large, unauthorized, validation_failed
Switch a plugin on or off
POST/api/v1/sites/{site_id}/plugins/{slug}/{action}Scope sites:write · destructive
Activates or deactivates a plugin. Activating can take a site down, so check it with sites.verify afterwards; deactivating is how you put it back.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
site_id | path | integer | yes | The site id (see GET /sites). |
slug | path | string | yes | The plugin folder name. |
action | path | string (one of: activate, deactivate) | yes | What to do. |
Example
curl -s -X POST "https://app.pbn.ltd/api/v1/sites/123/plugins/my-plugin/activate" \
-H "Authorization: Bearer $PBN_API_KEY"
import os
import requests
headers = {"Authorization": "Bearer " + os.environ["PBN_API_KEY"]}
r = requests.post("https://app.pbn.ltd/api/v1/sites/123/plugins/my-plugin/activate", headers=headers, timeout=120)
print(r.status_code, r.json())
const res = await fetch("https://app.pbn.ltd/api/v1/sites/123/plugins/my-plugin/activate", {
method: "POST",
headers: {Authorization: `Bearer ${process.env.PBN_API_KEY}`}
});
console.log(res.status, await res.json());
Response
200
{
"data": {
"site_id": 123,
"slug": "my-plugin",
"action": "activate",
"output": "Plugin 'my-plugin' activated."
}
}
Errors: conflict, not_found, rate_limited, scope_missing, unauthorized, validation_failed
Remove a plugin
DELETE/api/v1/sites/{site_id}/plugins/{slug}Scope sites:write · destructive
Switches a plugin off and deletes its files. There is no undo; the plugin's own data in the database is removed the way the plugin asks for.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
site_id | path | integer | yes | The site id (see GET /sites). |
slug | path | string | yes | The plugin folder name. |
Example
curl -s -X DELETE "https://app.pbn.ltd/api/v1/sites/123/plugins/my-plugin" \
-H "Authorization: Bearer $PBN_API_KEY"
import os
import requests
headers = {"Authorization": "Bearer " + os.environ["PBN_API_KEY"]}
r = requests.delete("https://app.pbn.ltd/api/v1/sites/123/plugins/my-plugin", headers=headers, timeout=120)
print(r.status_code, r.json())
const res = await fetch("https://app.pbn.ltd/api/v1/sites/123/plugins/my-plugin", {
method: "DELETE",
headers: {Authorization: `Bearer ${process.env.PBN_API_KEY}`}
});
console.log(res.status, await res.json());
Response
200
{
"data": {
"site_id": 123,
"slug": "my-plugin",
"deleted": true
}
}
Errors: conflict, not_found, rate_limited, scope_missing, unauthorized
Themes on this site
GET/api/v1/sites/{site_id}/themesScope sites:read
Every theme on a WordPress site and which one is in use.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
site_id | path | integer | yes | The site id (see GET /sites). |
Example
curl -s "https://app.pbn.ltd/api/v1/sites/123/themes" \
-H "Authorization: Bearer $PBN_API_KEY"
import os
import requests
headers = {"Authorization": "Bearer " + os.environ["PBN_API_KEY"]}
r = requests.get("https://app.pbn.ltd/api/v1/sites/123/themes", headers=headers, timeout=120)
print(r.status_code, r.json())
const res = await fetch("https://app.pbn.ltd/api/v1/sites/123/themes", {
method: "GET",
headers: {Authorization: `Bearer ${process.env.PBN_API_KEY}`}
});
console.log(res.status, await res.json());
Response
200
{
"data": {
"site_id": 123,
"themes": [
{
"slug": "twentytwentyfive",
"title": "Twenty Twenty-Five",
"status": "active",
"version": "1.0.0",
"update": "none",
"auto_update": "off"
}
],
"count": 1
}
}
Errors: conflict, not_found, rate_limited, scope_missing, unauthorized
Use this theme
POST/api/v1/sites/{site_id}/themes/{slug}/activateScope sites:write · destructive
Switches the site to another theme. This changes how every page looks at once - check the site with sites.verify straight after, and switch back if it is wrong.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
site_id | path | integer | yes | The site id (see GET /sites). |
slug | path | string | yes | The theme folder name. |
Example
curl -s -X POST "https://app.pbn.ltd/api/v1/sites/123/themes/twentytwentyfive/activate" \
-H "Authorization: Bearer $PBN_API_KEY"
import os
import requests
headers = {"Authorization": "Bearer " + os.environ["PBN_API_KEY"]}
r = requests.post("https://app.pbn.ltd/api/v1/sites/123/themes/twentytwentyfive/activate", headers=headers, timeout=120)
print(r.status_code, r.json())
const res = await fetch("https://app.pbn.ltd/api/v1/sites/123/themes/twentytwentyfive/activate", {
method: "POST",
headers: {Authorization: `Bearer ${process.env.PBN_API_KEY}`}
});
console.log(res.status, await res.json());
Response
200
{
"data": {
"site_id": 123,
"slug": "twentytwentyfive",
"active": true,
"output": "Success: Switched to Twenty Twenty-Five theme."
}
}
Errors: conflict, not_found, rate_limited, scope_missing, unauthorized
Check PHP code for errors
POST/api/v1/sites/{site_id}/code-checkScope sites:read
Runs PHP's own syntax check over a file or every .php file in a folder, in the exact PHP version the site runs. Do this before switching a plugin or theme on - a syntax error there takes the whole site down.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
site_id | path | integer | yes | The site id (see GET /sites). |
path | body | string | yes | A file or folder in the site, e.g. "wp-content/plugins/my-plugin". |
Example
curl -s -X POST "https://app.pbn.ltd/api/v1/sites/123/code-check" \
-H "Authorization: Bearer $PBN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"path": "wp-content/plugins/my-plugin"}'
import os
import requests
headers = {"Authorization": "Bearer " + os.environ["PBN_API_KEY"]}
r = requests.post("https://app.pbn.ltd/api/v1/sites/123/code-check", headers=headers, json={"path": "wp-content/plugins/my-plugin"}, timeout=120)
print(r.status_code, r.json())
const res = await fetch("https://app.pbn.ltd/api/v1/sites/123/code-check", {
method: "POST",
headers: {Authorization: `Bearer ${process.env.PBN_API_KEY}`, "Content-Type": "application/json"},
body: JSON.stringify({"path": "wp-content/plugins/my-plugin"})
});
console.log(res.status, await res.json());
Response
200
{
"data": {
"site_id": 123,
"path": "wp-content/plugins/my-plugin",
"php_version": "8.3.14",
"ok": true,
"problems": []
}
}
Errors: conflict, not_found, rate_limited, scope_missing, unauthorized, validation_failed
Sites on third-party hosts
GET/api/v1/external-sitesScope sites:read
The sites this account publishes to outside PBN.LTD (GitHub Pages, Cloudflare Pages, Netlify, Vercel and the rest), with their address and the state of the last deploy. Connecting a provider and adding a site stay in the panel, because they need your own provider credentials.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
limit | query | integer | no | How many to return. Default: 20. |
cursor | query | string | no | The next_cursor value of the previous page. |
provider | query | string | no | Only this provider. |
Example
curl -s "https://app.pbn.ltd/api/v1/external-sites" \
-H "Authorization: Bearer $PBN_API_KEY"
import os
import requests
headers = {"Authorization": "Bearer " + os.environ["PBN_API_KEY"]}
r = requests.get("https://app.pbn.ltd/api/v1/external-sites", headers=headers, timeout=120)
print(r.status_code, r.json())
const res = await fetch("https://app.pbn.ltd/api/v1/external-sites", {
method: "GET",
headers: {Authorization: `Bearer ${process.env.PBN_API_KEY}`}
});
console.log(res.status, await res.json());
Response
200
{
"data": [
{
"id": 9,
"name": "Landing page",
"provider": "netlify",
"provider_name": "Netlify",
"project": "my-landing",
"state": "live",
"state_message": null,
"url": "https://my-landing.netlify.app",
"domain": "landing.example.com",
"source": "upload",
"source_site_id": 123,
"last_deploy_at": "2026-09-19T14:22:00Z",
"created_at": "2026-08-02T09:00:00Z"
}
],
"next_cursor": null,
"has_more": false
}
Errors: payment_required, rate_limited, scope_missing, unauthorized
Deploys of an external site
GET/api/v1/external-sites/{ext_site_id}/deploymentsScope sites:read
The recent deploys of one external site, newest first - what to pass to a rollback.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
ext_site_id | path | integer | yes | |
limit | query | integer | no | Default: 25. |
Example
curl -s "https://app.pbn.ltd/api/v1/external-sites/9/deployments" \
-H "Authorization: Bearer $PBN_API_KEY"
import os
import requests
headers = {"Authorization": "Bearer " + os.environ["PBN_API_KEY"]}
r = requests.get("https://app.pbn.ltd/api/v1/external-sites/9/deployments", headers=headers, timeout=120)
print(r.status_code, r.json())
const res = await fetch("https://app.pbn.ltd/api/v1/external-sites/9/deployments", {
method: "GET",
headers: {Authorization: `Bearer ${process.env.PBN_API_KEY}`}
});
console.log(res.status, await res.json());
Response
200
{
"data": {
"site_id": 9,
"deployments": [
{
"id": 551,
"kind": "deploy",
"state": "done",
"created_at": "2026-09-19T14:20:00Z",
"finished_at": "2026-09-19T14:22:00Z",
"files": 12,
"bytes": 284113,
"message": null,
"reference": "a1b2c3d4"
}
]
}
}
Errors: not_found, payment_required, rate_limited, scope_missing, unauthorized
Deploy again or roll back
POST/api/v1/external-sites/{ext_site_id}/deployScope sites:write · destructive
Publishes the site to its provider again, or rolls it back to a deploy that is already there. This replaces what is live at that provider. No files travel through this call - to publish new files, put them on the PBN.LTD site first (files.write or files.upload_archive) and then redeploy.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
ext_site_id | path | integer | yes | |
kind | body | string (one of: redeploy, rollback) | no | Build and publish again, or go back to an earlier deploy. Default: redeploy. |
rollback_to | body | string | no | The deploy to go back to (see extsites.history). Needed for a rollback. |
Example
curl -s -X POST "https://app.pbn.ltd/api/v1/external-sites/9/deploy" \
-H "Authorization: Bearer $PBN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"kind": "redeploy"}'
import os
import requests
headers = {"Authorization": "Bearer " + os.environ["PBN_API_KEY"]}
r = requests.post("https://app.pbn.ltd/api/v1/external-sites/9/deploy", headers=headers, json={"kind": "redeploy"}, timeout=120)
print(r.status_code, r.json())
const res = await fetch("https://app.pbn.ltd/api/v1/external-sites/9/deploy", {
method: "POST",
headers: {Authorization: `Bearer ${process.env.PBN_API_KEY}`, "Content-Type": "application/json"},
body: JSON.stringify({"kind": "redeploy"})
});
console.log(res.status, await res.json());
Response
202
{
"data": {
"site_id": 9,
"deployment_id": 552,
"kind": "redeploy",
"state": "queued",
"url": "https://my-landing.netlify.app"
}
}
Errors: conflict, not_found, payment_required, rate_limited, scope_missing, unauthorized, validation_failed