Overview
The CertIQ API scores any domain's externally observable cyber posture in under
a minute. Typical integration: an insurer or broker captures a prospect's
website at quote intake, calls POST /api/v1/scans,
and receives a 202 with a scan token. The scan runs async (~30s); the partner
polls or receives a webhook when it completes, then fetches the branded PDF
for the underwriting file.
The base URL for v1 is:
https://certiq.au/api/v1
All requests and responses are JSON unless otherwise noted. All timestamps are
ISO-8601 in UTC. All scan_token
values are opaque strings — treat them as case-sensitive identifiers.
Authentication
Every request requires a Bearer token in the Authorization
header. You can view and rotate your key in the
partner dashboard.
curl https://certiq.au/api/v1/scans \ -H "Authorization: Bearer YOUR_API_KEY"
Requests without a valid, active key return 401 Unauthorised.
Rotating a key invalidates the previous one immediately — coordinate rollouts
with your integration.
Create a scan
Starts a new scan for the given domain. Returns 202 Accepted
immediately with a scan token. The scan runs asynchronously; poll
GET /api/v1/scans/:token
or subscribe to the scan.completed webhook.
Request body
| Field | Type | Description |
|---|---|---|
| domain | string | Apex domain to scan (e.g. example.com.au). Protocol and path are stripped. |
Example
curl -X POST https://certiq.au/api/v1/scans \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"domain": "example.com.au"}'
Response — 202 Accepted
{
"scan_token": "a1b2c3d4e5f6…",
"status": "pending",
"status_url": "https://certiq.au/api/v1/scans/a1b2c3d4e5f6…",
"self_assessment_url": "https://certiq.au/scans/a1b2c3d4e5f6…",
"estimated_seconds": 30
}
self_assessment_url
is a public, login-free page the insured can use to complete the Essential
Eight self-assessment — the scan_token in the
URL is the only authentication required. Forward this URL to the insured via
email (or embed in your own portal). When they finish, CertIQ fires the
scan.self_assessment_completed
webhook and combined_score becomes available
on the retrieve-scan endpoint.
Returns 422 Unprocessable Entity
with {"error":"Invalid domain"}
if the domain fails validation.
Retrieve a scan
Returns the current state of a scan. While the scan is running, only
scan_token,
domain,
status, and partial
signals are populated.
Once status == "complete",
the full scoring payload is included.
Status values
pending— queued, not yet runningscanning— signals in progressscoring— signals done, finalising scorecomplete— done; full payload availablefailed— scan aborted; try again
Response — 200 OK (complete)
{
"scan_token": "a1b2c3d4e5f6…",
"domain": "example.com.au",
"status": "complete",
"signals": {
"scan": { "status": "complete", "score": 22, "max_score": 25, "findings": [...] },
"breach": { "status": "complete", "score": 18, "max_score": 20, "findings": [...] },
"reputation": { "status": "complete", "score": 20, "max_score": 20, "findings": [] },
"mail": { "status": "complete", "score": 16, "max_score": 20, "findings": [...] },
"layer": { "status": "complete", "score": 13, "max_score": 15, "findings": [...] }
},
"score": 89,
"grade": "B",
"grade_label": "Low-moderate risk",
"sa_score": null,
"sa_complete": false,
"combined_score": null,
"combined_grade": "B",
"self_assessment": {},
"certiq_ready": true,
"ready_threshold": 65,
"completed_at": "2026-04-20T12:34:56Z",
"report_url": "https://certiq.au/api/v1/scans/a1b2c3d4e5f6…/report"
}
Field reference
| Field | Description |
|---|---|
| score | External score 0–100. Derived from the five signals. |
| grade | Letter grade A–F mapped from the effective score (combined if present, else external). |
| grade_label | Human-readable risk band (e.g. "Low-moderate risk"). |
| sa_score | Self-assessment score 0–100, or null until the insured completes the SA. |
| sa_complete | Boolean. True when the insured has finished the self-assessment. |
| combined_score | Weighted hybrid of external + SA, or null while sa_complete is false. |
| certiq_ready | True if the effective score clears the ready threshold (quote-eligible). |
| ready_threshold | Current threshold constant (65). |
| signals | Object keyed by signal name. Each entry has status, score, max_score, findings[]. |
| report_url | Authenticated endpoint that streams the branded PDF (see next section). |
Signal reference
The signals
object contains one entry per check. Each entry carries a
score out of
max_score
and a findings[]
array describing specific issues and fixes.
| Signal | What it measures | Max |
|---|---|---|
| scan | Open ports & services. Internet-exposed services and open ports an attacker could probe. | 25 |
| breach | Credential breach exposure. Known breach databases checked for leaked passwords tied to this domain. | 20 |
| reputation | Domain reputation. Cross-referenced against security vendors for malicious flags. | 20 |
| Email authentication. SPF, DKIM and DMARC records verified to gauge email spoofing risk. | 20 | |
| layer | TLS certificate health. Certificate validity, strength, and configuration inspected. | 15 |
Max scores sum to 100 — the score field at the top
of the response is the sum of per-signal scores. Signal names are stable; new
signals will be added with a new key rather than changing an existing one.
Download the report PDF
Streams the scan's PDF report as
application/pdf.
The PDF is co-branded with your firm's logo and contact card (configure these in
partner branding)
and includes CertIQ's scoring methodology as the report author.
curl https://certiq.au/api/v1/scans/a1b2c3d4e5f6…/report \ -H "Authorization: Bearer YOUR_API_KEY" \ -o report.pdf
Returns 409 Conflict
with {"error":"Scan is still running"}
if the scan has not yet reached complete.
Webhooks
As an alternative (or complement) to polling, CertIQ POSTs a signed JSON payload to your configured webhook URL when a scan reaches a notable state. Configure your URL and view recent deliveries in partner webhook settings.
Events
| Event | Fires when |
|---|---|
| scan.completed | External scan reaches complete status and a score is available. |
| scan.self_assessment_completed | Insured completes the self-assessment; combined_score is now populated. |
| webhook.test | Synthetic event fired from the dashboard's Send test event button. |
Headers
Content-Type: application/jsonUser-Agent: CertIQ-Webhook/1.0X-CertIQ-Event: scan.completed— the event nameX-CertIQ-Signature: sha256=<hex>— HMAC-SHA256 of the raw body, keyed with your webhook secret
Payload — scan.completed
{
"event": "scan.completed",
"scan_token": "a1b2c3d4e5f6…",
"status": "complete",
"score": 89,
"combined_score": null,
"sa_complete": false,
"grade": "B",
"report_url": "https://certiq.au/scans/a1b2c3d4e5f6…/report.pdf",
"completed_at": "2026-04-20T12:34:56Z"
}
Delivery & retries
- 10-second HTTP timeout.
- Retry on any non-2xx response or network error. Up to 8 attempts with exponential backoff (~1 hour cap).
- Every delivery attempt is logged and visible in the dashboard's Recent deliveries table.
- Make your handler idempotent — deduplicate by
scan_token+event.
Verifying the signature
Always verify the signature before trusting a payload. Compute HMAC-SHA256 of
the raw request body using your webhook secret, then compare against the
X-CertIQ-Signature header using a constant-time comparison.
require "openssl"
secret = ENV.fetch("CERTIQ_WEBHOOK_SECRET")
raw_body = request.body.read
signature = request.headers["X-CertIQ-Signature"].to_s # "sha256=abcd…"
expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body)
unless ActiveSupport::SecurityUtils.secure_compare(signature, expected)
head :unauthorized and return
end
payload = JSON.parse(raw_body)
# ... handle payload
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post(
"/webhooks/certiq",
express.raw({ type: "application/json" }),
(req, res) => {
const secret = process.env.CERTIQ_WEBHOOK_SECRET;
const sig = req.get("X-CertIQ-Signature") || "";
const expected =
"sha256=" + crypto.createHmac("sha256", secret).update(req.body).digest("hex");
const a = Buffer.from(sig);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).end();
}
const payload = JSON.parse(req.body.toString("utf8"));
// ... handle payload
res.status(200).end();
}
);
import hmac, hashlib, os, json
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/webhooks/certiq")
def certiq_webhook():
secret = os.environ["CERTIQ_WEBHOOK_SECRET"].encode()
raw = request.get_data() # raw bytes, before JSON parsing
sig = request.headers.get("X-CertIQ-Signature", "")
expected = "sha256=" + hmac.new(secret, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401)
payload = json.loads(raw)
# ... handle payload
return "", 200
Rate limits
The default limit is 100 requests per API key per rolling 24 hours,
applied across all /api/*
endpoints. When exceeded, CertIQ responds with:
HTTP/1.1 429 Too Many Requests
Retry-After: 86400
Content-Type: application/json
{"error":"Rate limit exceeded","retry_after":86400}
Need a higher limit? Email hello@certiq.au with your expected volume and use case — limits are lifted on request for production partners.
Errors
Error responses share a consistent JSON shape:
{ "error": "Invalid domain" }
| Status | Meaning |
|---|---|
| 401 Unauthorised | Missing, malformed, inactive, or unknown API key. |
| 404 Not Found | Scan token does not exist. |
| 409 Conflict | Requested the PDF of a scan that hasn't completed. |
| 422 Unprocessable Entity | Request body failed validation (e.g. invalid domain). |
| 429 Too Many Requests | Rate limit exceeded. Check Retry-After header. |
| 5xx | CertIQ-side issue. Retry with exponential backoff. |
SDK snippets
There is no official SDK yet — the API surface is small enough that any HTTP client will do. The snippets below show a typical create-and-poll loop.
require "faraday"
require "json"
client = Faraday.new("https://certiq.au/api/v1") do |f|
f.request :json
f.response :json
f.headers["Authorization"] = "Bearer #{ENV.fetch("CERTIQ_API_KEY")}"
end
created = client.post("scans", { domain: "example.com.au" }).body
token = created["scan_token"]
loop do
scan = client.get("scans/#{token}").body
break scan if scan["status"] == "complete"
raise "scan failed" if scan["status"] == "failed"
sleep 5
end
const BASE = "https://certiq.au/api/v1";
const headers = {
Authorization: `Bearer ${process.env.CERTIQ_API_KEY}`,
"Content-Type": "application/json"
};
const created = await fetch(`${BASE}/scans`, {
method: "POST",
headers,
body: JSON.stringify({ domain: "example.com.au" })
}).then((r) => r.json());
const token = created.scan_token;
let scan;
do {
await new Promise((r) => setTimeout(r, 5000));
scan = await fetch(`${BASE}/scans/${token}`, { headers }).then((r) => r.json());
} while (scan.status !== "complete" && scan.status !== "failed");
import os, time, requests
BASE = "https://certiq.au/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['CERTIQ_API_KEY']}"
created = session.post(f"{BASE}/scans", json={"domain": "example.com.au"}).json()
token = created["scan_token"]
while True:
scan = session.get(f"{BASE}/scans/{token}").json()
if scan["status"] == "complete":
break
if scan["status"] == "failed":
raise RuntimeError("scan failed")
time.sleep(5)
Changelog & versioning
The API is versioned in the URL. v1 is the current contract. Breaking changes
will ship under /api/v2
with a deprecation window and migration notes. Additive changes (new fields,
new events) land in v1 without version bumps — build tolerant parsers.
Need sandbox credentials or a higher limit?
Email us with your use case — we respond within a business day.
hello@certiq.au