Introduction
Edge delivery and image optimisation for WordPress.
This reference is generated from the routes themselves, so it cannot drift from what the API actually serves.
Three conventions
Sites are addressed by whatever you already have. Anywhere {site} appears
you may pass the numeric id, the label, or the domain with or without scheme and
www.. A miss returns 404 listing the domains that do exist, so a wrong guess
corrects itself in one turn.
Byte figures are already reconciled. Do not sum original across formats or
scopes yourself: the usage rollup is keyed by output format and holds every image
twice, so adding them reports a library of exactly twice its real size.
benchmark_modelled is derived from our own measured head-to-head against
Imagify, not from a live competitor result, and must never be quoted as one.
Markup is not evidence. Conversion counts, plugin status and <picture> tags
can all look perfect while no visitor receives an optimised byte. Only
POST /v1/sites/{site}/verify proves delivery — it fetches the live page, finds
an asset on the delivery hostname, and fetches that too.
Rate limits
Keyed on the bearer token, not on your address, so an agency's own tabs do not
throttle each other: 180 requests a minute overall, 20 for POST /v1/analyse,
30 for POST /v1/sites/{site}/verify. Exceeding one returns 429 with
Retry-After.
The plugin's credential is a different thing
The Plugin (site key) endpoints authenticate as a site, using the key held by the WordPress plugin rather than an account token. That key lives on a machine we do not control, so it is scoped to one site's blast radius: a stolen key purges and warms that site and nothing else, cannot read another site, cannot touch DNS or storage, and never reveals a CDN credential. Do not use an account token there, or a site key anywhere else.
Authenticating requests
To authenticate requests, include an Authorization header with the value "Bearer {YOUR_TOKEN}".
All authenticated endpoints are marked with a requires authentication badge in the documentation below.
Create one in the console at app.peregrinecdn.com/tokens, then send it as Authorization: Bearer <token>.
Tokens carry abilities, and the split is not read versus write - it is whether the call can change what visitors are served or what the account is billed. read, purge and analyse are the default set; sites, warm and provision have to be granted deliberately. A refusal is a 403 naming the ability that was missing, so a client can correct itself rather than stopping to ask somebody to read this page.
Discovery
Liveness.
Answers without a credential, and says nothing a stranger should not see. This is the only route the documentation generator is allowed to call for real; every other example is written by hand, so that generating docs can never reach a customer's data.
Example request:
curl --request GET \
--get "https://api.peregrinecdn.com/api/health" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://api.peregrinecdn.com/api/health"
);
const headers = {
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/health';
$response = $client->get(
$url,
[
'headers' => [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/health'
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers)
response.json()Example response (200):
Show headers
cache-control: no-cache, private
content-type: application/json
access-control-allow-origin: *
{
"ok": true,
"service": "peregrine-control-plane",
"version": "1.1.0",
"db": "peregrine"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
The OpenAPI document.
Public, because a client needs to read it before it has a credential, and it describes shapes rather than data.
Example request:
curl --request GET \
--get "https://api.peregrinecdn.com/api/v1/openapi.json" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://api.peregrinecdn.com/api/v1/openapi.json"
);
const headers = {
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/openapi.json';
$response = $client->get(
$url,
[
'headers' => [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/openapi.json'
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers)
response.json()Example response (200):
{
"openapi": "3.0.3",
"info": {
"title": "Peregrine API",
"version": "1.1.0"
},
"paths": {}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
What this token can do.
requires authentication
The first call any client should make. Returns the account, the abilities this token actually holds, the sites it can address, the warm-region catalogue, and the conventions of the API - so a program can work without being told about it in advance.
Example request:
curl --request GET \
--get "https://api.peregrinecdn.com/api/v1/meta" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://api.peregrinecdn.com/api/v1/meta"
);
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/meta';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/meta'
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers)
response.json()Example response (200):
{
"version": "1.1.0",
"account": {
"id": 42,
"name": "Acme Digital",
"kind": "agency",
"plan": "dive"
},
"token": {
"name": "Acme automation",
"authenticated_as": "api token",
"abilities": [
"read",
"purge",
"analyse"
],
"last_used_at": "2026-09-15T08:14:02.000000Z",
"expires_at": null
},
"sites": [
{
"id": 7,
"label": "example-co-uk",
"domain": "example.co.uk",
"status": "active",
"cdn_base": "https://images.example.co.uk"
}
],
"warm_regions": [
"UK",
"DE",
"US-East",
"Singapore"
],
"conventions": {
"site_reference": "Pass the numeric id, the label, or the domain.",
"bytes": "Totals are reconciled server-side. Use them as given.",
"benchmark": "benchmark_modelled is our measured head-to-head, not a live competitor result.",
"delivery": "Markup proves nothing. POST /v1/sites/{site}/verify is the evidence.",
"ttfb": "Quote the measured improvement between two points, never an absolute figure."
}
}
Example response (401, no token, or a revoked one):
{
"message": "Unauthenticated."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Account, plan and entitlement.
requires authentication
Includes what has been paid for. Where no order exists there is no ceiling, deliberately: blocking someone who has paid costs them what they bought today, while letting one extra through costs a little unbilled usage.
Example request:
curl --request GET \
--get "https://api.peregrinecdn.com/api/v1/account" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://api.peregrinecdn.com/api/v1/account"
);
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/account';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/account'
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers)
response.json()Example response (200):
{
"id": 42,
"name": "Acme Digital",
"email": "ops@acme.example",
"kind": "agency",
"plan": "dive",
"status": "active",
"entitlement": {
"sites_bought": 25,
"sites_in_use": 11,
"warm_locations": 3,
"shield": true
},
"usage": {
"images": 54874,
"bytes": {
"original": 12020896087,
"optimised": 1169791846,
"saved": 10851104241,
"benchmark_modelled": 2432456692
},
"percent": {
"vs_original": 90,
"vs_benchmark": 52
},
"benchmark_note": null,
"human": {
"original": "11.2 GB",
"optimised": "1.1 GB",
"saved": "10.1 GB"
},
"first_day": "2026-08-18",
"last_day": "2026-09-15"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Sites
List sites.
requires authentication
Every site on the account with its delivery state, DNS state and lifetime byte totals. The right first call for "which of my sites has a problem".
Example request:
curl --request GET \
--get "https://api.peregrinecdn.com/api/v1/sites?status=active&delivery_state=degraded&q=breckland" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://api.peregrinecdn.com/api/v1/sites"
);
const params = {
"status": "active",
"delivery_state": "degraded",
"q": "breckland",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/sites';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'status' => 'active',
'delivery_state' => 'degraded',
'q' => 'breckland',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/sites'
params = {
'status': 'active',
'delivery_state': 'degraded',
'q': 'breckland',
}
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers, params=params)
response.json()Example response (200):
[
{
"id": 7,
"domain": "example.co.uk",
"label": "example-co-uk",
"cdn_base": "https://images.example.co.uk",
"status": "active",
"delivery_state": "ok",
"dns_state": "ok",
"images": 4223,
"original_bytes": 229983921,
"optimised_bytes": 37648511,
"benchmark_bytes": 54807310
}
]
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Register a site that already delivers.
requires authentication
Writes one row and returns one key. Touches no CDN resource - that is the whole point of it being separate from provisioning. The live estate's pull zones already exist and pull from an origin rather than storage, so provisioning one of them would build a new storage-backed zone and move the hostname onto it: a full outage arrived at by running the tool that looks right.
Registration claims a NAME. It proves nothing about who owns the domain, and nothing happens until the key is installed on the site and DNS points here. A domain whose only claim is a deleted row is revived for its own account and released for anybody else.
Example request:
curl --request POST \
"https://api.peregrinecdn.com/api/v1/sites" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"domain\": \"example.co.uk\",
\"label\": \"example-co-uk\",
\"cdn_base\": \"https:\\/\\/images.example.co.uk\",
\"path_prefix\": \"wp-content\\/uploads\"
}"
const url = new URL(
"https://api.peregrinecdn.com/api/v1/sites"
);
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"domain": "example.co.uk",
"label": "example-co-uk",
"cdn_base": "https:\/\/images.example.co.uk",
"path_prefix": "wp-content\/uploads"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/sites';
$response = $client->post(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'domain' => 'example.co.uk',
'label' => 'example-co-uk',
'cdn_base' => 'https://images.example.co.uk',
'path_prefix' => 'wp-content/uploads',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/sites'
payload = {
"domain": "example.co.uk",
"label": "example-co-uk",
"cdn_base": "https:\/\/images.example.co.uk",
"path_prefix": "wp-content\/uploads"
}
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()Example response (201):
{
"site": {
"id": 8,
"label": "example-co-uk",
"domain": "example.co.uk",
"status": "active",
"created_at": "2026-09-15T08:20:00.000000Z",
"delivery": {
"cdn_base": null,
"storage_zone": null,
"path_prefix": "wp-content/uploads",
"state": "unknown",
"checked_at": null,
"ok_at": null,
"fails": 0
},
"dns": {
"state": "unknown",
"checked_at": null,
"ok_at": null,
"nudges": 0
},
"warm": {
"status": "paused",
"regions": [],
"interval_seconds": 604800,
"last_run_at": null,
"last_warmed": 0,
"last_errors": 0,
"last_note": null,
"queue_pending": 0,
"pops": []
},
"key": {
"prefix": "9f2c1ab4"
},
"report": {
"enabled": false,
"url": null
},
"usage": {
"images": 0,
"bytes": {
"original": 0,
"optimised": 0,
"saved": 0,
"benchmark_modelled": 0
},
"percent": {
"vs_original": 0,
"vs_benchmark": null
},
"benchmark_note": "No modelled benchmark recorded for this data.",
"human": {
"original": "0 B",
"optimised": "0 B",
"saved": "0 B"
},
"first_day": null,
"last_day": null
}
},
"key": "9f2c1ab4...shown once and never stored...",
"warning": "Shown once and never stored in plaintext. Paste it into the Peregrine plugin on example.co.uk now."
}
Example response (403, the token lacks the ability):
{
"error": "missing_ability",
"ability": "sites",
"detail": "This token cannot create, rename, pause and resume sites; rotate site keys. Issue a token with the \"sites\" ability from the console at https://app.peregrinecdn.com/tokens, then retry."
}
Example response (409, the domain is already registered):
{
"error": "site_exists",
"detail": "That domain or label is already in use.",
"site": null
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Everything about one site.
requires authentication
Deliberately one call rather than five. The question an operator actually asks is "is this site fine", and the parts of the answer live in four tables - usage, delivery, DNS and warming. Making the caller fetch and join them is how a check gets half done.
Example request:
curl --request GET \
--get "https://api.peregrinecdn.com/api/v1/sites/example.co.uk" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://api.peregrinecdn.com/api/v1/sites/example.co.uk"
);
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/sites/example.co.uk';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/sites/example.co.uk'
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers)
response.json()Example response (200):
{
"id": 7,
"label": "example-co-uk",
"domain": "example.co.uk",
"status": "active",
"created_at": "2026-08-18T09:12:44.000000Z",
"delivery": {
"cdn_base": "https://images.example.co.uk",
"storage_zone": "pg-example-co-uk",
"path_prefix": "wp-content/uploads",
"state": "ok",
"checked_at": "2026-09-15T07:40:11.000000Z",
"ok_at": "2026-09-15T07:40:11.000000Z",
"fails": 0
},
"dns": {
"state": "ok",
"checked_at": "2026-09-15T06:00:00.000000Z",
"ok_at": "2026-08-19T10:02:00.000000Z",
"nudges": 0
},
"warm": {
"status": "active",
"regions": [
"UK",
"DE"
],
"interval_seconds": 604800,
"last_run_at": "2026-09-14T02:11:09.000000Z",
"last_warmed": 812,
"last_errors": 0,
"last_note": "purged+rewarmed 812 url(s) across 2 pop(s)",
"queue_pending": 0,
"pops": [
{
"hostname": "example.b-cdn.net",
"region": "UK",
"pop_code": "UK1-1486",
"edge_ip": "203.0.113.10",
"resolved_at": "2026-09-14T02:10:58.000000Z"
}
]
},
"key": {
"prefix": "9f2c1ab4"
},
"report": {
"enabled": true,
"url": "https://app.peregrinecdn.com/r/4f1c8a2e9b7d6350a1c2e4f6b8d0a2c4"
},
"usage": {
"images": 4223,
"bytes": {
"original": 229983921,
"optimised": 37648511,
"saved": 192335410,
"benchmark_modelled": 54807310
},
"percent": {
"vs_original": 84,
"vs_benchmark": 31
},
"benchmark_note": null,
"human": {
"original": "219 MB",
"optimised": "35.9 MB",
"saved": "183 MB"
},
"first_day": "2026-08-18",
"last_day": "2026-09-15"
}
}
Example response (404, no site on this account matches):
{
"error": "site_not_found",
"ref": "exampl.co.uk",
"known": [
"example.co.uk",
"shop.example.co.uk"
],
"detail": "No site on this account matches that id, label or domain. The \"known\" list is what does exist."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Rename, repoint, pause or resume.
requires authentication
Pausing is a real operation rather than a UI state: a paused site stops being warmed and stops counting against the site allowance, so it is the honest thing to do with a client who has left.
Example request:
curl --request PATCH \
"https://api.peregrinecdn.com/api/v1/sites/example.co.uk" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"label\": \"example-co-uk\",
\"status\": \"paused\",
\"cdn_base\": \"https:\\/\\/images.example.co.uk\",
\"path_prefix\": \"wp-content\\/uploads\"
}"
const url = new URL(
"https://api.peregrinecdn.com/api/v1/sites/example.co.uk"
);
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"label": "example-co-uk",
"status": "paused",
"cdn_base": "https:\/\/images.example.co.uk",
"path_prefix": "wp-content\/uploads"
};
fetch(url, {
method: "PATCH",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/sites/example.co.uk';
$response = $client->patch(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'label' => 'example-co-uk',
'status' => 'paused',
'cdn_base' => 'https://images.example.co.uk',
'path_prefix' => 'wp-content/uploads',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/sites/example.co.uk'
payload = {
"label": "example-co-uk",
"status": "paused",
"cdn_base": "https:\/\/images.example.co.uk",
"path_prefix": "wp-content\/uploads"
}
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('PATCH', url, headers=headers, json=payload)
response.json()Example response (200):
{
"id": 7,
"label": "example-co-uk",
"domain": "example.co.uk",
"status": "paused",
"created_at": "2026-08-18T09:12:44.000000Z",
"delivery": {
"cdn_base": "https://images.example.co.uk",
"storage_zone": "pg-example-co-uk",
"path_prefix": "wp-content/uploads",
"state": "ok",
"checked_at": "2026-09-15T07:40:11.000000Z",
"ok_at": "2026-09-15T07:40:11.000000Z",
"fails": 0
},
"dns": {
"state": "ok",
"checked_at": null,
"ok_at": null,
"nudges": 0
},
"warm": {
"status": "active",
"regions": [
"UK"
],
"interval_seconds": 604800,
"last_run_at": null,
"last_warmed": 0,
"last_errors": 0,
"last_note": null,
"queue_pending": 0,
"pops": []
},
"key": {
"prefix": "9f2c1ab4"
},
"report": {
"enabled": false,
"url": null
},
"usage": {
"images": 4223,
"bytes": {
"original": 229983921,
"optimised": 37648511,
"saved": 192335410,
"benchmark_modelled": 54807310
},
"percent": {
"vs_original": 84,
"vs_benchmark": 31
},
"benchmark_note": null,
"human": {
"original": "219 MB",
"optimised": "35.9 MB",
"saved": "183 MB"
},
"first_day": "2026-08-18",
"last_day": "2026-09-15"
}
}
Example response (422, cdn_base was not a bare hostname):
{
"message": "The cdn base field format is invalid.",
"errors": {
"cdn_base": [
"The cdn base field format is invalid."
]
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Stop managing a site.
requires authentication
Soft. Usage history is kept, because it is the evidence behind every savings figure already quoted to that client. The CDN zones are deliberately not touched - deleting a pull zone from an API call is how a live site goes dark on a typo.
Example request:
curl --request DELETE \
"https://api.peregrinecdn.com/api/v1/sites/example.co.uk" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://api.peregrinecdn.com/api/v1/sites/example.co.uk"
);
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "DELETE",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/sites/example.co.uk';
$response = $client->delete(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/sites/example.co.uk'
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('DELETE', url, headers=headers)
response.json()Example response (200):
{
"deleted": true,
"detail": "Stopped managing example.co.uk. Usage history is kept. Its Bunny zones were not touched - remove them in Bunny if you want them gone."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Rotate the site key.
requires authentication
The old key stops working the moment this returns, so the plugin on that site is disconnected until the new one is pasted in. Do not do this speculatively. Plaintext is returned once and never stored.
Example request:
curl --request POST \
"https://api.peregrinecdn.com/api/v1/sites/example.co.uk/key" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://api.peregrinecdn.com/api/v1/sites/example.co.uk/key"
);
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "POST",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/sites/example.co.uk/key';
$response = $client->post(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/sites/example.co.uk/key'
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers)
response.json()Example response (200):
{
"key": "9f2c1ab4...shown once and never stored...",
"prefix": "9f2c1ab4",
"warning": "Shown once. The old key stopped working the moment this returned, so the plugin on example.co.uk is disconnected until this is pasted in."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
What is still heavy.
requires authentication
Two lists, because they need different fixes. "heaviest" is the largest files a visitor still downloads. "poorly_compressed" barely shrank - almost always a photograph saved as a PNG, or something already compressed. No encoder setting fixes the second list; the source file has to change.
Figures are the AVIF rows only, which is what a visitor receives.
Example request:
curl --request GET \
--get "https://api.peregrinecdn.com/api/v1/sites/example.co.uk/images?limit=20" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://api.peregrinecdn.com/api/v1/sites/example.co.uk/images"
);
const params = {
"limit": "20",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/sites/example.co.uk/images';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'limit' => '20',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/sites/example.co.uk/images'
params = {
'limit': '20',
}
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers, params=params)
response.json()Example response (200):
{
"site_id": 7,
"domain": "example.co.uk",
"format": "avif",
"note": "Figures are the AVIF rows only - what a visitor downloads. WebP fallbacks cover the same originals and are not added in.",
"heaviest": [
{
"path": "/wp-content/uploads/2026/03/team-photo.png",
"original_bytes": 412880,
"optimised_bytes": 380114,
"quality": 45
}
],
"poorly_compressed": [
{
"path": "/wp-content/uploads/2026/03/team-photo.png",
"original_bytes": 412880,
"optimised_bytes": 380114,
"quality": 45
}
],
"unpathed": 0
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Prove a visitor receives optimised images.
requires authentication
Fetches the live page, finds an asset on the delivery hostname, and fetches that too.
Every failure this product has had arrived as a silent success: the plugin reports connected, the site returns 200, the conversions table fills up, and not one optimised byte reaches a browser - because a DNS record changed, a certificate lapsed, or a key was rotated and never pasted back. Nothing on the WordPress side can see that; the evidence exists only from outside.
stage walks the chain: page, markup, format, asset, delivered. Both
hostnames are checked to resolve only to public addresses, and the
connection is pinned to the address that passed.
Example request:
curl --request POST \
"https://api.peregrinecdn.com/api/v1/sites/example.co.uk/verify" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://api.peregrinecdn.com/api/v1/sites/example.co.uk/verify"
);
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "POST",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/sites/example.co.uk/verify';
$response = $client->post(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/sites/example.co.uk/verify'
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers)
response.json()Example response (200, delivering):
{
"site_id": 7,
"domain": "example.co.uk",
"ok": true,
"stage": "delivered",
"references": 42,
"sample": "https://images.example.co.uk/wp-content/uploads/2026/03/hero.jpg.avif",
"status": 200,
"content_type": "image/avif",
"cache": "HIT",
"why": "A visitor receives image/avif from images.example.co.uk."
}
Example response (200, not delivering):
{
"site_id": 7,
"domain": "example.co.uk",
"ok": false,
"stage": "format",
"references": 42,
"sample": null,
"status": 0,
"content_type": "",
"cache": null,
"why": "The page references images.example.co.uk but serves no AVIF or WebP from it, so visitors are still downloading originals."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Turn the client report link on or off.
requires authentication
Disabling clears the token rather than flipping a flag, so a link that has been revoked cannot come back later by someone toggling the flag the other way.
Example request:
curl --request POST \
"https://api.peregrinecdn.com/api/v1/sites/example.co.uk/report" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"enabled\": true,
\"email\": \"client@example.co.uk\"
}"
const url = new URL(
"https://api.peregrinecdn.com/api/v1/sites/example.co.uk/report"
);
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"enabled": true,
"email": "client@example.co.uk"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/sites/example.co.uk/report';
$response = $client->post(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'enabled' => true,
'email' => 'client@example.co.uk',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/sites/example.co.uk/report'
payload = {
"enabled": true,
"email": "client@example.co.uk"
}
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()Example response (200, enabling):
{
"enabled": true,
"url": "https://app.peregrinecdn.com/r/4f1c8a2e9b7d6350a1c2e4f6b8d0a2c4",
"email": "client@example.co.uk"
}
Example response (200, disabling - the token is destroyed, not hidden):
{
"enabled": false,
"url": null
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Usage
Lifetime totals for the account.
requires authentication
Reconciled server-side. Do not re-derive savings by summing columns: the rollup is keyed by format and holds every image twice.
Example request:
curl --request GET \
--get "https://api.peregrinecdn.com/api/v1/stats" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://api.peregrinecdn.com/api/v1/stats"
);
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/stats';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/stats'
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers)
response.json()Example response (200):
{
"images": 54874,
"bytes": {
"original": 12020896087,
"optimised": 1169791846,
"saved": 10851104241,
"benchmark_modelled": 2432456692
},
"percent": {
"vs_original": 90,
"vs_benchmark": 52
},
"benchmark_note": null,
"human": {
"original": "11.2 GB",
"optimised": "1.1 GB",
"saved": "10.1 GB"
},
"first_day": "2026-08-18",
"last_day": "2026-09-15",
"seconds_saved_at_10mbps": 8680.9
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Daily series across the account.
requires authentication
One row per day, already deduplicated by format.
Example request:
curl --request GET \
--get "https://api.peregrinecdn.com/api/v1/trend?days=30" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://api.peregrinecdn.com/api/v1/trend"
);
const params = {
"days": "30",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/trend';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'days' => '30',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/trend'
params = {
'days': '30',
}
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers, params=params)
response.json()Example response (200):
[
{
"day": "2026-09-14",
"images": 81,
"original_bytes": 19828439,
"optimised_bytes": 1720154,
"saved": 18108285
},
{
"day": "2026-09-15",
"images": 183,
"original_bytes": 24211808,
"optimised_bytes": 5494995,
"saved": 18716813
}
]
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Usage over an explicit window.
requires authentication
What an invoice or a monthly client report is built from. The window is inclusive at both ends and echoed back, because a report that silently dropped its last day would be wrong in the direction nobody checks.
Example request:
curl --request GET \
--get "https://api.peregrinecdn.com/api/v1/usage?from=2026-08-01&to=2026-08-31&site=example.co.uk" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://api.peregrinecdn.com/api/v1/usage"
);
const params = {
"from": "2026-08-01",
"to": "2026-08-31",
"site": "example.co.uk",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/usage';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'from' => '2026-08-01',
'to' => '2026-08-31',
'site' => 'example.co.uk',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/usage'
params = {
'from': '2026-08-01',
'to': '2026-08-31',
'site': 'example.co.uk',
}
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers, params=params)
response.json()Example response (200):
{
"from": "2026-08-01",
"to": "2026-08-31",
"total": {
"images": 4223,
"bytes": {
"original": 229983921,
"optimised": 37648511,
"saved": 192335410,
"benchmark_modelled": 54807310
},
"percent": {
"vs_original": 84,
"vs_benchmark": 31
},
"benchmark_note": null,
"human": {
"original": "219 MB",
"optimised": "35.9 MB",
"saved": "183 MB"
},
"first_day": "2026-08-18",
"last_day": "2026-08-31"
},
"sites": [
{
"site_id": 7,
"domain": "example.co.uk",
"label": "example-co-uk",
"images": 4223,
"bytes": {
"original": 229983921,
"optimised": 37648511,
"saved": 192335410,
"benchmark_modelled": 54807310
},
"percent": {
"vs_original": 84,
"vs_benchmark": 31
},
"benchmark_note": null,
"human": {
"original": "219 MB",
"optimised": "35.9 MB",
"saved": "183 MB"
},
"first_day": null,
"last_day": null
}
]
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Usage for one site.
requires authentication
Lifetime and windowed, plus the daily series.
Example request:
curl --request GET \
--get "https://api.peregrinecdn.com/api/v1/sites/example.co.uk/usage?from=2026-08-01&to=2026-08-31" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://api.peregrinecdn.com/api/v1/sites/example.co.uk/usage"
);
const params = {
"from": "2026-08-01",
"to": "2026-08-31",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/sites/example.co.uk/usage';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'from' => '2026-08-01',
'to' => '2026-08-31',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/sites/example.co.uk/usage'
params = {
'from': '2026-08-01',
'to': '2026-08-31',
}
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers, params=params)
response.json()Example response (200):
{
"site_id": 7,
"domain": "example.co.uk",
"from": "2026-08-16",
"to": "2026-09-15",
"lifetime": {
"images": 4223,
"bytes": {
"original": 229983921,
"optimised": 37648511,
"saved": 192335410,
"benchmark_modelled": 54807310
},
"percent": {
"vs_original": 84,
"vs_benchmark": 31
},
"benchmark_note": null,
"human": {
"original": "219 MB",
"optimised": "35.9 MB",
"saved": "183 MB"
},
"first_day": "2026-08-18",
"last_day": "2026-09-15"
},
"window": {
"images": 792,
"bytes": {
"original": 12939761,
"optimised": 3547457,
"saved": 9392304,
"benchmark_modelled": 6212516
},
"percent": {
"vs_original": 73,
"vs_benchmark": 43
},
"benchmark_note": null,
"human": {
"original": "12.3 MB",
"optimised": "3.4 MB",
"saved": "9.0 MB"
},
"first_day": "2026-08-19",
"last_day": "2026-08-19"
},
"days": [
{
"day": "2026-08-19",
"format": "avif",
"images": 792,
"original_bytes": 12939761,
"optimised_bytes": 3547457
}
]
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Edge
Purge URLs from the edge.
requires authentication
The plugin never holds a CDN key - that rule is why this exists. It says what changed; the worker purges, which clears every PoP in one call, and re-warms at each PoP the site pays for.
Rejections come back in rejected rather than being dropped: a
{"queued": 0} with a 200 is what weeks of silently-skipped purges look
like. More than 500 URLs is refused, not truncated.
Example request:
curl --request POST \
"https://api.peregrinecdn.com/api/v1/sites/example.co.uk/invalidate" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"urls\": [
\"https:\\/\\/example.co.uk\\/about\\/\"
]
}"
const url = new URL(
"https://api.peregrinecdn.com/api/v1/sites/example.co.uk/invalidate"
);
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"urls": [
"https:\/\/example.co.uk\/about\/"
]
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/sites/example.co.uk/invalidate';
$response = $client->post(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'urls' => ['https://example.co.uk/about/'],
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/sites/example.co.uk/invalidate'
payload = {
"urls": [
"https:\/\/example.co.uk\/about\/"
]
}
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()Example response (200):
{
"queued": 2,
"rejected": []
}
Example response (200, some URLs were rejected - always read this, a queued of 0 with a 200 is what weeks of silently-skipped purges look like):
{
"queued": 1,
"rejected": [
{
"url": "not a url",
"why": "not a URL"
}
]
}
Example response (429, the queue is already full):
{
"error": "queue_full",
"pending": 20000,
"detail": "This site already has 20000 URLs queued and not yet purged. Nothing was added."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Which edge regions a site keeps warm.
requires authentication
A CDN caches per point of presence, so a warm run only warms the PoP nearest whatever ran it. Measured on a client site warmed from Falkenstein: a HIT in about 55ms from Frankfurt, and a MISS at 1.1-1.4s from London, where the readers actually were.
Not the same axis as the site's storage region, which is one value and says where the origin copies live.
Example request:
curl --request GET \
--get "https://api.peregrinecdn.com/api/v1/sites/example.co.uk/warm" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://api.peregrinecdn.com/api/v1/sites/example.co.uk/warm"
);
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/sites/example.co.uk/warm';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/sites/example.co.uk/warm'
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers)
response.json()Example response (200):
{
"available": [
"UK",
"DE",
"US-East",
"US-West",
"Singapore",
"Thailand",
"Australia",
"Japan",
"India",
"Brazil",
"S-Africa"
],
"allowance": 3,
"regions": [
"UK",
"DE"
],
"pops": [
{
"hostname": "example.b-cdn.net",
"region": "UK",
"pop_code": "UK1-1486",
"edge_ip": "203.0.113.10",
"resolved_at": "2026-09-14T02:10:58.000000Z"
}
],
"job": {
"site_id": 7,
"status": "active",
"interval_seconds": 604800,
"last_run_at": "2026-09-14T02:11:09.000000Z",
"last_warmed": 812,
"last_errors": 0
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Choose where a site stays warm.
requires authentication
Billed per location. Asking for more than the account has paid for is refused with 402 rather than silently trimmed - a caller that asked for six and got two would report six, and the only symptom would be Australians finding the site slow.
Example request:
curl --request PUT \
"https://api.peregrinecdn.com/api/v1/sites/example.co.uk/warm" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"regions\": [
\"UK\",
\"DE\"
],
\"interval_seconds\": 604800
}"
const url = new URL(
"https://api.peregrinecdn.com/api/v1/sites/example.co.uk/warm"
);
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"regions": [
"UK",
"DE"
],
"interval_seconds": 604800
};
fetch(url, {
method: "PUT",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/sites/example.co.uk/warm';
$response = $client->put(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'regions' => ['UK', 'DE'],
'interval_seconds' => 604800,
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/sites/example.co.uk/warm'
payload = {
"regions": [
"UK",
"DE"
],
"interval_seconds": 604800
}
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('PUT', url, headers=headers, json=payload)
response.json()Example response (200):
{
"regions": [
"UK",
"DE"
],
"ignored": []
}
Example response (402, more regions than were paid for):
{
"error": "over_allowance",
"asked": 6,
"allowance": 3,
"detail": "This account has paid to keep 3 locations warm. Buy more locations, or choose fewer."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Ask for a full warm.
requires authentication
Expressed as "this job is overdue" rather than running anything inline: a full warm is thousands of requests and must not happen inside a request.
Example request:
curl --request POST \
"https://api.peregrinecdn.com/api/v1/sites/example.co.uk/warm/run" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://api.peregrinecdn.com/api/v1/sites/example.co.uk/warm/run"
);
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "POST",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/sites/example.co.uk/warm/run';
$response = $client->post(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/sites/example.co.uk/warm/run'
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers)
response.json()Example response (200):
{
"queued": true,
"detail": "Marked overdue. The warm worker picks it up on its next tick; read GET /v1/sites/7/warm for the result."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Provisioning
Build a domain out from nothing.
requires authentication
Storage zone, pull zone, hostname, certificate, edge profile and key.
Queued rather than run inline, and that is a security boundary rather than a performance one: provisioning needs the CDN account key, which can delete every zone we run for every client, so it never enters a process that serves web requests. A worker running as the user that owns the key picks the row up instead, and the queue doubles as the audit trail.
It will not reach "done" until the hostname resolves, because a certificate cannot be issued before that.
Example request:
curl --request POST \
"https://api.peregrinecdn.com/api/v1/provisions" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"domain\": \"example.co.uk\",
\"profile\": \"wordpress\",
\"region\": \"UK\"
}"
const url = new URL(
"https://api.peregrinecdn.com/api/v1/provisions"
);
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"domain": "example.co.uk",
"profile": "wordpress",
"region": "UK"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/provisions';
$response = $client->post(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'domain' => 'example.co.uk',
'profile' => 'wordpress',
'region' => 'UK',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/provisions'
payload = {
"domain": "example.co.uk",
"profile": "wordpress",
"region": "UK"
}
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()Example response (202):
{
"id": 41,
"status": "pending",
"detail": "Queued. The provisioner runs on a tick; poll GET /v1/provisions/41 for status and log. It will not be \"done\" until the certificate is issued, which needs the hostname to resolve first."
}
Example response (409, one is already in flight for that domain):
{
"error": "already_queued",
"detail": "A provision for example.co.uk is already pending."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Recent provisioning requests.
requires authentication
Example request:
curl --request GET \
--get "https://api.peregrinecdn.com/api/v1/provisions?limit=25" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://api.peregrinecdn.com/api/v1/provisions"
);
const params = {
"limit": "25",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/provisions';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'limit' => '25',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/provisions'
params = {
'limit': '25',
}
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers, params=params)
response.json()Example response (200):
[
{
"id": 41,
"account_id": 42,
"domain": "example.co.uk",
"profile": "wordpress",
"region": "UK",
"status": "done",
"site_id": 7,
"log": "storage zone ok / pull zone ok / hostname ok / certificate ok",
"created_at": "2026-09-15 08:20:00",
"started_at": "2026-09-15 08:20:31",
"finished_at": "2026-09-15 08:21:12"
}
]
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Status and log for one provisioning request.
requires authentication
Example request:
curl --request GET \
--get "https://api.peregrinecdn.com/api/v1/provisions/41" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://api.peregrinecdn.com/api/v1/provisions/41"
);
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/provisions/41';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/provisions/41'
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers)
response.json()Example response (200):
{
"id": 41,
"account_id": 42,
"domain": "example.co.uk",
"profile": "wordpress",
"region": "UK",
"status": "done",
"site_id": 7,
"log": "storage zone ok / pull zone ok / hostname ok / certificate ok",
"created_at": "2026-09-15 08:20:00",
"started_at": "2026-09-15 08:20:31",
"finished_at": "2026-09-15 08:21:12"
}
Example response (404, not on this account):
{
"error": "not_found"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Analysis
Measure any public page.
requires authentication
Fetches the page, then re-encodes up to forty of its images - real encoding of the real images, not a multiplier. The constant it replaced told a site already running Peregrine that it could save 92%.
Works on any site, customer or not, so it is the call for qualifying prospects and for before-and-after evidence. Slow by nature and throttled to 20 a minute per token; a result for the same URL is reused for half an hour, which protects the site being analysed as much as it protects us.
Example request:
curl --request POST \
"https://api.peregrinecdn.com/api/v1/analyse" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"url\": \"https:\\/\\/example.co.uk\\/\"
}"
const url = new URL(
"https://api.peregrinecdn.com/api/v1/analyse"
);
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"url": "https:\/\/example.co.uk\/"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/analyse';
$response = $client->post(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'url' => 'https://example.co.uk/',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/analyse'
payload = {
"url": "https:\/\/example.co.uk\/"
}
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()Example response (200):
{
"ok": true,
"url": "https://example.co.uk/",
"images_found": 18,
"images_measured": 18,
"bytes": {
"before": 2660001,
"after": 750884,
"saved": 1909117
},
"percent": 72,
"heaviest": [
{
"name": "hero.jpg",
"url": "https://example.co.uk/wp-content/uploads/hero.jpg",
"before": 486210,
"after": 61204
}
],
"previews": []
}
Example response (502, the page could not be fetched):
{
"error": "Could not fetch that page (HTTP 403).",
"remedies": [
"Allow PeregrineBot/1.0 in your firewall"
],
"blocked": true,
"http": 403
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Uptime
Uptime monitors and their last result.
requires authentication
A monitor answers "does the site respond". It would be perfectly happy while every image on it quietly reverted to a 4 MB original - that is what the delivery check is for.
Example request:
curl --request GET \
--get "https://api.peregrinecdn.com/api/v1/monitors" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://api.peregrinecdn.com/api/v1/monitors"
);
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/monitors';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/monitors'
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers)
response.json()Example response (200):
[
{
"id": 12,
"name": "Storefront",
"url": "https://example.co.uk/",
"method": "GET",
"expect_status": 200,
"interval_seconds": 300,
"status": "active",
"last_checked_at": "2026-09-15 08:19:02",
"last_ok": 1,
"last_ms": 184,
"last_status_code": 200,
"last_error": null,
"fail_streak": 0,
"cert_expires_at": "2026-11-02 11:00:00"
}
]
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Add a monitor.
requires authentication
The URL must resolve only to public addresses. A monitor is fetched on a timer from two servers and its status code, latency and keyword result are readable here, so an unguarded one would be a scheduled scan of our own network with the results posted back.
alert_email must be the account's own address or someone at the domain
being monitored: an alert is mail from our sending domain carrying a
subject you choose, fired when a URL you choose fails.
At most 100 monitors per account.
Example request:
curl --request POST \
"https://api.peregrinecdn.com/api/v1/monitors" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"name\": \"Storefront\",
\"url\": \"https:\\/\\/example.co.uk\\/\",
\"method\": \"GET\",
\"expect_status\": 200,
\"keyword\": \"Add to basket\",
\"keyword_absent\": \"Database error\",
\"interval_seconds\": 300,
\"alert_email\": \"ops@example.co.uk\"
}"
const url = new URL(
"https://api.peregrinecdn.com/api/v1/monitors"
);
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"name": "Storefront",
"url": "https:\/\/example.co.uk\/",
"method": "GET",
"expect_status": 200,
"keyword": "Add to basket",
"keyword_absent": "Database error",
"interval_seconds": 300,
"alert_email": "ops@example.co.uk"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/monitors';
$response = $client->post(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'name' => 'Storefront',
'url' => 'https://example.co.uk/',
'method' => 'GET',
'expect_status' => 200,
'keyword' => 'Add to basket',
'keyword_absent' => 'Database error',
'interval_seconds' => 300,
'alert_email' => 'ops@example.co.uk',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/monitors'
payload = {
"name": "Storefront",
"url": "https:\/\/example.co.uk\/",
"method": "GET",
"expect_status": 200,
"keyword": "Add to basket",
"keyword_absent": "Database error",
"interval_seconds": 300,
"alert_email": "ops@example.co.uk"
}
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()Example response (201):
{
"id": 12,
"account_id": 42,
"name": "Storefront",
"url": "https://example.co.uk/",
"method": "GET",
"expect_status": 200,
"keyword": null,
"keyword_absent": null,
"interval_seconds": 300,
"timeout_seconds": 15,
"confirm_failures": 2,
"alert_email": "ops@example.co.uk",
"ssl_warn_days": 14,
"status": "active",
"created_at": "2026-09-15 08:22:00"
}
Example response (422, the URL points inside a private network):
{
"message": "The url is not a public address we will fetch: host resolves to a non-public address.",
"errors": {
"url": [
"The url is not a public address we will fetch: host resolves to a non-public address. We only check hostnames that resolve to addresses on the public internet."
]
}
}
Example response (422, the alert address is neither yours nor the monitored domain's):
{
"error": "alert_email_not_allowed",
"detail": "Alerts can go to the account address (ops@acme.example) or to someone at the domain being monitored."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Rename, pause or retime a monitor.
requires authentication
Example request:
curl --request PATCH \
"https://api.peregrinecdn.com/api/v1/monitors/12" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"name\": \"Storefront\",
\"status\": \"paused\",
\"interval_seconds\": 300,
\"alert_email\": \"ops@example.co.uk\"
}"
const url = new URL(
"https://api.peregrinecdn.com/api/v1/monitors/12"
);
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"name": "Storefront",
"status": "paused",
"interval_seconds": 300,
"alert_email": "ops@example.co.uk"
};
fetch(url, {
method: "PATCH",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/monitors/12';
$response = $client->patch(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'name' => 'Storefront',
'status' => 'paused',
'interval_seconds' => 300,
'alert_email' => 'ops@example.co.uk',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/monitors/12'
payload = {
"name": "Storefront",
"status": "paused",
"interval_seconds": 300,
"alert_email": "ops@example.co.uk"
}
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('PATCH', url, headers=headers, json=payload)
response.json()Example response (200):
{
"id": 12,
"account_id": 42,
"name": "Storefront",
"url": "https://example.co.uk/",
"method": "GET",
"expect_status": 200,
"interval_seconds": 300,
"status": "paused",
"last_ok": 1,
"last_ms": 184
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Remove a monitor.
requires authentication
Example request:
curl --request DELETE \
"https://api.peregrinecdn.com/api/v1/monitors/12" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://api.peregrinecdn.com/api/v1/monitors/12"
);
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "DELETE",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/monitors/12';
$response = $client->delete(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/monitors/12'
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('DELETE', url, headers=headers)
response.json()Example response (200):
{
"deleted": true
}
Example response (404, not on this account):
{
"error": "not_found"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Recent checks for one monitor.
requires authentication
The raw evidence behind an incident.
Example request:
curl --request GET \
--get "https://api.peregrinecdn.com/api/v1/monitors/12/checks?limit=100" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://api.peregrinecdn.com/api/v1/monitors/12/checks"
);
const params = {
"limit": "100",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/monitors/12/checks';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'limit' => '100',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/monitors/12/checks'
params = {
'limit': '100',
}
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers, params=params)
response.json()Example response (200):
[
{
"id": 9044,
"monitor_id": 12,
"checked_at": "2026-09-15 08:19:02",
"ok": 1,
"status_code": 200,
"ms": 184,
"note": null,
"vantage": "primary"
}
]
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Governance
What has been changed through the API, and by which token.
requires authentication
Write operations only; reads are not recorded, because an audit log that includes them is one nobody reads. Exposed to the account rather than kept internal: the point of handing a token to an automation is that somebody can afterwards read what it did, and a trail only the vendor can see does not answer that.
Example request:
curl --request GET \
--get "https://api.peregrinecdn.com/api/v1/audit?limit=100" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://api.peregrinecdn.com/api/v1/audit"
);
const params = {
"limit": "100",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/audit';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'limit' => '100',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/audit'
params = {
'limit': '100',
}
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers, params=params)
response.json()Example response (200):
[
{
"id": 318,
"token_name": "Acme automation",
"action": "site.invalidate",
"context": "{\"site_id\":7,\"queued\":12}",
"ip": "203.0.113.44",
"created_at": "2026-09-15 08:18:40"
}
]
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
List your tokens. Console session only.
requires authentication
Refused for a bearer token: listing the names and abilities of an account's other credentials hands whoever stole one a map of what to steal next.
Example request:
curl --request GET \
--get "https://api.peregrinecdn.com/api/v1/tokens" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://api.peregrinecdn.com/api/v1/tokens"
);
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/tokens';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/tokens'
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers)
response.json()Example response (200):
[
{
"id": 7,
"name": "Acme automation",
"abilities": [
"read",
"purge",
"analyse"
],
"last_used_at": "2026-09-15T08:14:02.000000Z",
"expires_at": null,
"created_at": "2026-09-01T10:00:00.000000Z"
}
]
Example response (403, attempted with a token):
{
"error": "session_required",
"detail": "Tokens are managed from a signed-in console session, never by another token. Sign in at https://app.peregrinecdn.com/tokens."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Issue a token. Console session only.
requires authentication
A token that can mint tokens can widen its own abilities, which makes every narrower ability decoration - so this refuses bearer authentication and requires a signed-in session.
Example request:
curl --request POST \
"https://api.peregrinecdn.com/api/v1/tokens" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"name\": \"Barrie\'s Claude\",
\"abilities\": [
\"read\",
\"purge\",
\"analyse\"
],
\"expires_in_days\": 90
}"
const url = new URL(
"https://api.peregrinecdn.com/api/v1/tokens"
);
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"name": "Barrie's Claude",
"abilities": [
"read",
"purge",
"analyse"
],
"expires_in_days": 90
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/tokens';
$response = $client->post(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'name' => 'Barrie\'s Claude',
'abilities' => ['read', 'purge', 'analyse'],
'expires_in_days' => 90,
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/tokens'
payload = {
"name": "Barrie's Claude",
"abilities": [
"read",
"purge",
"analyse"
],
"expires_in_days": 90
}
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()Example response (201):
{
"id": 8,
"name": "Acme automation",
"abilities": [
"read",
"purge",
"analyse"
],
"expires_at": null,
"token": "8|shown once and never stored",
"warning": "Shown once. Store it where the client that needs it will read it, and nowhere else."
}
Example response (403, attempted with a token):
{
"error": "session_required",
"detail": "Tokens are managed from a signed-in console session, never by another token."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Revoke a token. Console session only.
requires authentication
Refused for a bearer token: otherwise a read-only credential could turn off every other credential the account has, including the one an operator would investigate with.
Example request:
curl --request DELETE \
"https://api.peregrinecdn.com/api/v1/tokens/7" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://api.peregrinecdn.com/api/v1/tokens/7"
);
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "DELETE",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/tokens/7';
$response = $client->delete(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/tokens/7'
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('DELETE', url, headers=headers)
response.json()Example response (200):
{
"revoked": true
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Plugin (site key)
Plugin connection check.
requires authentication
Authenticates as a SITE, with the key held by the WordPress plugin - not an account token. That key lives in wp_options on a machine we do not control, so it is scoped to one site's blast radius: a stolen key purges and warms that site and nothing else, cannot read another site, cannot touch DNS or storage, and never reveals a CDN credential.
Returns everything the plugin's settings screen shows, including the region catalogue, so a new region becomes available to every site without shipping a plugin update.
Example request:
curl --request GET \
--get "https://api.peregrinecdn.com/api/v1/site/status" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://api.peregrinecdn.com/api/v1/site/status"
);
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/site/status';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/site/status'
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers)
response.json()Example response (200):
{
"connected": true,
"site": {
"label": "example-co-uk",
"domain": "example.co.uk"
},
"media": {
"host": "https://images.example.co.uk",
"storage_zone": "pg-example-co-uk"
},
"warm": {
"available": [
"UK",
"DE",
"US-East"
],
"regions": [
"UK"
],
"pops": [
{
"region": "UK",
"pop_code": "UK1-1486",
"edge_ip": "203.0.113.10",
"resolved_at": "2026-09-14T02:10:58.000000Z"
}
],
"status": "active",
"last_run_at": "2026-09-14T02:11:09.000000Z",
"last_warmed": 812,
"last_errors": 0,
"last_note": null,
"queue_pending": 0
}
}
Example response (401, the key is wrong, or the site is paused):
{
"error": "invalid_key"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Plugin purge report.
requires authentication
The plugin says what changed and nothing more. URLs that are not on the site's own hostname are skipped: without that, a stolen key could queue purges for any domain on the platform.
Truncates at 500 rather than refusing, unlike the account route. A plugin on somebody else's WordPress cannot be told to try again, so quietly capping a runaway sitemap is the least-bad option here.
Example request:
curl --request POST \
"https://api.peregrinecdn.com/api/v1/site/invalidate" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"urls\": [
\"https:\\/\\/example.co.uk\\/about\\/\"
]
}"
const url = new URL(
"https://api.peregrinecdn.com/api/v1/site/invalidate"
);
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"urls": [
"https:\/\/example.co.uk\/about\/"
]
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/site/invalidate';
$response = $client->post(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'urls' => ['https://example.co.uk/about/'],
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/site/invalidate'
payload = {
"urls": [
"https:\/\/example.co.uk\/about\/"
]
}
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()Example response (200):
{
"queued": 3
}
Example response (401, the key is wrong, or the site is paused):
{
"error": "invalid_key"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Always refused. Warm regions are an account decision.
requires authentication
Every extra region multiplies the warm: the same page set fetched again at another edge, and warming is billed egress. A site holding its own key could otherwise select every region and multiply the bandwidth bill - and the bill lands on us, not on whoever ticked the box.
The refusal lives here rather than merely being absent from the plugin's UI, because a hidden control is not an access control.
Example request:
curl --request PUT \
"https://api.peregrinecdn.com/api/v1/site/warm" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://api.peregrinecdn.com/api/v1/site/warm"
);
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "PUT",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/site/warm';
$response = $client->put(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/site/warm'
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('PUT', url, headers=headers)
response.json()Example response (403):
{
"error": "not_permitted",
"detail": "Warm regions are set on the account, not from the site. Ask your provider to change them."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Plugin warm request.
requires authentication
Marks the job overdue so the worker picks it up on its next tick. Clearing running_since as well releases a claim left behind by a run that died, which is otherwise only cleared after an hour.
Example request:
curl --request POST \
"https://api.peregrinecdn.com/api/v1/site/warm/run" \
--header "Authorization: Bearer {YOUR_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://api.peregrinecdn.com/api/v1/site/warm/run"
);
const headers = {
"Authorization": "Bearer {YOUR_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "POST",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://api.peregrinecdn.com/api/v1/site/warm/run';
$response = $client->post(
$url,
[
'headers' => [
'Authorization' => 'Bearer {YOUR_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));import requests
import json
url = 'https://api.peregrinecdn.com/api/v1/site/warm/run'
headers = {
'Authorization': 'Bearer {YOUR_TOKEN}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers)
response.json()Example response (200):
{
"queued": true
}
Example response (401, the key is wrong, or the site is paused):
{
"error": "invalid_key"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.