MENU navbar-image

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"
}
 

Request      

GET api/health

Headers

Content-Type        

Example: application/json

Accept        

Example: application/json

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": {}
}
 

Request      

GET api/v1/openapi.json

Headers

Content-Type        

Example: application/json

Accept        

Example: application/json

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

Request      

GET api/v1/meta

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

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"
    }
}
 

Request      

GET api/v1/account

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

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
    }
]
 

Request      

GET api/v1/sites

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Query Parameters

status   string  optional    

active or paused. Example: active

delivery_state   string  optional    

ok, degraded, down or unknown. Example: degraded

q   string  optional    

Domain or label substring. Example: breckland

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
}
 

Request      

POST api/v1/sites

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Body Parameters

domain   string     

The site's domain. Example: example.co.uk

label   string  optional    

A slug. Defaults to the domain with dots replaced. Example: example-co-uk

cdn_base   string  optional    

Delivery hostname, optionally with a scheme. No port, path or bare IP. Example: https://images.example.co.uk

path_prefix   string  optional    

Defaults to wp-content/uploads. Example: wp-content/uploads

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

Request      

GET api/v1/sites/{site}

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

site   string     

The site: its numeric id, its label, or its domain with or without scheme and "www.". Example: example.co.uk

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."
        ]
    }
}
 

Request      

PATCH api/v1/sites/{site}

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

site   string     

The site: its numeric id, its label, or its domain with or without scheme and "www.". Example: example.co.uk

Body Parameters

label   string  optional    

A new slug. Example: example-co-uk

status   string  optional    

active or paused. Example: paused

cdn_base   string  optional    

Delivery hostname. Example: https://images.example.co.uk

path_prefix   string  optional    

Example: wp-content/uploads

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

Request      

DELETE api/v1/sites/{site}

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

site   string     

The site: its numeric id, its label, or its domain with or without scheme and "www.". Example: example.co.uk

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

Request      

POST api/v1/sites/{site}/key

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

site   string     

The site: its numeric id, its label, or its domain with or without scheme and "www.". Example: example.co.uk

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
}
 

Request      

GET api/v1/sites/{site}/images

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

site   string     

The site: its numeric id, its label, or its domain with or without scheme and "www.". Example: example.co.uk

Query Parameters

limit   integer  optional    

How many in each list, 1-100. Defaults to 20. Example: 20

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

Request      

POST api/v1/sites/{site}/verify

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

site   string     

The site: its numeric id, its label, or its domain with or without scheme and "www.". Example: example.co.uk

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
}
 

Request      

POST api/v1/sites/{site}/report

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

site   string     

The site: its numeric id, its label, or its domain with or without scheme and "www.". Example: example.co.uk

Body Parameters

enabled   boolean     

Whether the shareable report link exists. Example: true

email   string  optional    

Address the monthly report is posted to. Example: client@example.co.uk

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
}
 

Request      

GET api/v1/stats

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

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
    }
]
 

Request      

GET api/v1/trend

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Query Parameters

days   integer  optional    

Days back to include, 1-365. Defaults to 30. Example: 30

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
        }
    ]
}
 

Request      

GET api/v1/usage

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Query Parameters

from   string  optional    

date Start of the window, inclusive, YYYY-MM-DD. Defaults to 30 days ago. Example: 2026-08-01

to   string  optional    

date End of the window, inclusive, YYYY-MM-DD. Defaults to today. Example: 2026-08-31

site   string  optional    

Restrict to one site. Example: example.co.uk

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
        }
    ]
}
 

Request      

GET api/v1/sites/{site}/usage

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

site   string     

The site: its numeric id, its label, or its domain with or without scheme and "www.". Example: example.co.uk

Query Parameters

from   string  optional    

date Inclusive, YYYY-MM-DD. Example: 2026-08-01

to   string  optional    

date Inclusive, YYYY-MM-DD. Example: 2026-08-31

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

Request      

POST api/v1/sites/{site}/invalidate

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

site   string     

The site: its numeric id, its label, or its domain with or without scheme and "www.". Example: example.co.uk

Body Parameters

urls   string[]     

