Verification API
Tender portals, banks and other systems can check a certificate against the ISAO public register and get the same answer as the record page, as JSON.
Request
GET https://isao.org.uk/api/v1/verify/{code}{code} is the verification code (XXXX-XXXX-XXXX, any case, hyphens optional) or the certificate number. Certificate numbers often contain /: encode it as %2F, or write the number with hyphens (ISAO-BAR-CB-YYYY-NNNN finds ISAO-BAR/CB/YYYY/NNNN). Add a revision suffix to ask for one revision (…%20R1); without it you get the current revision. No authentication or API key is needed. The API does not search by name.
curl -s https://isao.org.uk/api/v1/verify/XXXX-XXXX-XXXX
curl -s https://isao.org.uk/api/v1/verify/ISAO-BAR%2FCB%2FYYYY%2FNNNNResponse
JSON in UTF-8. The example below uses placeholder values. Fields may be added over time; existing fields keep their names and meaning.
{
"status": "valid",
"statusLine": "Valid certificate — valid until 1 June 2029",
"publicCode": "XXXX-XXXX-XXXX",
"number": "ISAO-QMS-YY-NNNNN",
"revision": 0,
"type": "REGISTRATION",
"typeLabel": "Certificate of registration",
"holder": {
"name": "Example Components Ltd",
"city": "Leeds",
"country": "United Kingdom",
"countryCode": "GB",
"grade": null
},
"standards": [
{
"code": "ISO 9001:2015",
"title": "Quality management systems"
}
],
"scope": "Design and manufacture of machined components.",
"sectorCodes": [
"17"
],
"sites": [
{
"name": "Head office and works",
"city": "Leeds",
"country": "United Kingdom"
}
],
"dates": {
"issue": "2026-06-02",
"originalIssue": "2026-06-02",
"validFrom": "2026-06-02",
"expiry": "2029-06-01"
},
"mainCertificate": null,
"statusDetail": {
"since": null,
"until": "2029-06-01",
"reasonCategory": null,
"reason": null
},
"replacedBy": null,
"surveillance": "on_schedule",
"issuer": {
"kind": "isao",
"name": "International Standards Accreditation Organization",
"nameOnCertificate": null,
"accreditationNumber": null,
"accreditationStatus": null,
"coveredOnIssueDate": true
},
"accreditation": null,
"signature": {
"state": "verified",
"keyId": "isao-2026-01",
"keyProblem": null,
"value": "(86 base64url characters)",
"contentHash": "(64 hexadecimal characters)",
"shortForm": "XXXX XXXX XXXX XXXX",
"payload": {
"type": "REGISTRATION",
"number": "ISAO-QMS-YY-NNNNN",
"revision": 0,
"holderName": "Example Components Ltd",
"standards": [
"ISO 9001:2015"
],
"scopeText": "Design and manufacture of machined components.",
"issueDate": "2026-06-02",
"expiryDate": "2029-06-01",
"issuer": "International Standards Accreditation Organization",
"publicCode": "XXXX-XXXX-XXXX",
"payloadVersion": 2,
"validFrom": "2026-06-02",
"auditReportNo": "AR/2026/0001",
"placeOfIssue": "London",
"issuingOffice": null,
"mainCertificateNumber": null,
"recognitionMarks": []
},
"payloadVersion": 2,
"keysUrl": "https://isao.org.uk/.well-known/isao-signing-keys.json"
},
"notices": [],
"verifyUrl": "https://isao.org.uk/v/XXXX-XXXX-XXXX",
"checkedAt": "2026-09-29T10:15:00.000Z"
}| Field | Meaning |
|---|---|
status | valid, not_yet_valid, suspended, withdrawn, expired, superseded or not_covered (see Statuses). |
statusLine | The status as one plain-English line, as shown on the record page. |
publicCode | The verification code printed on the certificate. |
number, revision | Certificate number as printed (with R1, R2 … for revised certificates) and the revision as a number. |
type, typeLabel | ACCREDITATION, REGISTRATION, AUDITOR, TRAINING or CUSTOM, and its label. |
holder | The holder as certified: name, city and country (code and name) as printed on the certificate, not later edits to the register. Registered auditors: name and grade only. |
standards | Standards by number, with the titles printed on the certificate, in the order printed. |
scope, sectorCodes, sites | Scope of certification, IAF sector codes (1 to 39, numeric order), and the certified sites with city and country, as printed. |
dates | issue, originalIssue (initial certification), validFrom and expiry as YYYY-MM-DD (UTC). The expiry day is included in the validity. |
mainCertificate | For a site or sub-certificate: the main certificate it is valid together with (number, code, record address), with its status today and the date behind it; otherwise null. A sub-certificate never reads better than its main certificate. |
statusDetail | since (date of suspension, withdrawal or expiry), until (valid-until or planned end of a suspension), reasonCategory and reason. A certificate that is not yet valid starts on dates.validFrom. |
replacedBy | For superseded certificates: the current version's code and record address. |
surveillance | on_schedule, overdue, complete or none. |
issuer | Who issued the certificate. kind isao: ISAO issued it itself (every new certificate of registration); accreditationNumber and accreditationStatus are null and coveredOnIssueDate is true, as no accreditation chain applies. kind body: a certification body issued it; its current name, the name printed on the certificate when different (nameOnCertificate), its ISAO accreditation number and status today (past its expiry date it reads expired), and whether that accreditation covered the certificate on every day from its first day of validity to its issue date (coveredOnIssueDate). |
accreditation | Certificates of accreditation only: the accreditation the certificate records (number), its status today (active, suspended, withdrawn or expired; past its expiry date it reads expired) and since (the date of that suspension, withdrawal or expiry); otherwise null. The certificate keeps its own status; notices says when that accreditation is suspended, withdrawn or expired. |
signature | state (verified, mismatch, unsigned, unknown_key), keyId, keyProblem (unpublished, retired or revoked, with unknown_key), the Ed25519 signature (value), contentHash, shortForm as printed, the signed payload with its payloadVersion (1, 2 or 3) and keysUrl. |
notices | Notices about the certificate, for example a suspension of the issuing body. |
verifyUrl, checkedAt | The record page for people, and the time of the check (UTC). |
Statuses
| status | Meaning |
|---|---|
valid | Valid today. For a certificate issued by a certification body, that body's accreditation also covered the standard on every day from the first day of validity to the issue date, and has not been withdrawn. |
not_yet_valid | Issued, but its validity has not started yet: it is valid from dates.validFrom (statusLine gives the date). Do not rely on it before then. |
suspended | Temporarily not valid. statusDetail gives the date, reason category and any planned end. |
withdrawn | Permanently not valid since statusDetail.since. |
expired | The validity period has ended. |
superseded | Replaced by a newer version: see replacedBy. |
not_covered | Not valid under ISAO accreditation: on some day from the first day of validity to the issue date the issuing body was not accredited for the standard (or its accreditation was suspended, withdrawn or expired that day), or its accreditation has been withdrawn since. notices gives the day and the reason. |
not_found | HTTP 404. No matching record was found. Check the code or number and try again. |
Only valid means the certificate can be relied on today. Read notices as well: when an issuing body is suspended, or its accreditation has passed its expiry date, its certificates keep their status and carry a notice. So does a certificate of accreditation whose accreditation is suspended, withdrawn or expired (accreditation gives that state). A site or sub-certificate takes its main certificate's status when that is worse.
HTTP status codes
| Code | Meaning |
|---|---|
| 200 | A certificate was found. The body is the record above, whatever its status. |
| 400 | The path is neither a verification code nor a certificate number. The API does not search by name. |
| 404 | No certificate matches: {"status": "not_found", "statusLine": …, "checkedAt": …}. |
| 429 | The lookup allowance for your IP address is used up. Wait for Retry-After seconds. |
| 503 | The register cannot be checked at the moment. Try again later. |
Errors have the form {"error": "invalid_query" | "rate_limited" | "unavailable", "message": "…"}.
Rate limits, CORS and caching
- 30 lookups per 10 minutes for each IP address, shared with the verification pages of this site. IPv6 addresses count per /64 network, so addresses in one /64 share an allowance. The allowance refills evenly over that time.
- Every response carries
RateLimit-Limit,RateLimit-Remaining,RateLimit-Reset(seconds until the allowance is full again) andRateLimit-Policy(30;w=600). When it is used up, the API answers 429 withRetry-Afterin seconds. - CORS is open (
Access-Control-Allow-Origin: *), so browsers can call the API from any site. No cookies or credentials are used. - Responses are never cached (
Cache-Control: no-store): a suspension or withdrawal shows at once. Do not cache results for longer than you need them. - If your integration needs a higher limit, contact us.
Checking the digital signature yourself
ISAO signs every issued certificate with Ed25519. The API already re-verifies the signature on each call (signature.state), but you can check it independently:
- Fetch the public keys from /.well-known/isao-signing-keys.json and pick the key whose
idequalssignature.keyId. Only the keys listed there verify ISAO signatures. A retired key verifies only signatures made before its retirement; a revoked key verifies none (signature.keyProblemthen saysretiredorrevoked). - Serialise
signature.payloadas canonical JSON (RFC 8785: keys sorted, no whitespace) and encode it as UTF-8. - Verify
signature.value(base64url, 64 bytes) over those bytes with the key (base64url, 32 bytes). The SHA-256 of the same bytes, in hex, equalssignature.contentHash. - Compare the payload with the certificate you were given: holder name, standards, scope, dates, issuer and verification code. Version 2 payloads (
"payloadVersion": 2) also cover the valid-from date, audit report number, place of issue, issuing office, main certificate number and recognition marks. Version 3 payloads ("payloadVersion": 3) also cover the initial certification date, the IAF sector codes, the holder's trading names and address, the sites, the issuer name as printed, the issuing body's accreditation number as recorded and, on a certificate of accreditation, the schedule;documentDigestcovers the rest of the printed document, so any change to it reads as a mismatch.
signature.payload is given in the version the certificate was signed with: signature.payloadVersion is 3 for certificates signed since the certificate-integrity release (autumn 2026), 2 for those signed from September 2026 until then, and 1 for earlier ones, whose payload has no payloadVersion field. In version 3, issuer is the name printed on the certificate and signedAt the signing time. Every version is described at /.well-known/isao-signing-keys.json.
import { createPublicKey, verify } from "node:crypto";
const base = "https://isao.org.uk";
const record = await (await fetch(`${base}/api/v1/verify/XXXX-XXXX-XXXX`)).json();
const { keys } = await (await fetch(`${base}/.well-known/isao-signing-keys.json`)).json();
const key = keys.find((k) => k.id === record.signature.keyId);
// RFC 8785 canonical JSON (sorted keys, no whitespace) for the payload's strings, numbers and arrays.
const canonical = (v) =>
Array.isArray(v) ? `[${v.map(canonical).join(",")}]`
: v !== null && typeof v === "object"
? `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(",")}}`
: JSON.stringify(v);
const publicKey = createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: key.publicKey }, format: "jwk" });
const valid = verify(
null,
Buffer.from(canonical(record.signature.payload), "utf8"),
publicKey,
Buffer.from(record.signature.value, "base64url"),
);
console.log(valid ? "Signature verified" : "Signature does not match");