PBN.LTD API docs
View as Markdown

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/archive

Scope 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

NameInTypeRequiredDescription
site_idpathintegeryesThe site id (see GET /sites).
zip_base64bodystringyesThe .zip file, base64.
pathbodystringnoFolder inside the site to unpack into (default: the site root).
strip_top_folderbodybooleannoDrop the single top folder the zip may have ("mysite/index.html" -> "index.html"). Default: False.
overwritebodybooleannoReplace 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-page

Scope 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

NameInTypeRequiredDescription
site_idpathintegeryesThe site id (see GET /sites).
pagebodystringyesWordPress: 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}/verify

Scope 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

NameInTypeRequiredDescription
site_idpathintegeryesThe site id (see GET /sites).
pathbodystringnoThe page to fetch, e.g. "/about". Default: /.
publicbodybooleannoAlso try the page from the internet. Default: True.
errorsbodybooleannoAlso 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}/plugins

Scope sites:read

Every plugin installed on a WordPress site, whether it is active, its version and whether an update is waiting.

Parameters

NameInTypeRequiredDescription
site_idpathintegeryesThe 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}/plugins

Scope 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

NameInTypeRequiredDescription
site_idpathintegeryesThe site id (see GET /sites).
slugbodystringnoA wordpress.org plugin slug, e.g. "classic-editor".
zip_base64bodystringnoOr your own plugin as a .zip file, base64.
activatebodybooleannoSwitch 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

NameInTypeRequiredDescription
site_idpathintegeryesThe site id (see GET /sites).
slugpathstringyesThe plugin folder name.
actionpathstring (one of: activate, deactivate)yesWhat 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

NameInTypeRequiredDescription
site_idpathintegeryesThe site id (see GET /sites).
slugpathstringyesThe 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}/themes

Scope sites:read

Every theme on a WordPress site and which one is in use.

Parameters

NameInTypeRequiredDescription
site_idpathintegeryesThe 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}/activate

Scope 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

NameInTypeRequiredDescription
site_idpathintegeryesThe site id (see GET /sites).
slugpathstringyesThe 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-check

Scope 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

NameInTypeRequiredDescription
site_idpathintegeryesThe site id (see GET /sites).
pathbodystringyesA 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-sites

Scope 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

NameInTypeRequiredDescription
limitqueryintegernoHow many to return. Default: 20.
cursorquerystringnoThe next_cursor value of the previous page.
providerquerystringnoOnly 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}/deployments

Scope sites:read

The recent deploys of one external site, newest first - what to pass to a rollback.

Parameters

NameInTypeRequiredDescription
ext_site_idpathintegeryes
limitqueryintegernoDefault: 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}/deploy

Scope 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

NameInTypeRequiredDescription
ext_site_idpathintegeryes
kindbodystring (one of: redeploy, rollback)noBuild and publish again, or go back to an earlier deploy. Default: redeploy.
rollback_tobodystringnoThe 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