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.
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