Remediation API
Website readiness evidence and deterministic fixes, in your workflow.
Authentication
Create a named key in Settings → API. Only Owners and Admins manage keys. Choose read-only or read and create audits. Keys are shown once and stored as hashes. Keep them on your server, never in browser code.
Authorization: Bearer $AI_PRIMED_API_KEY
Base URL: https://ai-primed.com/api/public/v1Rotation immediately invalidates the old key. Revocation blocks subsequent requests and queued work. Never log authorization headers. All reads are restricted to the key’s account, including domain score lookups.
Endpoints
| POST | /audits | Queue one domain; returns 202 and a persistent audit ID. |
|---|---|---|
| GET | /audits/{id} | Same technical export as the JSON report download. |
| GET | /audits/{id}/fixes | Same export envelope, including saved generated fixes. |
| GET | /domains/{domain}/score | Latest saved score and observation date in your account. Never crawls. |
Submit only a public hostname. No paths, credentials, IP literals or bulk lists. Body limit: 4 KiB. Processing wakes approximately every minute; deployed scheduling remains unverified and completion time varies. Respect Retry-After (60 seconds on submission) and poll with a bounded wait. Signed audit-completion and score-change delivery is implemented behind a protected sender. Activation and deployed receiver verification are pending; use polling until Settings allows destinations.
Limits and commercial access
The allowance that matters is site audits per month: each new audit you submit counts once, even if fetching fails. Reads of stored results — fetching scores, findings and fixes you already have — are a separate, larger allowance and never start a crawl. Scheduled monitoring is metered separately and never draws on your API allowance.
| Account allowance | Pro | Team | Agency | API + Reports |
|---|---|---|---|---|
| Site audits / month | 500 | 2,000 | 5,000 | 1,500 |
| Site audits / UTC day | 25 | 100 | 250 | 50 |
| Audits running at once | 2 | 4 | 8 | 2 |
| Reads of stored results / month | 1,000 | 25,000 | 100,000 | 5,000 |
| Reads / minute, per key and account | 30 | 60 | 120 | 30 |
| Submissions / minute, per account | 1 | 2 | 4 | 2 |
Audits running at once is the concurrency limit: how many submitted audits process in parallel before the rest queue. At 1, a 20-site re-test runs one site at a time; at 4 (Team) the same sweep finishes in about a quarter of the wall-clock time. Queued audits still count against your submission allowance.
API access is included in Pro, Team and Agency — no add-on needed. The standalone API + Reports package is $79/month or $790/year: a developer entry point with 5 active Sites, one user and API-origin dashboard reports/fix drafts — no monitoring, extra seats, hosted injection or automatic overages. Free has no API access. All keys share account allowances; existing site caps still apply. Revocation, pausing and downgrades do not replay cancelled notifications.
Worked example: a 50-site agency
An agency monitoring 50 client sites daily runs about 1,500 scheduled checks a month (50 sites × 30 days). Those checks use the monitoring budget, not the API — your API allowance is untouched. Agency includes 5,000 API submissions a month: even if you counted monitoring against it, 1,500 would leave 3,500 for re-tests, competitor checks and onboarding. The same arithmetic holds on every tier: Pro includes 500 against 150 (5 sites × 30 days), and Team includes 2,000 against 750 (25 sites × 30 days).
RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset describe the minute window; reset is Unix seconds. X-RateLimit-Monthly-Remaining reports monthly reads left. A refused allowance returns 429 and Retry-After. Custom atomic backend counters fail closed; this is not a dedicated edge abuse shield.
JSON contract and errors
Schema identifier: agentready.audit/v1. The export has schema, exported_at, audit, findings, fixes, fetch_log, raw_signals. The OpenAPI specification defines the stable fields. Raw signals are extensible technical evidence, not a fixed field-by-field contract.
Queued, running, failed and unscorable results have null score and grade. An empty fixes array means no saved fixes; it never means a fix was applied. Placeholders require real customer data. Applying a file does not verify it or promise assistant mentions.
{"error":{"code":"rate_limited","message":"Request rate exceeded.","request_id":"request UUID"}}400 invalid_request/not_auditable · 401 unauthorized · 403 forbidden/plan_required · 404 not_found · 409 site_limit/site_unavailable · 413 body_too_large · 415 unsupported_media_type · 429 rate_limited, monthly_limit, audit_minute_limit, audit_daily_limit, audit_monthly_limit, concurrency_limit · 503 service_unavailable. Unknown and other-account audit IDs both return 404.
JSON and curl quickstart
Use a write-scoped key from any plan with API access (Pro, Team, Agency or API + Reports) for submission. Bash, curl 7.76+ and jq are required. Export your key securely; do not paste it into browser code. The example submits our public domain; replace it with your hostname. POST is not idempotent—do not blindly retry a submission after a timeout.
POST /audits · JSON body
{"domain":"ai-primed.com"}Submit, poll, retrieve fixes and read a saved score
# Bash + curl + jq. Set AI_PRIMED_API_KEY securely first.
# Do not enable shell tracing or curl --verbose: they can expose the key.
set -euo pipefail
: "${AI_PRIMED_API_KEY:?Set a write-scoped key securely}"
BASE="https://ai-primed.com/api/public/v1"
AUDIT_DOMAIN="ai-primed.com" # Replace with your public hostname.
# Create an explicit JSON request file; jq escapes the hostname safely.
jq -n --arg domain "$AUDIT_DOMAIN" '{domain:$domain}' > audit-request.json
curl --fail-with-body --silent --show-error --max-time 30 \
-X POST "$BASE/audits" \
-H "Authorization: Bearer $AI_PRIMED_API_KEY" \
-H 'Content-Type: application/json' \
--data-binary @audit-request.json -o submission.json
AUDIT_ID=$(jq -er '.id' submission.json)
# Respect the submission's Retry-After: 60; poll at most 10 times.
# A 429 stops this script. Wait for Retry-After, then retrieve later.
done=false
for attempt in {1..10}; do
sleep 60
curl --fail-with-body --silent --show-error --max-time 30 \
"$BASE/audits/$AUDIT_ID" \
-H "Authorization: Bearer $AI_PRIMED_API_KEY" -o audit.json
status=$(jq -r '.audit.status' audit.json)
if [[ "$status" == completed || "$status" == failed ]]; then
jq . audit.json
done=true
break
fi
done
if [[ "$done" == false ]]; then
printf 'Still pending. Retrieve later: %s\n' "$AUDIT_ID"
fi
# Both reads use saved data; neither starts a crawl or applies a fix.
curl --fail-with-body --silent --show-error --max-time 30 \
"$BASE/audits/$AUDIT_ID/fixes" \
-H "Authorization: Bearer $AI_PRIMED_API_KEY" -o fixes-export.json
curl --fail-with-body --silent --show-error --max-time 30 \
"$BASE/domains/$AUDIT_DOMAIN/score" \
-H "Authorization: Bearer $AI_PRIMED_API_KEY" -o score.jsonThese commands use the published base URL. If the new service is not deployed there yet, substitute the preview origin. API keys and responses are private to your account. All admitted reads, including polling, count against the reads-of-stored-results allowance — never against your audit submissions. A 429 stops the example; wait for Retry-After and retrieve later. A failed audit returns HTTP 200 with audit.status failed and X-Audit-Error: audit_failed; HTTP success is not a successful score.
JSON response examples
The following are explicit protocol fixtures with illustrative IDs and dates, not additional measured audits. No score is fabricated. The own-audit case study contains the separately recorded observation.
202 · Accepted submission
{
"id": "10000000-0000-4000-8000-000000000001",
"domain": "ai-primed.com",
"status": "queued",
"result_url": "/api/public/v1/audits/10000000-0000-4000-8000-000000000001"
}200 · Pending export (audit and fixes endpoints)
{
"schema": "agentready.audit/v1",
"exported_at": "2026-10-20T22:00:00Z",
"audit": {
"id": "10000000-0000-4000-8000-000000000001",
"domain": "ai-primed.com",
"status": "queued",
"score": null,
"grade": null,
"started_at": "2026-10-20T22:00:00Z",
"completed_at": null,
"scoring_mode": {
"siteType": "content_site",
"detectedType": "content_site",
"overridden": false
},
"category_scores": {}
},
"findings": [],
"fixes": [],
"fetch_log": [],
"raw_signals": {}
}Both endpoints return the full export, not a bare fixes array. Completed scorable exports include six category objects (label, score, weight), finding records and saved draft fix files. Weight zero means that category is not applicable; it is not a pass. Queued, running, failed and unscorable exports have null score and grade; an empty category object is not six zero scores. Finding evidence is observed text. A saved fix is not proof of application.
200 · Saved unscorable domain observation
{
"domain": "ai-primed.com",
"audit_id": "10000000-0000-4000-8000-000000000001",
"score": null,
"grade": null,
"status": "failed",
"observed_at": "2026-10-20T22:00:00Z"
}Python
# Python 3, standard library only. Set AI_PRIMED_API_KEY securely.
import os, json, time, urllib.request, urllib.error
BASE = "https://ai-primed.com/api/public/v1"
KEY = os.environ["AI_PRIMED_API_KEY"]
def call(path, data=None):
body = json.dumps(data).encode() if data is not None else None
req = urllib.request.Request(BASE + path, data=body,
headers={"Authorization": "Bearer " + KEY, "Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as response:
return json.load(response)
except urllib.error.HTTPError as error:
raise RuntimeError(error.read().decode()) from None
job = call("/audits", {"domain": os.environ["AUDIT_DOMAIN"]})
for _ in range(20):
time.sleep(60)
result = call("/audits/" + job["id"])
if result["audit"]["status"] in ("completed", "failed"):
print(json.dumps(result, indent=2))
break
else:
print("Still pending; retrieve the audit later:", job["id"])JavaScript
// Node 22+. Set AI_PRIMED_API_KEY and AUDIT_DOMAIN securely.
const base = "https://ai-primed.com/api/public/v1";
const key = process.env.AI_PRIMED_API_KEY;
if (!key || !process.env.AUDIT_DOMAIN) throw new Error("Missing environment values");
async function call(path, data) {
const response = await fetch(base + path, {
method: data ? "POST" : "GET",
headers: { Authorization: "Bearer " + key, "Content-Type": "application/json" },
...(data ? { body: JSON.stringify(data) } : {})
});
const result = await response.json();
if (!response.ok) throw new Error(JSON.stringify(result.error));
return result;
}
const job = await call("/audits", { domain: process.env.AUDIT_DOMAIN });
let done = false;
for (let i = 0; i < 20; i++) {
await new Promise(resolve => setTimeout(resolve, 60000));
const result = await call("/audits/" + job.id);
if (["completed", "failed"].includes(result.audit.status)) {
console.log(JSON.stringify(result, null, 2)); done = true; break;
}
}
if (!done) console.log("Still pending; retrieve later:", job.id);Signed webhooks
Built, not activated: signed delivery now uses an isolated sender that pins each connection to a validated public IP and verifies the receiver’s TLS certificate. Sender deployment and a real receiver test are still required; use polling until activation.
After activation, configure a public HTTPS receiver and a signing secret (32–256 characters) in Settings → API. Only eligible account Owners and Admins can add or enable destinations; all eligible account audit completions are covered, including audits started outside the API. The signing secret is distinct from your API key. Supported events are audit.completed and/or score.changed. Existing alert destinations are never opted in. Score-change events require two scored observations; blocked or transient failures do not create a score-change event.
The signature is sha256=hex(HMAC-SHA256(secret, timestamp + "." + exact_raw_body)). Timestamp is Unix seconds in X-AI-Primed-Timestamp. Signature is X-AI-Primed-Signature; X-AI-Primed-Id is the event UUID. The delivery contract is at least once: deduplicate that ID and reject timestamps older than five minutes.
Event JSON · illustrative protocol fixture
{
"id": "20000000-0000-4000-8000-000000000002",
"type": "audit.completed",
"created_at": "2026-10-20T22:00:00Z",
"data": {
"audit_id": "10000000-0000-4000-8000-000000000001",
"domain": "ai-primed.com",
"score": null,
"grade": null,
"previous_score": null,
"observed_at": "2026-10-20T22:00:00Z"
}
}audit.completed carries previous_score: null and may have a null score. score.changed uses the same envelope with type score.changed and non-null current and previous scores. Event IDs persist across retries; the timestamp and signature are regenerated per attempt. Authenticate the signature first, then check that X-AI-Primed-Id matches the signed body ID. Use an accurate clock and reject past or future timestamps beyond five minutes.
Node verification · verify-webhook.mjs
// Save as verify-webhook.mjs. Node 22+, no dependencies.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyWebhook(secret, timestamp, signature, rawBody, now = Date.now()) {
if (typeof secret !== 'string' || Buffer.byteLength(secret) < 32) return false;
if (typeof timestamp !== 'string' || !/^[0-9]{1,12}$/.test(timestamp)) return false;
if (Math.abs(Math.floor(now / 1000) - Number(timestamp)) > 300) return false;
if (typeof signature !== 'string' || !/^sha256=[a-f0-9]{64}$/.test(signature)) return false;
if (!Buffer.isBuffer(rawBody)) return false;
const expected = createHmac('sha256', secret)
.update(timestamp + '.').update(rawBody).digest();
const received = Buffer.from(signature.slice(7), 'hex');
return received.length === expected.length && timingSafeEqual(received, expected);
}Local receiver · receiver.mjs
// Save as receiver.mjs beside verify-webhook.mjs.
// Local verification harness only: not a production delivery service.
import { createServer } from 'node:http';
import { verifyWebhook } from './verify-webhook.mjs';
const secret = process.env.AI_PRIMED_WEBHOOK_SECRET;
if (!secret || Buffer.byteLength(secret) < 32) throw new Error('Set a 32+ byte signing secret');
const seen = new Set(); // Production: durable, atomic event-ID deduplication.
createServer(async (req, res) => {
const reply = (status) => { res.writeHead(status); res.end(); };
if (req.method !== 'POST' || req.url !== '/webhook') return reply(404);
if (!req.headers['content-type']?.startsWith('application/json')) return reply(415);
const chunks = []; let size = 0;
try {
for await (const chunk of req) {
size += chunk.length;
if (size > 65536) return reply(413);
chunks.push(chunk);
}
const body = Buffer.concat(chunks);
if (!verifyWebhook(secret, req.headers['x-ai-primed-timestamp'],
req.headers['x-ai-primed-signature'], body)) return reply(401);
// Parse only after verifying the exact bytes, never reserialized JSON.
const event = JSON.parse(body.toString('utf8'));
if (typeof event.id !== 'string' || event.id !== req.headers['x-ai-primed-id'] ||
!['audit.completed', 'score.changed'].includes(event.type) ||
!event.data || typeof event.data.audit_id !== 'string') return reply(400);
if (seen.has(event.id)) return reply(204);
// Production: atomically persist event + ID before returning 2xx.
seen.add(event.id);
reply(204);
} catch { reply(400); }
}).listen(8787, '127.0.0.1');This receiver is a loopback-only test harness with a 64 KiB local body cap and in-memory deduplication. It is not a production queue. A production receiver needs public HTTPS, a request deadline, strict event validation and atomic durable storage of the event and deduplication ID before acknowledging. Never trust an unsigned ID header or parse and reserialize before verification.
Local signed curl test · expected 204, then 401
# Local synthetic protocol fixture; not an AI-Primed delivery or audit.
# Save the two JavaScript examples above in this folder first.
set -euo pipefail
export AI_PRIMED_WEBHOOK_SECRET=$(node --input-type=module -e \
"import {randomBytes} from 'node:crypto'; console.log(randomBytes(32).toString('hex'))")
node receiver.mjs &
RECEIVER_PID=$!
trap 'kill "$RECEIVER_PID" 2>/dev/null || true; unset AI_PRIMED_WEBHOOK_SECRET' EXIT
node --input-type=module <<'NODE'
import { createHmac } from 'node:crypto';
import { writeFileSync } from 'node:fs';
const payload = {"id":"20000000-0000-4000-8000-000000000002","type":"audit.completed","created_at":"2026-10-20T22:00:00Z","data":{"audit_id":"10000000-0000-4000-8000-000000000001","domain":"ai-primed.com","score":null,"grade":null,"previous_score":null,"observed_at":"2026-10-20T22:00:00Z"}};
const body = JSON.stringify(payload);
const timestamp = String(Math.floor(Date.now() / 1000));
const signature = 'sha256=' + createHmac('sha256', process.env.AI_PRIMED_WEBHOOK_SECRET)
.update(timestamp + '.').update(body).digest('hex');
writeFileSync('event.json', body);
writeFileSync('event-headers.txt', [
'Content-Type: application/json', 'X-AI-Primed-Id: ' + payload.id,
'X-AI-Primed-Timestamp: ' + timestamp, 'X-AI-Primed-Signature: ' + signature
].join('\n') + '\n', { mode: 0o600 });
NODE
# Retry startup connection errors only; expected response is HTTP 204.
curl --silent --show-error --retry 5 --retry-connrefused --retry-delay 1 \
--max-time 5 -o /dev/null -w 'HTTP %{http_code}\n' \
-H @event-headers.txt --data-binary @event.json http://127.0.0.1:8787/webhook
# Tampering must return HTTP 401. No retries for this rejection.
printf ' ' >> event.json
curl --silent --show-error --max-time 5 -o /dev/null -w 'HTTP %{http_code}\n' \
-H @event-headers.txt --data-binary @event.json http://127.0.0.1:8787/webhookThis generates and verifies a synthetic event entirely on your machine. It does not enable AI-Primed delivery or claim a live webhook was received.
The entire delivery attempt, including DNS and TLS, has a five-second deadline. Receivers must return 2xx in that window. HTTPS on port 443 only; IP literals, credentials in URLs, private/reserved IPs and mixed public/private DNS answers are refused. The sender connects to an approved numeric IP, checks the actual peer, and verifies the certificate for the original hostname before sending the signed body. DNS rebinding cannot change the pinned destination. Redirects are never followed. Payloads are capped at 64 KiB and response headers at 16 KiB; response bodies are discarded. Up to five delivery attempts use exponential backoff; failures appear in Settings. Disabling a destination or losing API access cancels pending deliveries. A network send already started cannot be recalled. Delivery acceptance does not establish downstream processing.
Safe use
No internal hosts, private IP ranges or arbitrary crawl proxying. Every audit fetch uses the existing DNS, redirect, size and deadline safeguards. Use results only for authorized purposes; publicly shared third-party observations require clear attribution. An audit is one technical observation, not a guarantee of future access or AI visibility.