Error Handling
Learn how to handle errors gracefully, interpret error responses, and troubleshoot common issues with the ComplianceLayer API.
Error Response Format
Every error response is a JSON object with exactly one key: detail. There is no error code, no message field, and no status field in the body — the HTTP status line carries the status.
detail takes one of two shapes:
HTTP/1.1 404 Not Found
{
"detail": "Scan job not found"
}| Shape | When | Contents |
|---|---|---|
detail is a string | All errors except request validation | A human-readable description of what went wrong |
detail is an array | 422 request validation failures | One { field, message } object per invalid or missing field |
String(body.detail) or f"{body['detail']}" will render a validation error as [object Object] or a raw Python list. The examples further down show a small helper that handles both shapes.HTTP Status Codes
OKRequest succeeded. Response body contains the requested data.
AcceptedThe success status for POST /v1/scan. The scan has been queued, not completed — the body is a job object with an integer job_id and status: "queued". Poll GET /v1/scan/jobs/{job_id} for the result. Treat 202 as success; a strict === 200 check will reject every scan you submit.
Bad RequestThe request was well-formed but semantically rejected — an unscannable domain, an unsupported option, a duplicate resource. Missing or wrongly typed fields produce 422, not 400.
{
"detail": "Invalid scan frequency"
}UnauthorizedMissing, invalid, or expired credentials. A missing key and an invalid key are indistinguishable — both return the same message, so don't use it to decide whether a key was sent.
{
"detail": "Invalid or missing authentication"
}ForbiddenYou authenticated successfully, but this account may not do this. That means a disabled account, an inactive subscription, or a feature gated to a higher plan — the Zapier integration and white-label PDF branding. Webhooks and PDF reports are not plan-gated. Running out of scan quota is not a 403 — see 429 below.
{
"detail": "Zapier integration requires the Pro plan or higher. Upgrade at /billing/checkout"
}403 status and on which feature you were calling, and treat the message as display text only.Not FoundResource not found, or not owned by your account. Check that the ID and the path are correct. Note that the public verify endpoint returns 404 for a malformed report ID as well as an unknown one, deliberately, so the ID format isn't leaked.
{
"detail": "Scan job not found"
}Method Not AllowedThe path exists but not for this HTTP method — usually a GET where a POST was intended, or the reverse.
{
"detail": "Method Not Allowed"
}ConflictThe resource exists but isn't in a state that allows this request. In practice you'll see this when fetching GET /v1/scan/jobs/{job_id}/report before the scan has finished. It is not a failure — keep polling the job status and retry the report once status is completed.
{
"detail": "Scan job is not complete yet"
}Unprocessable EntityRequest validation failed: a required field is missing, has the wrong type, or violates a constraint. This is the only status where detail is an array. Each entry names the offending field and why it was rejected.
{
"detail": [
{
"field": "domain",
"message": "Field required"
}
]
}Too Many RequestsTwo different conditions share this status, and they need opposite handling. A rate limit means you sent requests too quickly — waiting and retrying works. A quota exhaustion means you've used every scan in your billing period — retrying will never succeed until the period rolls over or you upgrade.
HTTP/1.1 429 Too Many Requests
{
"detail": "Rate limit exceeded for free plan. Limit: 120 per minute",
"error": "Rate limit exceeded",
"message": "Rate limit exceeded for free plan. Limit: 120 per minute",
"retry_after": 37,
"upgrade_url": "/pricing"
}How to tell them apart: the rate-limit response comes from middleware and carries extra keys — retry_after, error, and upgrade_url. The quota response is a plain { "detail": ... } with none of them. So: if retry_after is present, sleep for that many seconds and retry; if it's absent, stop retrying and surface the message to the user.
A Retry-After header is only guaranteed on the public scanner path (POST /v1/scan/free), so treat the retry_after body field as the reliable signal.
Server ErrorAn unexpected error on our side (500), or a dependency that is temporarily unavailable (502, 503). These are usually transient — retry with exponential backoff, and contact support if they persist.
{
"detail": "Scan execution failed. Please try again shortly."
}Status Code Summary
| Status | Meaning | What to do |
|---|---|---|
200 | Success | Read the response body |
202 | Scan accepted and queued | Treat as success; poll the job with the returned job_id |
400 | Semantically invalid request | Fix the value — retrying unchanged will fail again |
401 | Missing or invalid credentials | Check the key and the header; don't parse the message |
403 | Account disabled, subscription inactive, or plan-gated feature | Upgrade or contact support; retrying won't help |
404 | Resource not found or not yours | Verify the ID and the path |
405 | Wrong HTTP method for this path | Check the method against the API reference |
409 | Report requested before the scan finished | Keep polling job status, then retry the report |
422 | Request validation failed | Read the field entries in the detail array |
429 | Rate limit or quota exhausted | Retry only if retry_after is present; otherwise stop |
5xx | Server-side error | Retry with backoff; contact support if it persists |
Error Handling Examples
Both samples below normalize detail across its two shapes and treat a quota 429 as terminal rather than retrying forever.
class QuotaExhausted extends Error {}
class RateLimited extends Error {
constructor(message, retryAfter) {
super(message);
this.retryAfter = retryAfter;
}
}
// detail is a string for most errors, and an array of
// { field, message } objects for 422 validation failures.
function formatDetail(detail) {
if (Array.isArray(detail)) {
return detail
.map(d => `${d.field}: ${d.message}`)
.join('; ');
}
return detail ?? 'Unknown error';
}
async function scanDomain(domain) {
const response = await fetch(
'https://api.compliancelayer.net/v1/scan',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ domain })
}
);
// A queued scan returns 202, not 200.
if (response.ok) {
return await response.json();
}
const body = await response.json().catch(() => ({}));
const detail = formatDetail(body.detail);
switch (response.status) {
case 400:
throw new Error(`Request rejected: ${detail}`);
case 401:
throw new Error('Authentication failed. Check your API key.');
case 403:
throw new Error(`Not permitted: ${detail}`);
case 422:
throw new Error(`Validation failed — ${detail}`);
case 429:
// Only rate-limit responses carry retry_after. Without it,
// the monthly quota is gone and retrying is pointless.
if (body.retry_after != null) {
throw new RateLimited(detail, body.retry_after);
}
throw new QuotaExhausted(detail);
default:
if (response.status >= 500) {
throw new Error(`Server error (${response.status}). Retry shortly.`);
}
throw new Error(`Request failed (${response.status}): ${detail}`);
}
}
// Retry only what is actually retryable.
async function scanWithRetry(domain, maxAttempts = 3) {
for (let attempt = 1; ; attempt++) {
try {
return await scanDomain(domain);
} catch (error) {
if (error instanceof QuotaExhausted) throw error;
if (!(error instanceof RateLimited) || attempt >= maxAttempts) throw error;
await new Promise(r => setTimeout(r, error.retryAfter * 1000));
}
}
}Troubleshooting Guide
Domain Resolution Failures
"Could not resolve example.com"Possible causes:
- Domain doesn't exist or DNS is misconfigured
- Typo in domain name
- Domain was recently registered (DNS not propagated)
Solutions:
- Verify domain spelling
- Check DNS records with
nslookupordig - Wait 24-48 hours for new domains to propagate
A closely related 400 is "Scanning targets that resolve to private or non-public IP space is not allowed". The scanner is external-only, so internal hostnames and anything resolving to private address space are rejected by design.
Scan Failures and Timeouts
"status": "failed" with a populated failure_reasonA scan that is accepted can still fail later. Polling GET /v1/scan/jobs/{job_id} returns status: "failed" and a failure_reason string explaining why — always read that field rather than assuming a timeout. Common causes:
- Domain is slow to respond or intermittently unreachable
- Firewall or WAF blocking the scanner
- Transient infrastructure error on our side
Solutions:
- Retry the scan (this consumes another scan from your quota)
- Check that the domain is reachable from the public internet
- Ask support about allowlisting ComplianceLayer scanner ranges
SSL Certificate Errors
This is normally reported as a finding inside the report's ssl module, not as an API error. Common issues:
- Self-signed certificates
- Expired certificates
- Hostname mismatch
- Incomplete certificate chain
Getting Help
If you're experiencing issues not covered here:
- Email: support@compliancelayer.net
- Status Page: compliancelayer.net/status
- API Docs: OpenAPI Specification
When contacting support, please include:
- The full error response body and HTTP status code
- The endpoint and method you called
- An example request, with the API key redacted
- The timestamp of the error, including timezone
- The
job_idor domain involved, if the problem concerns a specific scan