01Choose the right integration
The REST API lets your application start scans, track progress, and read findings over HTTPS. Follow the REST quickstart for your first request, then use the JavaScript or Python examples to collect results. For an agent with an MCP client, see hosted MCP. Use the local CLI or stdio MCP when a developer needs access to a repository checkout.
| CLI + local MCP | Repository-local developer and coding-agent workflows with device login |
| Hosted MCP | Production agents over Streamable HTTP at https://ai.cantina.xyz/mcp |
| Service API | Serverless workers, cron jobs, CI, and internal audit hubs |
| Authentication | Authorization: Bearer apex_sk_live_… or X-API-Key |
| Findings format | GitLab SAST for scan exports; normalized JSON for workspace exports |
The machine-readable contract is available at /api/apex/v1/openapi.json.
02Prerequisites
- For remote scans, connect the GitHub repository to the same Apex organization that owns the service key. The first API scan can create and link its workspace automatically.
- For remote scans, use a full 40- or 64-character Git commit SHA. Branch names and moving refs are not accepted.
- For local or non-Git directories, package the source as tar.gz. The API creates the workspace and signed archive upload; no Git remote or website scan is required.
- Generate or revoke keys as an Apex organization manager or administrator.
- Store the key in a secret manager. Apex displays the raw secret only once and stores only its hash.
Version-one repository support
The first service API release accepts GitHub repositories already connected to your Apex organization. GitLab service-account access will follow once connection ownership can be made explicit.
03Generate a service key
Where Settings is available
The Settings flow is available on hosted, non-demo company tenants to active, verified company managers and Apex administrators. Active, verified personal-tenant owners use the scripted CLI instead. Demo tenants and on-prem deployments do not support hosted service API key management.
- Open company SettingsFrom the company dashboard, open Settings and find Service API keys.
- Generate the keySelect Generate key, give the key a descriptive name, choose the scopes it needs, and set an expiration.
- Save the one-time secretCopy the secret into your secret manager before closing the dialog. Apex cannot display it again.
Prefer the smallest set of scopes the integration needs. Use only scans:read and findings:read when a key polls existing scans and imports findings; add scans:create when it must start or cancel scans. Select Write findings (findings:write) to change finding status or create or link Linear or Jira tickets. Existing keys and the default three scopes do not gain it.
CLI alternative
Managers and administrators can also generate, list, and revoke service keys from their own terminal with Apex CLI.
apex update
apex login
# Replace this placeholder with a future ISO-8601 timestamp from your rotation policy:
FUTURE_ISO_8601_TIMESTAMP="YYYY-MM-DDTHH:MM:SSZ"
# Read-only key with an explicit expiration:
apex service-key create --name "Audit hub" --scope scans:read,findings:read --expires-at "$FUTURE_ISO_8601_TIMESTAMP"
# Omit --scope only when this key must also trigger scans:
apex service-key create --name "Audit hub" --expires-at "$FUTURE_ISO_8601_TIMESTAMP"
# Optional machine-readable output:
apex service-key create --name "Audit hub" --scope scans:read,findings:read --expires-at "$FUTURE_ISO_8601_TIMESTAMP" --jsonApex CLI 0.1.16 and later automatically uses your company when only one is available. If you belong to multiple companies, it asks you to choose. Use --company <id-or-handle> to skip that prompt, or when a non-interactive command cannot prompt. As in Settings, copy the secret into your secret manager when it is created. It cannot be retrieved later.
Settings defaults new keys to a 90-day expiration. The CLI leaves a key non-expiring when --expires-at is omitted, so prefer an explicit future ISO-8601 timestamp unless your credential-rotation policy intentionally requires no expiration.
When --scope is omitted, the key gets the default three scopes: scans:create, scans:read, and findings:read. List metadata or revoke a key without exposing its secret:
apex service-key list
apex service-key revoke <key-id>04REST quickstart
The base URL is https://ai.cantina.xyz/api/apex/v1. Requests and responses use JSON. Run requests from your server, CI job, or local terminal; keep the service key out of browser JavaScript. Your key selects the organization, so you do not send a company ID.
- Create a service keyFollow Generate a service key above. For the full scan workflow, select scans:create, scans:read, and findings:read.
- Load the credentialInject APEX_SERVICE_KEY using your secret manager or CI secrets. Send it in the Authorization: Bearer header on each request.
- Verify access with a GETCheck credit availability below before starting a scan. This request does not start a scan or spend scan credits.
# APEX_SERVICE_KEY is already set by your secret manager.
export APEX_API_BASE="https://ai.cantina.xyz/api/apex/v1"
curl --fail-with-body --silent --show-error --max-time 30 \
--header "Authorization: Bearer $APEX_SERVICE_KEY" \
"$APEX_API_BASE/credits"A 200 response includes companyId, scansEnabled, balances.standard.remaining, balances.lite.remaining, and scanTypes. Check the chosen scan type's available, ledger, and creditsRequired. This is a snapshot; the scan-creation response is authoritative if balances change.
Only importing results?
Use a key with scans:read and findings:read. Use workspace and scan listing to discover IDs. Credits and legacy repository-only discovery require scans:create. Verify a results-only key with a scan list:
curl --fail-with-body --silent --show-error --max-time 30 \
--header "Authorization: Bearer $APEX_SERVICE_KEY" \
"https://ai.cantina.xyz/api/apex/v1/scans?limit=20"Next, start a scan and save its scanId, then collect findings in JavaScript or Python. Both examples also work with a scan already started in the web app.
05Endpoints and scopes
All paths below are relative to /api/apex/v1 and require a service key with the listed scope. Use the UUID returned by Apex for scan and finding IDs; workspaceRef also accepts a workspace prefix such as BEDR9.
| Endpoint | Scope | Use |
|---|---|---|
GET /credits | scans:create | Credit balances and scan availability |
GET /workspaces | scans:read | List workspaces, associated scans, and finding counts |
GET /scans | scans:read | Discover scan IDs; filter by workspace, status, or type |
POST /scans | scans:create | Start a scan; requires Idempotency-Key |
POST /apex-black/scans | scans:create | Start an Apex Black scan; requires Idempotency-Key and 200 Standard credits |
GET /scans/{scanId} | scans:read | Read status and progress |
POST /scans/{scanId}/cancel | scans:create | Cancel an active scan |
GET /scans/{scanId}/findings | findings:read | Export a GitLab SAST report |
GET /workspaces/{workspaceRef}/findings | findings:read | Export workspace findings as normalized JSON |
PATCH /findings/{findingId}/status | findings:write | Close, reopen, or update remediation status |
POST /source-uploads | scans:create | Create archive upload sessions |
POST /source-uploads/{uploadId}/complete | scans:create | Verify a source archive upload |
GET /findings/{findingId}/tickets | findings:read | List linked tickets |
POST /findings/{findingId}/tickets | findings:write | Link an existing Linear or Jira ticket |
POST /findings/{findingId}/issue-trackers/{provider} | findings:write | Send a finding to Linear or Jira |
Listing counts also requires findings:read. The legacy GET /workspaces?repository=owner/repo summary response keeps its scans:create scope; use include=scans for expanded, read-only inventory with a repository filter.
Download the public OpenAPI 3.1 specification for complete request and response schemas, or import it into Postman or an OpenAPI-compatible client generator. The specification itself does not require authentication.
06Discover workspaces and scans
Use GET /workspaces to inventory your organization and GET /scans to find existing scan IDs. Both require scans:read. Add findings:read to receive finding counts; without it, count fields are null. These requests do not start scans or consume scan credits.
curl --fail-with-body --silent --show-error --max-time 30 \
--header "Authorization: Bearer $APEX_SERVICE_KEY" \
"https://ai.cantina.xyz/api/apex/v1/workspaces?limit=20&scanLimit=20"Each workspace includes its workspaceId, prefix, name, repositories, timestamps, scanCount, findingsCount, a scans array, and scansNextCursor. Empty workspaces and workspaces created from local source archives are included. A repository filter selects matching workspaces; their inventory still includes all linked repositories and scans.
List scan IDs directly
curl --fail-with-body --silent --show-error --max-time 30 \
--header "Authorization: Bearer $APEX_SERVICE_KEY" \
"https://ai.cantina.xyz/api/apex/v1/scans?limit=20" \
| jq -r '.scans[].scanId'Remove the jq step to keep the full JSON response, including nextCursor. Scan summaries include their workspace ID, display name, sequence number, stored scan type, status, timestamps, archive state, finding count, and last findings sync time. PR and fix-review scans are included; unfinished setup rows have isSetupDraft: true. A preparing API request may not have a stored scan row yet, so retain the scanId returned by POST /scans as well.
Filter and retrieve every page
export APEX_WORKSPACE_ID="<workspace-uuid>"
curl --fail-with-body --silent --show-error --max-time 30 --get \
--header "Authorization: Bearer $APEX_SERVICE_KEY" \
--data-urlencode "workspaceId=$APEX_WORKSPACE_ID" \
--data-urlencode "status=completed" \
--data-urlencode "limit=20" \
"https://ai.cantina.xyz/api/apex/v1/scans"Both lists default to 20 records and accept limit=1 through 50. Results are ordered by UUID, not creation time. Request the same endpoint and filters with cursor=nextCursor until nextCursor is null. Each request reads the current state; pagination is not a frozen snapshot of a changing inventory.
Workspace scans have independent pagination. If a workspace has a non-null scansNextCursor, continue at GET /scans?workspaceId=…&cursor=… using that cursor and the same includeArchived setting. The workspace's totals include scans beyond this nested page. scanLimit controls the nested page size, also from 1 to 50.
# Add these query parameters to the corresponding endpoint:
/scans?workspaceId=<workspace-uuid>&status=completed&scanType=standard
/scans?includeArchived=true
/workspaces?includeArchived=true
/workspaces?repository=owner%2Frepo&include=scansstatus accepts queued, running, completed, failed, or cancelled. The scanType filter matches the stored type, such as standard, ultra (Audit), lite, pr, or fix_review. Archived records are excluded by default; includeArchived=true includes them. On workspace inventory, that setting applies to both workspaces and nested scans.
What the counts mean
findingsCount is the last persisted scan total, which can include draft and associated findings. It can lag a running scan; use findingsSyncedAt when available to inspect freshness. Workspace finding totals sum matching scan counts, so a finding associated with multiple scans can count more than once. They are not unique workspace-finding counts or severity/validation-filtered totals. A count is null when unavailable or outside the key's scopes. A zero before the first findings sync is also unknown; 0 means a synchronized zero. Imported nonzero totals can be available without a sync timestamp.
Existing repository discovery stays compatible
A request containing only repository retains the existing unpaginated workspace summaries and requires scans:create. Add include=scans or a pagination option to receive the expanded inventory using scans:read.
07Trigger a scan
Send a unique, stable Idempotency-Key for each intended scan. Persist it with the request body in your job state and reuse both when retrying. Replace the example repository and full commit SHA below with your connected repository and the revision to scan. Starting a scan consumes the selected scan credits.
Threat model first, scan second
By default, the API accepts the request with HTTP 202, generates a fresh unattended threat model in the background, and starts the scan automatically when generation completes. The initial status is preparing; poll the returned statusUrl through preparation and scanning. Scan credits are not reserved until threat-model generation succeeds. When you provide workspaceId, useExistingThreatModel defaults to true. Apex validates that the completed model exists before reserving credits. Requests without workspaceId still default to fresh generation for first-scan workspace creation. Set generateThreatModel to false only when you intentionally want to start without generation or validated reuse.
Apex reuses the active workspace already linked to the repository. If none exists, the same request creates a workspace named after the canonical GitHub repository and links it before starting the scan—no website scan is required. If more than one workspace matches, include its workspaceId in the body. The conflict response includes each matching workspace ID, prefix, and name so the caller can present a choice instead of failing with an undiscoverable ID.
Explicit workspaces stay explicit
When workspaceId is present, Apex will not rewrite that workspace to point at another repository. A mismatch returns a conflict so integrations cannot silently change an existing workspace.
Reuse an existing threat model
For a subsequent scan, provide the existing workspace's UUID;useExistingThreatModel is true by default. Set generateThreatModel to true when you want a fresh threat model instead; an explicit fresh generation request overrides the implicit reuse default. Apex fails closed with THREAT_MODEL_NOT_AVAILABLE if the workspace has no completed model; after choosing a different workspace or switching back to generation, submit the corrected request with a new Idempotency-Key.
# Set once for this intended scan; save it before making the request.
export APEX_IDEMPOTENCY_KEY="<unique-job-id>"
curl --fail-with-body --silent --show-error --max-time 60 \
--request POST https://ai.cantina.xyz/api/apex/v1/scans \
--header "Authorization: Bearer $APEX_SERVICE_KEY" \
--header "Idempotency-Key: $APEX_IDEMPOTENCY_KEY" \
--header "Content-Type: application/json" \
--data '{
"repoUrl": "https://github.com/acme/payments",
"commit": "0123456789abcdef0123456789abcdef01234567",
"paths": ["src", "packages/auth"],
"scanType": "standard",
"generateThreatModel": true
}'The key accepts 1–255 letters, numbers, periods, underscores, colons, or hyphens. Generate a UUID or use your durable job ID. After an HTTP 202, save scanId and poll instead of submitting another POST. The returned statusUrl and findingsUrl are relative to https://ai.cantina.xyz.
| standard (default) | Consumes the configured standard scan-credit cost (200 credits by default). |
| audit | Consumes 1,000 standard credits. |
| lite | Consumes Lite credits when the Lite-credit ledger is enabled; otherwise consumes the configured standard-credit cost. |
| local archives | Currently support standard scans and use the same standard credit reservation path. |
Paths are focus hints
The optional paths array prioritizes those files in Apex prompts, but it is not a hard repository boundary. The scan may inspect or report code outside those paths. Omit it if you do not need focus hints; version one does not yet provide strict path isolation.
Scan a local or non-Git directory
Raw filesystem paths cannot cross an HTTP boundary. Create a tar.gz archive, calculate its SHA-256 plus compressed and uncompressed byte counts, then use the upload → complete → scan flow below. A directory does not need to be a Git repository; omit the optional git object for non-Git source.
# 1. Package the source without embedding the output archive:
tar --exclude="./apex-source.tar.gz" -czf apex-source.tar.gz .
# Calculate SHA256, COMPRESSED_BYTES, and UNCOMPRESSED_BYTES in your uploader.
# 2. Create the workspace and signed upload session:
curl --request POST https://ai.cantina.xyz/api/apex/v1/source-uploads \
--header "Authorization: Bearer $APEX_SERVICE_KEY" \
--header "Content-Type: application/json" \
--data '{
"workspaceName": "Payments local",
"uploads": [{
"displayName": "payments",
"relativePath": ".",
"archiveFormat": "tar.gz",
"sha256": "<sha256>",
"compressedBytes": <compressed-bytes>,
"uncompressedBytes": <uncompressed-bytes>
}]
}'
# 3. PUT the archive to uploads[0].upload.putUrl using its returned headers:
curl --request PUT --header "Content-Type: application/gzip" \
--upload-file apex-source.tar.gz "<put-url>"
# 4. Verify the upload; this returns archiveId and sha256:
curl --request POST \
--header "Authorization: Bearer $APEX_SERVICE_KEY" \
--header "Content-Type: application/json" \
--data '{"workspaceId":"<workspace-id>"}' \
https://ai.cantina.xyz/api/apex/v1/source-uploads/<upload-id>/complete
# 5. Start the first scan with a fresh threat model:
curl --request POST https://ai.cantina.xyz/api/apex/v1/scans \
--header "Authorization: Bearer $APEX_SERVICE_KEY" \
--header "Idempotency-Key: local-payments-2026-07-22" \
--header "Content-Type: application/json" \
--data '{
"workspaceId": "<workspace-id>",
"scanType": "standard",
"generateThreatModel": true,
"scanSources": [{
"sourceKind": "local_archive",
"displayName": "payments",
"relativePath": ".",
"archiveId": "<archive-id>",
"sha256": "<sha256>"
}]
}'Generate a model for a new local workspace
A newly created local workspace has no completed threat model to reuse. Keep generateThreatModel: true on its first scan. Omitting it makes a request with workspaceIddefault to existing-model reuse and can return THREAT_MODEL_NOT_AVAILABLE.
Archive limits
Each archive must be at most 500 MiB compressed and 2 GiB uncompressed, and one scan may include at most 20 archives. Signed upload URLs are credentials: do not log or persist them beyond the upload.
A successful request returns HTTP 202:
{
"scanId": "3e559b86-f524-4b27-a175-fb4944c53f1b",
"workspaceId": "2abf03ae-768b-4717-9d14-43e1a8d8d9a1",
"status": "preparing",
"statusUrl": "/api/apex/v1/scans/3e559b86-f524-4b27-a175-fb4944c53f1b",
"findingsUrl": "/api/apex/v1/scans/3e559b86-f524-4b27-a175-fb4944c53f1b/findings"
}08Poll scan status
An HTTP 200 means the status lookup succeeded; check the JSON status to determine whether the scan succeeded. Status responses include scanId, status, progress (0–100), createdAt, and updatedAt. Preparation failures also include error and code.
curl --header "Authorization: Bearer $APEX_SERVICE_KEY" \
https://ai.cantina.xyz/api/apex/v1/scans/<scan-id>- StartTreat the HTTP 202 response as accepted and retain its scanId and statusUrl.
- PollUse bounded exponential backoff while status is preparing, queued, or running.
- StopStop polling on completed, failed, or cancelled.
- CollectFetch findings after the scan reaches completed.
09JavaScript and Python examples
Save either program below, set APEX_SCAN_ID to the UUID from the creation response or an existing scan, and inject APEX_SERVICE_KEY from your secret manager. The key needs scans:read and findings:read.
Each program waits for completion and writes the GitLab SAST JSON report to standard output. It stops on failed or cancelled scans and other non-retryable errors, retries GETs on transport failures, 429, and 5xx, and honors Retry-After while findings finish synchronizing. The budget is 120 requests with a 30-second request timeout and backoff capped at 30 seconds unless the server asks for a longer wait.
Node.js 22 or later
No packages to install. Save as apex-findings.mjs.
const { APEX_SERVICE_KEY, APEX_SCAN_ID } = process.env;
if (!APEX_SERVICE_KEY || !APEX_SCAN_ID) {
throw new Error("Set APEX_SERVICE_KEY and APEX_SCAN_ID first.");
}
const url = "https://ai.cantina.xyz/api/apex/v1/scans/" +
encodeURIComponent(APEX_SCAN_ID);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let readingFindings = false;
async function collectFindings() {
for (let attempt = 0; attempt < 120; attempt++) {
let delay = Math.min(2000 * 2 ** Math.min(attempt, 4), 30000);
let response;
try {
response = await fetch(url + (readingFindings ? "/findings" : ""), {
headers: { Authorization: "Bearer " + APEX_SERVICE_KEY },
signal: AbortSignal.timeout(30000),
redirect: "error",
});
} catch {
// Transport errors are safe to retry: every request here is a GET.
await sleep(delay);
continue;
}
if (response.ok) {
let data;
try {
data = await response.json();
} catch {
// Retry an incomplete or malformed GET response within the same budget.
await sleep(delay);
continue;
}
if (readingFindings) return data;
if (data.status === "completed") {
readingFindings = true;
continue;
}
if (!["preparing", "queued", "running"].includes(data.status)) {
throw new Error("Scan stopped: " + data.status +
(data.code ? " (" + data.code + ")" : ""));
}
} else {
if (response.status !== 429 && response.status < 500) {
const error = await response.json().catch(() => ({}));
throw new Error("HTTP " + response.status + ": " +
(error.code ?? "request_failed"));
}
// Apex returns Retry-After in seconds. Never retry sooner.
const retryAfter = Number(response.headers.get("Retry-After"));
if (Number.isFinite(retryAfter) && retryAfter > 0) {
delay = Math.max(delay, retryAfter * 1000);
}
await response.body?.cancel();
}
await sleep(delay);
}
throw new Error("Polling budget exhausted. Resume with the same APEX_SCAN_ID.");
}
console.log(JSON.stringify(await collectFindings(), null, 2));node apex-findings.mjs > findings.jsonPython 3.10 or later
Uses only the standard library. Save as apex_findings.py.
import json
import os
import time
from urllib.error import HTTPError, URLError
from urllib.parse import quote
from urllib.request import HTTPRedirectHandler, Request, build_opener
key = os.environ["APEX_SERVICE_KEY"]
scan_id = os.environ["APEX_SCAN_ID"]
url = "https://ai.cantina.xyz/api/apex/v1/scans/" + quote(scan_id, safe="")
# Keep the bearer credential on the configured API origin.
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
opener = build_opener(NoRedirect)
def collect_findings():
reading_findings = False
for attempt in range(120):
delay = min(2 * 2 ** min(attempt, 4), 30)
request = Request(
url + ("/findings" if reading_findings else ""),
headers={"Authorization": "Bearer " + key},
)
try:
with opener.open(request, timeout=30) as response:
data = json.load(response)
except HTTPError as error:
with error:
if error.code != 429 and error.code < 500:
raise RuntimeError("HTTP " + str(error.code) +
": check the API error and scopes") from None
try:
retry_after = int(error.headers.get("Retry-After", "0"))
except ValueError:
retry_after = 0
delay = max(delay, retry_after)
except (URLError, TimeoutError, json.JSONDecodeError, UnicodeDecodeError):
pass # These GET requests are safe to retry.
else:
if reading_findings:
return data
if data["status"] == "completed":
reading_findings = True
continue
if data["status"] not in ("preparing", "queued", "running"):
raise RuntimeError("Scan stopped: " + data["status"] +
" (" + data.get("code", "terminal") + ")")
time.sleep(delay)
raise TimeoutError("Polling budget exhausted. Resume with the same APEX_SCAN_ID.")
print(json.dumps(collect_findings(), indent=2))python3 apex_findings.py > findings.jsonResume the same scan
A local timeout does not cancel the scan. Save its ID in your job state and rerun with the same APEX_SCAN_ID; these programs never create a scan. Run long polling in a background worker or scheduled job when it exceeds your hosting platform's request duration. Treat the exported report as confidential; findings can include unpublished drafts.
10Cancel a scan
curl --request POST \
--header "Authorization: Bearer $APEX_SERVICE_KEY" \
https://ai.cantina.xyz/api/apex/v1/scans/<scan-id>/cancelCancellation requires scans:create and stops the remaining Apex work for a queued or running scan. A successful response returns HTTP 200 with status: cancelled. Repeating the request after cancellation is safe and returns the same terminal status.
Terminal scans cannot be cancelled
Apex returns 409 when a scan is already completed or failed, or when it has not started in Apex yet. Poll the status URL to confirm the current terminal state.
11Fetch findings
curl --header "Authorization: Bearer $APEX_SERVICE_KEY" \
https://ai.cantina.xyz/api/apex/v1/scans/<scan-id>/findingsThe response is a GitLab SAST report. Each vulnerability includes the persisted Apex finding ID, title, description, severity, and available file and line location. IDs remain stable when you fetch the same scan again, so downstream systems can deduplicate safely.
Findings retrieval also works for scans started in the Apex web app, so an integration can begin with this endpoint before automating scan creation.
Discover workspace IDs
A key with scans:create can list active workspaces before creating a scan. Provide a repository URL or GitHub owner/name to find every linked workspace without exposing unrelated workspace metadata. If more than one is returned, let the user choose one and send its workspaceId in a new scan request with a new Idempotency-Key.
curl --header "Authorization: Bearer $APEX_SERVICE_KEY" \
'https://ai.cantina.xyz/api/apex/v1/workspaces?repository=Uniswap%2Fliquidity-launcher'Fetch a whole workspace by prefix
To fetch the same finding set as the workspace findings browser, use its human-readable prefix such as BEDR9. The export uses non-archived scans and excludes PR scans, fix-review scans, and unfinished advanced-scan setup drafts. The service key still limits the lookup to its own organization, so a prefix from a different organization returns 404. Use the workspace UUID instead if a company has an ambiguous prefix.
curl --header "Authorization: Bearer $APEX_SERVICE_KEY" \
'https://ai.cantina.xyz/api/apex/v1/workspaces/BEDR9/findings?state=open'The response includes the resolved workspace, its active scans, and full finding records rather than only finding IDs. Set state=open to exclude invalid findings, duplicates, and findings marked fixed, false positive, won't fix, or skipped. The response includes each finding's reviewState, isDuplicate, and duplicateOfId. Repeat filter to combine facets. Supported forms include severity:high, status:valid, review:accepted, duplicate:unique, repo:example-app, source:lib/auth.ts, impact:high, likelihood:medium, visibility:draft, scan:<scan-id>, and !validation:invalid. Finding statuses are proposed, partially_valid, valid, invalid, or duplicate; open is a finding state, not a status value. Review state and validity include the current shared client feedback used by the workspace UI. Validation filters use the same client/viewer feedback precedence as the workspace UI, and each finding exposes the resulting validity as valid, invalid, or unknown. Invalid or unknown filter strings are ignored; the response's filters array shows the filters that were applied.
Findings access includes drafts
A key with findings:read can retrieve unpublished draft findings for scans in its organization so an external hub can perform triage. Treat the service key as a privileged credential and do not expose its findings payload to users who cannot review drafts in Apex.
The first request after a scan completes durably synchronizes the terminal finding set. If that sync is unavailable or has more pages, Apex returns 503 with Retry-After. Retry the same GET; after readiness is persisted, later requests serve the cached report without depending on a live scan process.
12Errors and retries
API errors have a human-readable error and a stable code; branch on the code instead of matching the message. Some errors include additional fields, such as matching workspaces. For example, a missing scope returns HTTP 403:
{
"error": "Service API key is missing the scans:read scope",
"code": "insufficient_scope"
}Rate limits apply per service key. On 429, wait at least the number of seconds in Retry-After before trying again. Share a polling budget across workers using the same key, and add jitter to backoff in concurrent integrations.
| 400 | Invalid repository URL, commit SHA, path hint, or request body |
| 401 | Missing, malformed, expired, or revoked service key |
| 402 | The selected scan credit or entitlement is exhausted |
| 403 | The key lacks the required scope |
| 404 | The scan does not exist in the key's organization |
| 409 | Idempotency conflict, repository workspace mismatch, active scan, or a scan that cannot be cancelled |
| 422 | Unsupported repository or scan-source input |
| 429 | Rate limit reached; respect Retry-After |
| 500 | Internal persistence or lookup failure; retry with backoff |
| 502 | Transient provider or Apex scan failure; retry with the identical request when appropriate |
| 503 | Workspace provisioning is in progress, an Apex operation timed out, scan finalization is incomplete, or the first terminal findings sync is incomplete; respect Retry-After |
Retry safely
Threat-model generation and scan startup continue server-side after the creation request returns 202. Preparation failures appear as status: failed with an error code at the returned status URL; callers do not need to replay the creation POST merely because generation takes longer than one HTTP request.
GET requests and scan cancellation requests are safe to retry. After a transport error, timeout, or 5xx, retry a scan creation POST only with the original body and the same Idempotency-Key. Idempotency is organization-scoped, so replay remains safe if the service key is rotated between attempts. For 429 or a 409with IDEMPOTENCY_REQUEST_IN_PROGRESS, wait for Retry-After and retry the same body and key. A stored, definitive 4xx failure is retained: after correcting its precondition, submit it as a new intended scan with a new Idempotency-Key. Never log the Authorization or X-API-Key header.
13Connect hosted MCP
Point a Streamable HTTP MCP client at https://ai.cantina.xyz/mcp. ChatGPT and supported Codex loopback clients link an Apex organization through OAuth. Service-key clients send Authorization: Bearer <TOKEN> using their organization’s key. No local apex-mcp process is required.
Connect once and continue your request
The Apex Black plugin uses browser sign-in in ChatGPT and Codex. Use its Connect or Authenticate action, sign in, and select your organization. Your agent retains the requested targets and continues preparation after connecting. Connecting is free and does not start a scan.
ChatGPT
Connect https://ai.cantina.xyz/mcp using OAuth in your host’s developer connection flow, or import the Apex Black plugin. Leave client credentials blank; Apex supports dynamic public-client registration and S256 PKCE. Sign in with your existing Apex account, select an organization you manage, and review the scan and finding permissions before connecting. This connection does not start a scan.
Connections expire after 30 days. Revoke the MCP OAuth: ChatGPT entry in organization Settings → Service API keys to disconnect. Access tokens expire after one hour and refresh tokens rotate automatically. The OAuth server migration must be deployed before account linking works.
Codex
The Apex Black plugin uses OAuth by default and needs no service key or environment-variable setup. For a standalone connection, add the hosted server without a bearer-token setting, then log in. Complete the browser consent flow with an organization you manage; Codex receives the callback through its local loopback listener and manages the tokens. Connecting does not start a scan.
codex mcp add apex --url https://ai.cantina.xyz/mcp
codex mcp login apex
codex mcp get apexAlternatively, configure an existing organization service key using an environment variable. Use only one of these configurations for the apex server entry.
Keep service keys out of config files
Use an environment variable or a secret manager for unattended integrations. Do not paste the raw key into committed configuration, command history, logs, or prompts.
export APEX_SERVICE_KEY='<service-key>'
codex mcp add apex --url https://ai.cantina.xyz/mcp --bearer-token-env-var APEX_SERVICE_KEY
codex mcp get apexClaude Code
Add a user- or project-scoped .mcp.json entry. Claude Code expands ${APEX_SERVICE_KEY} from the process environment when it connects.
{
"mcpServers": {
"apex": {
"type": "http",
"url": "https://ai.cantina.xyz/mcp",
"headers": {
"Authorization": "Bearer ${APEX_SERVICE_KEY}"
}
}
}
}Service-key clients use the same URL and Bearer header. A successful connection advertises apex-credits, apex-workspaces, apex-scan, apex-black-prepare, apex-black-start, apex-status, apex-cancel-scan, apex-findings, apex-workspace-findings, apex-finding-tickets, apex-finding-link-ticket, apex-finding-send, and apex-finding-status. Tool calls enforce the same scopes, organization isolation, rate limits, usage attribution, and idempotency rules as the REST API. When a REST operation supplies a Retry-After header, the MCP tool error preserves it as retryAfter and a normalized retryAfterSeconds value in structured content.
Run an Apex Black scan
Existing Apex customers with Apex Black beta enabled can run active web security scans through apex-black-start. The connection needs scans:create; include scans:read and findings:read to follow the scan and read its results. Each new scan costs 200 Standard credits. Use apex-black-prepare with the exact targets to check access and credits before starting. It returns readiness, the cost, and support or billing links for missing prerequisites, without creating a workspace or reserving credits. Older deployments can use apex-credits to check the balance; scan creation checks the current entitlement and balance again. Retain the returned organization ID as expectedCompanyId for starts and replays. When the discovered input schema advertises this field, OAuth starts require it and a changed connection is rejected before launch. Older servers omit the field; keep the original ID locally and verify the same organization after reconnecting. The connected deployment must advertise apex-black-start; installing the plugin alone does not enable the tool or the beta.
Supply targetUrls with 1–16 exact HTTPS origins, such as https://app.example.com. Credentials, paths, query strings, and fragments are rejected. The listed origins define the target scope. Add testing instructions and exclusions in optional scopeText (up to 100,000 characters), application context in threatModelContext (up to 4,000 characters), and a workspaceName (up to 242 characters, leaving room for the Apex Black prefix) if desired. Set authorizationAccepted: true when authority to test every target and acceptance of the credit cost are present. Use authorization already given in the session without asking again.
{
"idempotencyKey": "black-customer-portal-2026-09-30",
"expectedCompanyId": "11111111-1111-4111-8111-111111111111",
"targetUrls": ["https://app.example.com", "https://api.example.com"],
"scopeText": "Exclude billing and destructive actions.",
"workspaceName": "Customer portal",
"authorizationAccepted": true
}The result includes a durable scanId, workspaceId, status, statusUrl, findingsUrl, creditCost: 200, scanType: "standard", and operationType: "apex_black". The scan type matches the shared inventory; the operation type identifies the web scan. Keep the IDs and use apex-status to check progress, then apex-findings after completion. Reuse the same idempotencyKey and identical input after transport or retryable errors, respecting retryAfterSeconds. An identical retry returns the same scan without another charge; changed input with the same key returns 409. A stored remote-start failure, including APEX_BLACK_START_FAILED, is replayed without launching again. Keep the returned request and workspace IDs and contact support before attempting another request.
In the example, replace expectedCompanyId with the organization ID returned by preparation or credits. REST clients send the same body without the MCP-only expectedCompanyId and idempotencyKeyto POST /api/apex/v1/apex-black/scans, passing the key in the Idempotency-Key header. The endpoint returns 202 and the same scan result. The existing scan status and findings endpoints read Apex Black results. See the OpenAPI specification for the request, response, and error schemas.
Close and reopen findings
Use apex-finding-status to close a resolved issue with status: "fixed" or reopen it with status: "open". This records your remediation decision and an audit entry while preserving the scanner verdict, severity, and validity feedback.
Hosted MCP takes findingId (the finding UUID), status, and an optional note. Your connection must explicitly grant findings:write. Read-only keys cannot change statuses.
{
"findingId": "<finding-uuid>",
"status": "fixed",
"note": "Resolved in PR #123"
}curl -X PATCH "https://ai.cantina.xyz/api/apex/v1/findings/$FINDING_ID/status" \
-H "Authorization: Bearer $APEX_SERVICE_KEY" -H "Content-Type: application/json" \
-d '{"status":"fixed","note":"Resolved in PR #123"}'Other statuses are acknowledged, in_review, pending_merge, false_positive, wont_fix, and skipped. Reopening retains the reviewed state as accepted. Invalid or duplicate findings require a validity or duplicate-classification review before they can enter the open queue.
Include the finding's updatedAt as expectedUpdatedAt(CLI: --expected-updated-at) to prevent overwriting a newer edit. A conflict returns FINDING_STATUS_CONFLICT; reload and review the latest state before retrying. The response includes the saved reviewState, updatedAt, and changed. Workspace finding lists include remediationStatus so open and acknowledged remain distinct on subsequent reads.
Linear sync currently runs from Linear to Apex: started tickets become acknowledged and completed tickets become fixed. Changing a finding here does not update its Linear or Jira ticket. Use the direct status operation when managing remediation from an MCP client or API integration.
Send findings to Linear or Jira
Create a ticket from a finding with the same title, severity, details, and Apex link as the frontend. Connect the integration and configure its destination in Apex first. Sending uses saved Linear workspace project and labels (or the company default), or the workspace Jira site, project, and issue type.
Call apex-finding-send with findingId (UUID) and provider (linear or jira). The service key must explicitly include findings:write.
REST clients call POST /api/apex/v1/findings/{findingId}/issue-trackers/{provider}with no request body. The response contains created and export, including the ticket's issueKey and issueUrl.
Already sent or linked findings return their existing ticket with created: false; an active export returns a conflict. After an ambiguous timeout or provider failure, inspect the linked tickets and the provider before retrying. Sending is never automatically retried.
Link existing tickets
After connecting Linear or Jira in company settings, call apex-finding-link-ticket with findingId (the finding UUID), provider (linear or jira), and issueRef (ticket key or HTTPS issue URL). The key needs explicit findings:write scope. Call apex-finding-ticketswith findingId to list links using findings:read.
apex service-key create --name "Ticket linking" --scope findings:read,findings:writeREST clients use GET /api/apex/v1/findings/{findingId}/ticketsand POST /api/apex/v1/findings/{findingId}/tickets. POST accepts {"provider":"linear","issueRef":"SEC-123"}and returns linked plus a ticket containing provider, issueId, issueKey, and issueUrl. A repeated link returns linked: false. Apex verifies access through the company integration without creating or modifying a provider ticket. A different existing link or an in-progress export returns 409. Use a full Jira issue URL when the integration can access several sites and no destination is configured.
Hosted and local MCP have different jobs
Hosted MCP operates on repositories already connected to the connection's Apex organization and scans an exact full commit SHA, or runs Apex Black against authorized HTTPS origins. Use local apex-mcp when a tool needs the caller's filesystem, local archive preparation, device login, provider connection, or setup workflows.
14Claude and Codex prompts
These prompts work in Claude Code or Codex. The first two keep key creation in a user-controlled Settings or terminal flow; the others use either the hosted MCP endpoint or the REST API for a headless runtime instead of launching a local stdio process there.