Sites
OAuth scopes: reads require sites:read, writes require sites:write.
Two exceptions, in both directions: the
backup export endpoints require sites:write
including the status read, while the metrics endpoints require only
sites:read even though they are POSTs — they retrieve data rather than
change anything. The metrics POSTs do still require site edit permission,
though — a read-only collaborator gets 403
{"errors":["Not Authorized"]} even when holding sites:read.
A dunning-suspended site returns 402 service_suspended on its
show/update/destroy and tool endpoints (list endpoints instead surface
dunning_suspended: true).
A pending-delete site — one that has been deleted and is awaiting purge —
returns 403 pending_delete on update and delete and on every nested site
endpoint, reads included, while still appearing in list results and still
resolving on GET /api/sites/:id. See Delete a Site.
Related site tooling lives on its own pages: CDN, Cache & Logs and Shield.
List Sites
If you omit the account ID header in this api call, it will return all sites available to your user. If you include the account ID header, it will only return sites for this account.
GET /api/sites
Deleted sites still appear here
Deletion is recoverable, so a deleted site stays in this list with
pending_delete: true until it is purged — it is no longer dropped at delete
time. If your integration treats this list as "live sites only", filter on
pending_delete.
Returned Params
- sites: Array
- id: String
- name: String
- primary_domain: String
- location: String (Geographic Region)
- account: Object
- id: String
- name: String
- package: String
- dunning_suspended: Boolean
- pending_delete: Boolean |
truewhile the site is awaiting purge - delete_scheduled_at: DateTime | frozen purge date;
nullfor live sites - created_at
- updated_at
View Site
GET /api/sites/:id
A pending-delete site still resolves here — it does not 404 — but returns a
deliberately reduced payload (second block below).
Returned Params
- site: Object
- id: String
- name: String
- primary_domain: String
- location: String (Geographic Region)
- region: String (Availability Zone)
- package: String
- pending_delete: Boolean |
falsehere - php_version: String
- domain_cname: String
- sftp_base_path: String
- dunning_suspended: Boolean | always
falseon this endpoint — a suspended site returns402instead - ssh: Object
- ipaddr: String
- username: String
- password: String
- port: Integer
- domains: Array
- See domains api endpoint
- subscription: Object
- id: String
- status: String
- created_at: DateTime
- updated_at: DateTime
- price: Object
- amount_cents: Integer
- term: String
- run_rate: Object | the subscription's recurring charge, serialized as a money object with a
cents(Integer) key and acurrency_iso(String) key taken from the account's billing plan. It is not a bare integer - sites: Array | every site on this subscription, each a full site summary object (the same shape as an entry in List Sites) — including the site you requested. A shared subscription makes this a large nested payload
- product: Object
- id: String
- name: String
- account: Object
- id: String
- name: String
- created_at
- updated_at
Returned Params (pending-delete site)
While a site is awaiting purge its container is powered off, so the response
carries only identity plus delete status — region, php_version,
domain_cname, sftp_base_path, dunning_suspended, ssh, domains and
subscription are all omitted.
- site: Object
- id: String
- name: String
- primary_domain: String
- location: String (Geographic Region)
- package: String
- pending_delete: Boolean |
true - status: String |
"pending_delete"(this field is absent for live sites) - delete_scheduled_at: DateTime | when the site will be permanently purged
- account: Object
- id: String
- name: String
- created_at
- updated_at
Update Site / Resize
PATCH /api/sites/:id
Handles two intents:
- Rename (synchronous) — pass
name. Returns202with an empty body. - Plan change (cart-mediated, async) — pass
plan(a Product short_name). The price difference is invoiced against the account's default payment method off-session, and the response is the shared cart envelope (see Orders / Carts) — poll the cart for completion. It does not return atask_id. Requires a single-site account and a default payment method on file; the billing term is preserved.
When both fields are present, the rename runs first; on rename validation
failure the resize is skipped entirely and the response is 422.
A pending-delete site cannot be updated by either intent: the request is
rejected with 403 pending_delete and a delete_scheduled_at field, and nothing
is changed. Restore the site first.
Params
- name: String | site rename
- plan: String | Product short_name (e.g. "basic")
Returned Params (plan-change path, 202 Accepted)
- status: String |
"accepted" - cart: Object | { token, status, rollup_status, poll_url }
- payment: Object | { status, method_type, hosted_invoice_url }
- orders: Array |
[{ id, status, poll_url }](empty during the async window)
Errors (plan change)
- 400
unknown_product|plandoesn't match any Product short_name - 400
product_unavailable| Product exists but not offered on the account's billing plan - 400
no_default_payment_method| billing account not ready to charge - 422
no_price_for_plan| no price matching the account's term - 422
multi_site_resize_unsupported| account has more than one active site - 422
subscription_not_proratable| subscription is missing Stripe metadata - 422
cart_pay_failed| the off-session charge could not be initiated - 422
billing_settling| the site's billing is still settling (a freshly created site's initial billing window)
billing_settling is 422, not 402
The billing-settle guard on a plan change (and on
site delete) returns 422 billing_settling. This is
distinct from dunning suspension, which returns 402 service_suspended
(see the page header).
Restart a Site
Restart the WordPress container for a site. Does not affect other containers such as the database or redis. Returns 202.
POST /api/sites/:id/restart
curl -X POST -H "Authorization: Bearer $CLOUDPRESS_TOKEN" -H "X-Auth-Account: $ACCOUNT_ID" \
https://your-instance/api/sites/$SITE_ID/restart
Delete a Site
DELETE /api/sites/:id
Deletes the site recoverably, asynchronously. Returns 200 as soon as the
request is accepted.
The site does not vanish — it enters a pending-delete state for a retention window, during which:
- Its container is powered off; its data, backups and infrastructure are retained.
- Billing is cancelled immediately — renewals stop and unused time is credited to the account's balance.
- Transactional Email for the site is suspended for the duration of the window.
- The site stays listed in
GET /api/siteswithpending_delete: true, andGET /api/sites/:idstill returns it (reduced payload,status: "pending_delete"). - Everything else about the site is locked:
PATCH /api/sites/:id, a secondDELETE, and every nested site endpoint — reads included — return403pending_deletewith the site'sdelete_scheduled_at. delete_scheduled_atis frozen at delete time as now + the account's site retention window, which defaults to 7 days unless the account or its billing plan sets a different value.
When the window elapses the site is purged, which is permanent and
irreversible: the CDN pull zone is destroyed, the container project and its volumes
— the site's data — are destroyed, and the site's domain records are removed,
releasing each hostname. Until the purge those hostnames are still held
by the pending-delete site, so attaching one to a different site in the same
billing family returns 422 hostname_in_use.
sites:write alone no longer authorizes a delete
Deleting a site now requires the token's user to be a member of the site's
account holding the billing-management role — an account admin, or a role
carrying both billing and edit permission. For an account that inherits
billing, the role is checked on its billing account. Elevated CloudPress staff
privileges no longer qualify on their own: a caller who is not a member of the
account gets 403 {"errors":["Not Authorized"]} where the same token may have
succeeded previously.
Restore and early purge are not available over the API
There is no API endpoint to restore a pending-delete site or to purge it ahead of schedule; both live on the site's page in the CloudPress dashboard. Restore is a paid re-purchase of the same plan (the subscription was cancelled at delete time), not a free undo, and purging immediately waives the rest of the retention window.
Errors
- 403 | caller is not an account member with the billing-management role (
{"errors":["Not Authorized"]}) - 403
pending_delete| the site is already pending deletion; body includesdelete_scheduled_at - 402
service_suspended| the site is dunning-suspended (see the page header) - 422
billing_settling| the site's billing is still settling (a freshly created site's initial billing window)
Removing a site may affect volume discounts on existing sites.
Site SSO
This is a hybrid endpoint. It supports logging in for all SSO operations on a site. Currently that is WordPress and phpMyAdmin (database).
Not available via OAuth — SSO requires a session or API-key credential.
Without the wp_login role flag, you can only generate URLs for WordPress
users the token's user is explicitly linked to; with wp_login, any user.
List All WP Users
This returns raw user details from the WordPress site. The associated ID is WordPress' internal ID, not a CloudPress user.
GET /api/sites/:id/sso
Returned Params
- users: Array
- ID: Integer
- user_login: String
- display_name: String
- user_email: String
- user_registered: DateTime
- roles: String
- url: String
Login as User
POST /api/sites/:id/sso
Success returns 200 { "url": "..." }; failure returns 400. Returns 422
{"errors":["Missing username"]} when username is required but blank.
Params
- kind: String |
wordpress(default) ordatabase - username: String | required unless
kindis exactlydatabase. The guard testskind != "database", so omittingkindaltogether still requires a username — send it whenever you are not doing a database login
Returned Params
- url: String
Tasks
List all tasks for a given site.
GET /api/sites/{site-id}/tasks
GET /api/sites/{site-id}/tasks/{filter-name}/filter
Filter is optional, can be one of:
- OK
- PENDING
- RUNNING
- CANCELLED
- PAUSED
- FAILED
- TODAY <-- Special filter to show all events from today.
All responses will be limited to the 100 most recent tasks.
Returned Params
Returns an array of objects:
id: Integername: Stringdata: String | Raw data from the eventlabels: Object | Callback bookkeeping is stripped — anycallback_*key you set when placing an order is redacted herestatus: String | OK, PENDING, RUNNING, CANCELLED, PAUSED, FAILEDstart_on: Timestamp | When the task was started. Omitted entirely (notnull) when unsetend_on: Timestamp | Not consistently used, but may be when the task was completed. Omitted entirely (notnull) when unsetcreated_at: Timestampupdated_at: Timestampperformed_by: Object | Omitted entirely when no user is attributedid: String | null | The user's ID, ornullwhen the actor is maskedname: String | Full name of the user, or a masked stand-inemail: String | null |nullwhen the actor is masked
performed_by is masked for non-customer actors
Tasks performed by the platform itself report
{"id": null, "name": "System", "email": null}, and tasks performed by
support staff report {"id": null, "name": "Support", "email": null}. Only
actions taken by a user in your own workspace return a real ID, name and
email address. Treat name as a display string, not an identifier — match on
id and expect it to be null.
View a single site task
GET /api/sites/{site-id}/tasks/{id}
Returns a single task object (same fields as above).
Errors
- 404
unknown_task| no task with that id belongs to this site
Metrics
Realtime Resources
This endpoint will return the realtime status for a site.
GET /api/sites/{site-id}/metrics/resources
Returned Params
total_storage: Decimal{Image Name}: Object | i.e. "WordPress"cpu: Decimal | Percentage of total planstorage: Decimal | GBmemory: Decimal | MB
Raw Metrics
This is an advanced endpoint that shows raw container metrics for a given site. Due to the nature of the CloudPress platform, new sites may return metric data created prior to the creation of your site. This is expected behavior, as the site may already exist waiting for a customer order.
Furthermore, it may return additional containers that the customer may not directly be able to interface with. For example, 'CloudPress Lite' may not include redis, but you may still see a redis container in these results. This is also an expected behavior of our platform.
POST /api/sites/{site-id}/metrics/resources
kind can be one or more of:
- cpu
- cpu_throttled
- memory
- memory_throttled
- storage
Params
kind: Array |['storage', 'memory'](example)period_start: Integer | unix timestamp (must be within the last month)period_end: Integer | unix timestamp (after period_start, within the last month)step: String | defaults to '1m'.
Period and Step parameters have no affect on storage metric. That will always return the current value.
Returned Params
service_name: Objectid: Integername: Stringimage: Stringresources: Object | Dependent on which values selectedcpu:Array<time, value>cpu_throttled:Array<time, value>memory:Array<time, value>memory_throttled:Array<time, value>storage: Decimal
Errors
All error bodies use the {"errors": [...]} array shape.
- 422 | empty or invalid
kind, orperiod_start/period_endoutside the last month / out of order - 502 | upstream metric service unavailable
- 400 | unexpected failure
CDN Metrics
Retrieve RAW cdn metrics. Full description of the returned data can be found on Bunny's API Documentation Site. Note: Click the 200 under Responses to see field explanation.
POST /api/sites/{site-id}/metrics/cdn
Params
period_start: Integer | Unix timestamp. Takes precedence overperiod. (>3 months old → 422)step: String | hourly or monthly. Only consulted withperiod_start.period: String | preset window — one of12h,24h,7d,30d. Used only whenperiod_startis absent. Unknown values → 422.
Returned Params
TotalBandwidthUsed: Integer | For the given periodTotalOriginTraffic: Integer | For the given periodTotalRequestsServed: Integer | For the given periodCacheHitRate: IntegerOriginResponseTimeChart: Object |"2025-11-20T00:00:00Z": 0(ISO8601 timestamp → Integer)BandwidthUsedChart: ObjectBandwidthCachedChart: ObjectCacheHitRateChart: ObjectRequestsServedChart: ObjectPullRequestsPulledChart: ObjectOriginTrafficChart: ObjectGeoTrafficDistribution: Object | e.g."EU: Stockholm, SE": 0Error3xxChart: ObjectError4xxChart: ObjectError5xxChart: Object
The response is Bunny's raw statistics payload rendered verbatim (only
UserBalanceHistoryChart is stripped), so it carries additional fields beyond
those listed — the request enables origin-response-time and origin-shield-bandwidth
series. Read those key names from a live response; the names above are the ones this
platform itself reads, and are the only ones it can guarantee.
Units: bandwidth and traffic totals are bytes, exactly as returned upstream;
CacheHitRate is a percentage on a 0–100 scale, passed through unrounded. The
control panel converts bandwidth to KB and rounds the hit rate — that conversion
is not applied here.
Errors
- 409 | CDN not active for this site
- 422 | missing/zero/too-old
period_start, or unknownperiod - 502 | upstream statistics fetch failed
Example: Show Monthly Bandwidth
curl -X POST -d '{"period_start": 1760983782, "step": "monthly"}'
This will return data for the past month, in daily increments, but more importantly, the TotalBandwidthUsed value will be for the entire 30 day period.
See also Shield metrics.
Backups
Please use the Tasks endpoint to monitor the status of these actions.
List All Backups
Return a list of backups for a given site, grouped by volume. Returns [] when
there are no volumes; a volume with no archives renders as {}.
GET /api/sites/{site-id}/backups
Returned Params
{label}: Object | i.e. wordpress, or mysqlid: Integer | ID of the volume (required for backup operations)size: Decimal | GB - total raw backup size (not the volume's provisioned capacity)usage: Decimal | GB - compressed and deduplicated backup size (actual usage on disk)archives: Arrayid: Stringname: Stringcreated: String | in iso8601 format
Create a backup
PATCH /api/sites/{site-id}/backups/{volume-id}
Returns 202. An unknown volume returns 404 {"errors":["Volume not found"]}.
Params
name: String | Backup Name
Delete a backup
DELETE /api/sites/{site-id}/backups/{volume-id}
Returns 202.
Params
backup_id: String | Backup ID
Restore a backup
PATCH /api/sites/{site-id}/restores/{volume-id}
Returns 202.
Params
backup_id: String | Backup ID
Request a backup export
POST /api/sites/{site-id}/backups/{volume-id}/export
Ask for a downloadable .tar.gz of one backup archive. Asynchronous: the archive is
materialized onto object storage and a short-lived presigned download URL is minted.
Returns 202 with {"status": "preparing", "backup_id": "..."} — then poll
Export status for the URL.
Both export endpoints require sites:write
Reading export status requires sites:write too — a sites:read token cannot
even poll it. The status response carries a URL that grants read access to the
whole backup, so both actions are treated as writes and both additionally
require edit permission on the site.
Params
backup_id: String (required) | Archive ID from List All Backups
Errors
- 422 |
backup_idmissing ({"errors":["backup_id is required"]}) - 404 | unknown volume (
{"errors":["Volume not found"]}) - 403 | the token's user has no edit permission on the site (
{"errors":["Not Authorized"]})
A 202 does not guarantee a new export started. Exports are prepared one at
a time per volume, and a request made while one is already in flight is dropped.
Treat the status endpoint — not a task id — as the source of truth for what the
archive is actually doing.
Export status and download
GET /api/sites/{site-id}/backups/{volume-id}/export?backup_id={archive-id}
Returns the export state of a single archive, plus the presigned download URL once
it is ready. Fetch that URL with a plain HTTPS GET — it needs no CloudPress
credentials of its own.
Params
backup_id: String (required) | Archive ID, passed in the query string
Returned Params
status: String | one ofnone,in_progress,ready,expired,failedurl: String | only whenready— presigned HTTPS URL to the.tar.gzexpires_at: String | only whenready— iso8601, when the URL lapsessize: Integer | only whenready— size of the export in byteserror: String | only whenfailed— human-readable failure reason
Errors
- 422 |
backup_idmissing ({"errors":["backup_id is required"]}) - 404 | unknown volume (
{"errors":["Volume not found"]}), or no archive matchesbackup_id({"errors":["Backup not found"]}) - 403 | the token's user has no edit permission on the site (
{"errors":["Not Authorized"]})
Treat url as a bearer secret
Anyone who has it can download the entire backup until it expires. It is
returned only while status is ready, the response is sent with
Cache-Control: no-store, and it should never be logged, forwarded or stored.
Once status becomes expired, request a fresh export.
Change PHP Version
List Available
GET /api/sites/{site-id}/variants
Returned Params
php:Array<Object>| Currently onlyphpis returned.id: Integer |variant_idneeded for changing the versionlabel: Stringis_default: Boolean | When deploying a new site, this will be used.active: Boolean | Current version the site is using
Change
When changing a site's php version, the site will be restarted with a different container image. Please allow time for the site to restart. Returns 202. The URL segment must be php — any other variant returns 422 {"errors":["only php version changes are allowed"]}.
PATCH /api/sites/{site-id}/variants/php
Params
variant_id: Integer
Returned Params (202 Accepted)
task_id: Integer | The ID of the task so you can follow along.