Absolute URLs on the site's own hostname. At most 500.

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

Request      

GET api/v1/sites/{site}/warm

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

site   string     

The site: its numeric id, its label, or its domain with or without scheme and "www.". Example: example.co.uk

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

Request      

PUT api/v1/sites/{site}/warm

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

site   string     

The site: its numeric id, its label, or its domain with or without scheme and "www.". Example: example.co.uk

Body Parameters

regions   string[]     

Catalogue keys from GET /v1/meta. An empty list pauses warming.

interval_seconds   integer  optional    

Defaults to 604800 (weekly). Nightly costs roughly 7x for no correctness gain - per-publish purges are what keep content fresh. Example: 604800

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

Request      

POST api/v1/sites/{site}/warm/run

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

site   string     

The site: its numeric id, its label, or its domain with or without scheme and "www.". Example: example.co.uk

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

Request      

POST api/v1/provisions

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Body Parameters

domain   string     

The domain to build out. Example: example.co.uk

profile   string     

wordpress, woocommerce or static. woocommerce bypasses cache for carts and logins. Example: wordpress

region   string  optional    

Storage region - where origin copies live, not where the site is kept warm. Defaults to UK. Example: UK

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"
    }
]
 

Request      

GET api/v1/provisions

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Query Parameters

limit   integer  optional    

1-100. Defaults to 25. Example: 25

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"
}
 

Request      

GET api/v1/provisions/{id}

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

id   integer     

The request id. Example: 41

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
}
 

Request      

POST api/v1/analyse

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Body Parameters

url   string     

The page to measure. Scheme optional. Example: https://example.co.uk/

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"
    }
]
 

Request      

GET api/v1/monitors

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

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

Request      

POST api/v1/monitors

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Body Parameters

name   string     

Shown in alerts. Example: Storefront

url   string     

Must resolve to a public address. Example: https://example.co.uk/

method   string  optional    

GET or HEAD. Defaults to GET. Example: GET

expect_status   integer  optional    

Defaults to 200. Example: 200

keyword   string  optional    

Must be present in the body. Example: Add to basket

keyword_absent   string  optional    

Must NOT be present in the body. Example: Database error

interval_seconds   integer  optional    

60-86400. Defaults to 300. Example: 300

alert_email   string  optional    

Where alerts go. Example: ops@example.co.uk

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
}
 

Request      

PATCH api/v1/monitors/{id}

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

id   integer     

The monitor id. Example: 12

Body Parameters

name   string  optional    

Example: Storefront

status   string  optional    

active or paused. Example: paused

interval_seconds   integer  optional    

60-86400. Example: 300

alert_email   string  optional    

Example: ops@example.co.uk

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"
}
 

Request      

DELETE api/v1/monitors/{id}

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

id   integer     

The monitor id. Example: 12

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"
    }
]
 

Request      

GET api/v1/monitors/{id}/checks

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

id   integer     

The monitor id. Example: 12

Query Parameters

limit   integer  optional    

1-500. Defaults to 100. Example: 100

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"
    }
]
 

Request      

GET api/v1/audit

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Query Parameters

limit   integer  optional    

1-500. Defaults to 100. Example: 100

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

Request      

GET api/v1/tokens

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

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

Request      

POST api/v1/tokens

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Body Parameters

name   string     

Name it after the thing that will hold it - this is what appears in the audit trail. Example: Barrie's Claude

abilities   string[]  optional    

Any of read, purge, analyse, sites, warm, provision. Defaults to read, purge and analyse.

expires_in_days   integer  optional    

1-365. Omit for a token that does not expire. Example: 90

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
}
 

Request      

DELETE api/v1/tokens/{id}

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

id   integer     

The token id. Example: 7

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"
}
 

Request      

GET api/v1/site/status

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

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"
}
 

Request      

POST api/v1/site/invalidate

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Body Parameters

urls   string[]     

Absolute URLs on this site's hostname.

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

Request      

PUT api/v1/site/warm

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

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"
}
 

Request      

POST api/v1/site/warm/run

Headers

Authorization        

Example: Bearer {YOUR_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json