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

| 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

```bash
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}'
```

```python
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())
```

```javascript
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`

```json
{
  "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 /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

| 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

```bash
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"}'
```

```python
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())
```

```javascript
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`

```json
{
  "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 /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

| 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

```bash
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": "/"}'
```

```python
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())
```

```javascript
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`

```json
{
  "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 /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

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `site_id` | path | integer | yes | The site id (see GET /sites). |

### Example

```bash
curl -s "https://app.pbn.ltd/api/v1/sites/123/plugins" \
  -H "Authorization: Bearer $PBN_API_KEY"
```

```python
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())
```

```javascript
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`

```json
{
  "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 /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

| 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

```bash
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}'
```

```python
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())
```

```javascript
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`

```json
{
  "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 /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

```bash
curl -s -X POST "https://app.pbn.ltd/api/v1/sites/123/plugins/my-plugin/activate" \
  -H "Authorization: Bearer $PBN_API_KEY"
```

```python
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())
```

```javascript
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`

```json
{
  "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 /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

```bash
curl -s -X DELETE "https://app.pbn.ltd/api/v1/sites/123/plugins/my-plugin" \
  -H "Authorization: Bearer $PBN_API_KEY"
```

```python
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())
```

```javascript
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`

```json
{
  "data": {
    "site_id": 123,
    "slug": "my-plugin",
    "deleted": true
  }
}
```

Errors: `conflict`, `not_found`, `rate_limited`, `scope_missing`, `unauthorized`

## Themes on this site

`GET /sites/{site_id}/themes` · scope `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

```bash
curl -s "https://app.pbn.ltd/api/v1/sites/123/themes" \
  -H "Authorization: Bearer $PBN_API_KEY"
```

```python
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())
```

```javascript
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`

```json
{
  "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 /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

| 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

```bash
curl -s -X POST "https://app.pbn.ltd/api/v1/sites/123/themes/twentytwentyfive/activate" \
  -H "Authorization: Bearer $PBN_API_KEY"
```

```python
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())
```

```javascript
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`

```json
{
  "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 /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

| 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

```bash
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"}'
```

```python
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())
```

```javascript
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`

```json
{
  "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 /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

| 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

```bash
curl -s "https://app.pbn.ltd/api/v1/external-sites" \
  -H "Authorization: Bearer $PBN_API_KEY"
```

```python
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())
```

```javascript
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`

```json
{
  "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 /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

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `ext_site_id` | path | integer | yes |  |
| `limit` | query | integer | no |  Default: `25`. |

### Example

```bash
curl -s "https://app.pbn.ltd/api/v1/external-sites/9/deployments" \
  -H "Authorization: Bearer $PBN_API_KEY"
```

```python
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())
```

```javascript
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`

```json
{
  "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 /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

| 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

```bash
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"}'
```

```python
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())
```

```javascript
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`

```json
{
  "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`
