For developers
PAT Control API
Read and write your organisation's clients, sites, appliances and test results, and read instruments and invoices.
Authentication. Create a key in Settings → API & webhooks and send it as Authorization: Bearer pat_…. Each key belongs to one organisation and has scopes; a write scope includes read. Changes made with a key are recorded in the audit log against the person who created it.
Pagination. Lists return data, has_more and next_cursor, oldest first. Pass cursor to get the next page. To sync changes, poll with updated_since (or recorded_since for tests), or subscribe to webhooks.
Rate limits. 120 requests a minute per key. X-RateLimit-Remaining and X-RateLimit-Reset are on every response; a 429 carries Retry-After.
Test records are legal evidence: they can be recorded and voided (with a reason) but never edited or deleted.
Webhooks. Endpoints receive a POST with a JSON event for: client.created, appliance.created, appliance.updated, test.recorded, test.voided, invoice.issued, invoice.paid. Verify the X-Webhook-Signature header: t=<unix time>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" using your endpoint secret>; reject timestamps more than 5 minutes old. Reply with any 2xx within 10 seconds; failures are retried for about 2 days.
Base address: https://app.patcontrol.co.uk/api/v1 · Machine-readable description: openapi.json (OpenAPI 3.1; import it into Postman or a client generator).
Quick start
curl https://<your address>/api/v1/appliances?due_before=2026-12-31 \ -H "Authorization: Bearer pat_…"
Endpoints
Organisation
get/meThe key's organisation and scopesany
Clients
get/clientsList clientsclients:read
| Parameter | Type | Notes |
|---|---|---|
| limit | integer | |
| cursor | string | next_cursor from the previous page |
| updated_since | string (date-time) | |
| include_archived | boolean |
post/clientsCreate a clientclients:write
Body: ClientCreate
get/clients/{id}Get a clientclients:read
| Parameter | Type | Notes |
|---|---|---|
| id (path) | string (uuid) |
patch/clients/{id}Update or archive a clientclients:write
Send only the fields to change. null clears an optional field.
| Parameter | Type | Notes |
|---|---|---|
| id (path) | string (uuid) |
Body: ClientUpdate
Sites
get/sitesList sitesclients:read
| Parameter | Type | Notes |
|---|---|---|
| limit | integer | |
| cursor | string | next_cursor from the previous page |
| client_id | string (uuid) | |
| updated_since | string (date-time) | |
| include_archived | boolean |
post/sitesCreate a site for a clientclients:write
Body: SiteCreate
get/sites/{id}Get a siteclients:read
| Parameter | Type | Notes |
|---|---|---|
| id (path) | string (uuid) |
patch/sites/{id}Update or archive a siteclients:write
Send only the fields to change. null clears an optional field.
| Parameter | Type | Notes |
|---|---|---|
| id (path) | string (uuid) |
Body: SiteUpdate
Appliances
get/appliancesList appliances (the asset register)appliances:read
| Parameter | Type | Notes |
|---|---|---|
| limit | integer | |
| cursor | string | next_cursor from the previous page |
| client_id | string (uuid) | |
| site_id | string (uuid) | |
| asset_id | string | Exact match, any case |
| status | "in_service" | "failed" | "repaired" | "removed" | |
| due_before | string (date) | Appliances due for retest on or before this date |
| updated_since | string (date-time) |
post/appliancesAdd an appliance to a siteappliances:write
Body: ApplianceCreate
get/appliances/{id}Get an applianceappliances:read
| Parameter | Type | Notes |
|---|---|---|
| id (path) | string (uuid) |
patch/appliances/{id}Update an applianceappliances:write
Send only the fields to change. null clears an optional field.
| Parameter | Type | Notes |
|---|---|---|
| id (path) | string (uuid) |
Body: ApplianceUpdate
Tests
get/testsList test results with their readingstests:read
| Parameter | Type | Notes |
|---|---|---|
| limit | integer | |
| cursor | string | next_cursor from the previous page |
| appliance_id | string (uuid) | |
| site_id | string (uuid) | |
| result | "pass" | "fail" | |
| tested_from | string (date) | |
| tested_to | string (date) | |
| recorded_since | string (date-time) | Tests recorded here at or after this time (for syncing) |
post/testsRecord a testtests:write
Moves the appliance's last result and next due date on when this is its latest test. The pass/fail verdict is worked out from the readings.
Body: TestCreate
get/tests/{id}Get a test with its readingstests:read
| Parameter | Type | Notes |
|---|---|---|
| id (path) | string (uuid) |
post/tests/{id}/voidVoid a testtests:write
The test stays on record, marked void with the reason, and the appliance goes back to its latest remaining test.
| Parameter | Type | Notes |
|---|---|---|
| id (path) | string (uuid) |
Body: TestVoid
Instruments
get/instrumentsList test instrumentsinstruments:read
| Parameter | Type | Notes |
|---|---|---|
| limit | integer | |
| cursor | string | next_cursor from the previous page |
get/instruments/{id}Get an instrumentinstruments:read
| Parameter | Type | Notes |
|---|---|---|
| id (path) | string (uuid) |
Invoices
get/invoicesList invoices with their linesinvoices:read
| Parameter | Type | Notes |
|---|---|---|
| limit | integer | |
| cursor | string | next_cursor from the previous page |
| client_id | string (uuid) | |
| status | "draft" | "sent" | "paid" | "void" | |
| updated_since | string (date-time) |
get/invoices/{id}Get an invoiceinvoices:read
| Parameter | Type | Notes |
|---|---|---|
| id (path) | string (uuid) |
Verifying webhooks
Each delivery carries X-Webhook-Event, X-Webhook-Id (the event id) and X-Webhook-Signature. In Node.js:
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as received (a string), before JSON parsing.
export function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!parts.t || !parts.v1 || age > 300) return false;
const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}Objects
Error
| error | object |
Client
| object | "client" | |
| id | string (uuid) | |
| name | string | Client name, unique among your active clients. |
| contact_name | string | null | |
| string | null (email) | ||
| phone | string | null | |
| address_lines | array | Up to six lines. |
| town | string | null | |
| county | string | null | |
| postcode | string | null | |
| notes | string | null | |
| archived | boolean | |
| created_at | string (date-time) | |
| updated_at | string (date-time) |
Site
| object | "site" | |
| id | string (uuid) | |
| client_id | string (uuid) | |
| name | string | Site name, unique within the client. |
| address_lines | array | |
| town | string | null | |
| postcode | string | null | |
| environment | "construction" | "industrial" | "public" | "school" | "hotel" | "office" | "hire" | Drives the default retest interval (IET Code of Practice). |
| notes | string | null | |
| archived | boolean | |
| created_at | string (date-time) | |
| updated_at | string (date-time) |
Appliance
| object | "appliance" | |
| id | string (uuid) | |
| client_id | string (uuid) | |
| site_id | string (uuid) | |
| location | string | null | Room or area on the site; created if new. |
| asset_id | string | The label on the appliance. Unique per site. |
| description | string | |
| equipment_class | "class_i" | "class_ii" | "class_iii" | "lead" | |
| category | "portable" | "movable" | "hand_held" | "stationary" | "fixed" | "built_in" | "it" | "extension_lead" | |
| make | string | null | |
| model | string | null | |
| serial | string | null | |
| fuse_rating_a | integer | null | Fuse rating in amps (1–32). |
| status | "in_service" | "failed" | "repaired" | "removed" | |
| retest_months | integer | null | Overrides the interval from site and organisation rules (1–120). |
| notes | string | null | |
| last_tested_at | string | null (date-time) | |
| last_result | "pass" | "fail" | |
| next_due_on | string | null (date) | Last test date plus the retest interval. |
| created_at | string (date-time) | |
| updated_at | string (date-time) |
Measurement
| kind * | "visual" | "earth_continuity" | "insulation" | "protective_conductor_current" | "touch_current" | "substitute_leakage" | "polarity" | "functional" | "rcd_trip_time" | "load" | |
| label | string | Defaults to the standard name for the kind. |
| unit | string | null | Ω, MΩ, mA or ms; set from the kind. |
| limit | number | null | |
| limit_qualifier | "<" | ">" | |
| result | number | null | |
| result_qualifier | "<" | ">" | For off-scale readings, e.g. > 299 MΩ. |
| result_text | string | null | |
| test_voltage_v | integer | null | |
| status | "pass" | "fail" | "na" | Numeric readings are re-judged against their limit; checks use the status you send. |
Test
| object | "test" | |
| id | string (uuid) | |
| appliance_id | string (uuid) | |
| tested_at | string (date-time) | |
| engineer_name | string | |
| instrument_id | string | null (uuid) | |
| result | "pass" | "fail" | |
| kind | "visual" | "combined" | |
| retest_months | integer | |
| next_due_on | string (date) | |
| notes | string | null | |
| source | "manual" | "kewpat" | "csv" | "api" | |
| external_id | string | null | The external_id sent with a test recorded through the API. |
| test_code | string | null | |
| voided | object | null | |
| appliance_snapshot | object | The appliance and location as tested (what the certificate shows). |
| measurements | Measurement[] | |
| created_at | string (date-time) | When the test was recorded here. |
Instrument
| object | "instrument" | |
| id | string (uuid) | |
| make | string | |
| model | string | |
| serial | string | |
| calibrated_on | string | null (date) | |
| calibration_due_on | string | null (date) | |
| certificate_number | string | null | |
| archived | boolean | |
| created_at | string (date-time) | |
| updated_at | string (date-time) |
Invoice
| object | "invoice" | |
| id | string (uuid) | |
| client_id | string (uuid) | |
| number | string | null | Set when the invoice is issued. |
| status | "draft" | "sent" | "paid" | "void" | |
| issue_date | string | null (date) | |
| due_date | string | null (date) | |
| reference | string | null | |
| currency | "GBP" | |
| subtotal_pence | integer | |
| vat_pence | integer | |
| total_pence | integer | |
| paid_at | string | null (date-time) | |
| external | object | null | |
| lines | array | |
| created_at | string (date-time) | |
| updated_at | string (date-time) |
Organisation
| object | "organisation" | |
| id | string (uuid) | |
| name | string | |
| slug | string | |
| api_key | object |
WebhookEvent
| id | string (uuid) | The event id; the same for every endpoint. Use it to ignore repeats. |
| type | "client.created" | "appliance.created" | "appliance.updated" | "test.recorded" | "test.voided" | "invoice.issued" | "invoice.paid" | "ping" | |
| created_at | string (date-time) | |
| data | object | A summary of the record: object, id and key fields. Fetch the full record from the API if you need more. |
ClientCreate
| name * | string | Client name, unique among your active clients. |
| contact_name | string | null | |
| string | null (email) | ||
| phone | string | null | |
| address_lines | array | Up to six lines. |
| town | string | null | |
| county | string | null | |
| postcode | string | null | |
| notes | string | null |
SiteCreate
| client_id * | string (uuid) | |
| name * | string | Site name, unique within the client. |
| address_lines | array | |
| town | string | null | |
| postcode | string | null | |
| environment | "construction" | "industrial" | "public" | "school" | "hotel" | "office" | "hire" | Drives the default retest interval (IET Code of Practice). |
| notes | string | null |
ApplianceCreate
| site_id * | string (uuid) | |
| location | string | null | Room or area on the site; created if new. |
| asset_id * | string | The label on the appliance. Unique per site. |
| description * | string | |
| equipment_class * | "class_i" | "class_ii" | "class_iii" | "lead" | |
| category | "portable" | "movable" | "hand_held" | "stationary" | "fixed" | "built_in" | "it" | "extension_lead" | |
| make | string | null | |
| model | string | null | |
| serial | string | null | |
| fuse_rating_a | integer | null | Fuse rating in amps (1–32). |
| status | "in_service" | "failed" | "repaired" | "removed" | |
| retest_months | integer | null | Overrides the interval from site and organisation rules (1–120). |
| notes | string | null |
TestCreate
| appliance_id * | string (uuid) | |
| tested_at * | string (date-time) | With a time zone offset, e.g. 2026-10-14T09:30:00Z. |
| engineer_name * | string | |
| instrument_id | string | null (uuid) | |
| kind | "visual" | "combined" | |
| retest_months | integer | null | Leave out to use the appliance, site and organisation rules. |
| notes | string | null | |
| test_code | string | null | |
| external_id | string | Your system's id for this test. Sending the same id again is refused with 409, so retries are safe. |
| result | "pass" | "fail" | Your tester's overall verdict; kept when it is stricter than ours. |
| measurements * | Measurement[] | 1–30 readings and checks. Numeric kinds need limit and result (or a status); checks need a status. |
TestVoid
| reason * | string | At least 3 characters. |
* required when creating. Questions? Help centre.