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"
}
ShapeWhenContents
detail is a stringAll errors except request validationA human-readable description of what went wrong
detail is an array422 request validation failuresOne { field, message } object per invalid or missing field
Always type-check before formatting. Client code that does 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

200OK

Request succeeded. Response body contains the requested data.

202Accepted

The 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.

400Bad Request

The 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"
}
401Unauthorized

Missing, 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"
}
403Forbidden

You 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"
}
Don't match on the text of a plan-gate message. These messages are generated from the plan definitions, so the tier they name changes whenever a feature moves between plans — and the deployed API may still be returning an older phrasing while a change rolls out. Branch on the 403 status and on which feature you were calling, and treat the message as display text only.
404Not Found

Resource 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"
}
405Method Not Allowed

The path exists but not for this HTTP method — usually a GET where a POST was intended, or the reverse.

{
  "detail": "Method Not Allowed"
}
409Conflict

The 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"
}
422Unprocessable Entity

Request 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"
    }
  ]
}
429Too Many Requests

Two 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.

5xxServer Error

An 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

StatusMeaningWhat to do
200SuccessRead the response body
202Scan accepted and queuedTreat as success; poll the job with the returned job_id
400Semantically invalid requestFix the value — retrying unchanged will fail again
401Missing or invalid credentialsCheck the key and the header; don't parse the message
403Account disabled, subscription inactive, or plan-gated featureUpgrade or contact support; retrying won't help
404Resource not found or not yoursVerify the ID and the path
405Wrong HTTP method for this pathCheck the method against the API reference
409Report requested before the scan finishedKeep polling job status, then retry the report
422Request validation failedRead the field entries in the detail array
429Rate limit or quota exhaustedRetry only if retry_after is present; otherwise stop
5xxServer-side errorRetry 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

400 Bad Request: "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 nslookup or dig
  • 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

Job status: "status": "failed" with a populated failure_reason

A 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

Warning: "SSL certificate validation failed"

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:

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_id or domain involved, if the problem concerns a specific scan