Skip to main content

Programmatic Access Guide

This guide walks you through using ProjectAchilles API keys to read and act on data from external applications — dashboards, CI pipelines, SIEM forwarders, monitoring scripts, anything that isn't a browser session.

For an endpoint catalog see the per-module references (Analytics, Agent Admin, Defender, etc.). This page is the how-to-actually-use-them complement.

Reference vs. guide
  • The per-module pages answer "what does this endpoint do?" — use them as a lookup.
  • This page answers "how do I build X using these endpoints?" — read it top-to-bottom the first time, then jump to the cookbook for recipes.

Prerequisites

  • A user account with the admin role (only admins can create API keys).
  • The ability to reach your deployment's backend (see Two-host architecture).
  • curl and jq for the examples below. The patterns transfer to any HTTP client.

1. Create a key

  1. Sign in to your ProjectAchilles deployment as an admin.
  2. Open Settings → API Keys.
  3. Click Generate, give it a descriptive name (e.g. splunk-exporter, ci-gate), and choose a scope:
    • read — all *:read permissions. Read analytics, executions, agents, tasks, schedules, test library. Cannot mutate anything. Pick this by default — least privilege.
    • read-write — operator-equivalent. Can create builds, dispatch tasks, manage schedules. No destructive actions, no user or cert management.
  4. Copy the key immediately. The full plaintext (a pa_… string) is shown exactly once. You cannot retrieve it again — only its short prefix.

API keys cannot create or revoke other API keys — that requires a human admin via the UI.

2. Two-host architecture (important)

ProjectAchilles deployments use two subdomains per tenant:

SubdomainPurposeUse for API calls?
<tenant>.projectachilles.ioSingle-page app (the dashboard you sign into)No
<tenant>.agent.projectachilles.ioBackend API (Express)Yes

The SPA reads VITE_API_URL from /env-config.js at runtime and calls the backend directly via CORS — it never proxies through itself. If you point your client at the SPA host's /api/* path, you may hit a vestigial nginx config that returns 502.

If you don't know the backend URL for your deployment, fetch it from the SPA:

curl -s https://<tenant>.projectachilles.io/env-config.js
# returns: window.__env__ = { VITE_API_URL: "https://<tenant>.agent.projectachilles.io", ... }

3. Set up your shell

Save the key and base URL once per session:

export PA_KEY='pa_…'                                          # the full key shown at creation
export BACKEND='https://<tenant>.agent.projectachilles.io/api' # backend host, not SPA host

All examples below assume these variables are set.

4. Smoke test

A trivial read to confirm the key works and the backend is reachable:

curl -s -H "Authorization: Bearer $PA_KEY" "$BACKEND/analytics/defense-score" | jq

Expected: a JSON object with score, protectedCount, etc. If you see score: 0 with totalExecutions: 0, see §5 — the time-window default below.

If the call hangs or returns HTTP 502, your $BACKEND is probably pointing at the SPA host — re-check §2.

5. The time-window default (very common gotcha)

Every /api/analytics/* endpoint defaults to the last 7 days when no from / to is specified. The dashboard UI defaults to Last 30 days. Same data, two different windows — so a quiet 7-day stretch can produce zero rows in the API while the UI still shows healthy numbers.

Always pass from (and optionally to) explicitly. Three accepted formats:

# 1. Date math (relative to now — convenient for cron jobs)
"$BACKEND/analytics/defense-score?from=now-30d"

# 2. Absolute ISO timestamp (reproducible across multiple calls)
FROM=$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ)
TO=$(date -u +%Y-%m-%dT%H:%M:%SZ)
"$BACKEND/analytics/defense-score?from=$FROM&to=$TO"

# 3. Calendar dates
"$BACKEND/analytics/defense-score?from=2026-04-01&to=2026-05-01"

6. Defense Score — what the fields mean

The defense-score response carries three score variants. Each answers a different question:

FieldNumeratorRisk-acceptanceUse it for
scoreEDR-protected OR Defender-detectedExcludedDay-to-day dashboard headline
realScoreEDR-protected onlyExcludedAlerting on real EDR coverage regressions
rawScoreEDR-protected OR Defender-detectedNot excludedAuditing for risk-acceptance creep

Companion counts:

{
"score": 54.59, // headline
"realScore": 53.21, // EDR-only, risk-adjusted
"rawScore": 53.83, // combined, no risk adjustment
"protectedCount": 1391, // caught by EDR (strict)
"detectedCount": 36, // ONLY Defender caught it (EDR missed)
"unprotectedCount": 1187, // neither
"totalExecutions": 2614, // = protected + detected + unprotected
"riskAcceptedCount": 37 // explicitly accepted; excluded from totalExecutions
}

Two useful derived metrics:

  • score − realScore = your "Defender lifeline" gap. If this is large, a meaningful chunk of your coverage relies on Defender catching what EDR missed. If Defender ingestion ever breaks, your operational reality is closer to realScore.
  • score − rawScore = your "risk-acceptance creep" gap. A growing gap over time means more results are being dismissed via Accept Risk. Worth a periodic audit.

Recommended alerting: page on realScore drops (no masking from either Defender or risk-acceptance — purest signal). Show score on dashboards. Watch rawScore drift as a hygiene metric.

7. Reading test executions

The platform offers two different shapes for test results, and choosing the right one matters:

ShapeEndpointHasLacks
Enriched (analytics)/api/analytics/executions*is_protected, MITRE techniques, severity, score, Defender flagsstdout, stderr, timing, binary hash, OS, arch
Raw (admin)/api/agent/admin/tasks*stdout, stderr, execution_duration_ms, binary_sha256, os, archAll catalog/MITRE enrichment, scoring

Recent enriched executions

curl -s -H "Authorization: Bearer $PA_KEY" \
"$BACKEND/analytics/executions?from=now-7d&limit=10" \
| jq '.[] | {test_name, hostname, is_protected, timestamp}'

Paginated enriched executions with filters

/executions/paginated accepts a long filter vocabulary (see Analytics for the full list).

Two response shapes

This endpoint returns different shapes depending on the grouped parameter:

  • Default (no grouped, or grouped=false) → {"data": [...], "pagination": {...}} — one row per ES document. Bundle tests appear as multiple rows (one per control). Iterate with .data[].
  • ?grouped=true{"groups": [...], "pagination": {...}} — bundle-aware, one row per "run" with a representative + members[]. Iterate with .groups[].

The examples below use the default flat shape. Append &grouped=true and switch the jq paths if you want the bundle-aware view.

# All runs of one specific test in the last 30 days (flat, one row per execution)
TEST_UUID='paste-from-/analytics/executed-test-uuids'
curl -s -H "Authorization: Bearer $PA_KEY" \
"$BACKEND/analytics/executions/paginated?tests=$TEST_UUID&from=now-30d&pageSize=25" \
| jq '.data[] | {hostname, test_name, is_protected, timestamp, error_code}'

# Same query but grouped (bundle parent + members), good for dashboards
curl -s -H "Authorization: Bearer $PA_KEY" \
"$BACKEND/analytics/executions/paginated?tests=$TEST_UUID&from=now-30d&pageSize=25&grouped=true" \
| jq '.groups[] | {host: .representative.hostname, protected: .protectedCount, unprotected: .unprotectedCount, total: .totalCount}'

# Only unprotected results of a specific MITRE technique (flat)
curl -s -H "Authorization: Bearer $PA_KEY" \
"$BACKEND/analytics/executions/paginated?techniques=T1486&result=unprotected&from=now-30d" \
| jq '.data[]'

# Critical-severity tests on a specific host (flat)
curl -s -H "Authorization: Bearer $PA_KEY" \
"$BACKEND/analytics/executions/paginated?hostnames=workstation-01&severities=critical&from=now-30d" \
| jq '.data[]'

Raw task results (stdout, stderr, timing)

Different endpoint, different shape. The agent admin path returns the agent's verbatim TaskResult from SQLite:

curl -s -H "Authorization: Bearer $PA_KEY" "$BACKEND/agent/admin/tasks?limit=5" \
| jq '.data.tasks[] | {
id,
host: .agent_hostname,
status,
exit_code: .result.exit_code,
duration_ms: .result.execution_duration_ms,
stdout_preview: (.result.stdout // "")[0:200]
}'

Use this path when you need execution-level forensic data — what the binary printed, how long it took, which exact binary ran (verifiable by SHA-256).

8. Reading the agent fleet

# All enrolled agents with current status
curl -s -H "Authorization: Bearer $PA_KEY" "$BACKEND/agent/admin/agents" \
| jq '.data[] | {id, hostname, status, last_heartbeat_at, os, version}'

# Just the agents that haven't heartbeated in 24h
curl -s -H "Authorization: Bearer $PA_KEY" "$BACKEND/agent/admin/agents" \
| jq --arg cutoff "$(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ)" \
'.data[] | select(.last_heartbeat_at < $cutoff) | {hostname, last_heartbeat_at}'

Cookbook

Recipe — CI gate that fails on Defense Score regression

Fail a build if realScore dropped more than a configured threshold versus the prior 7 days:

#!/usr/bin/env bash
# defense-score-gate.sh
set -euo pipefail

: "${PA_KEY:?set PA_KEY}"
: "${BACKEND:?set BACKEND}"
THRESHOLD_PP="${THRESHOLD_PP:-5}" # percentage-point drop that fails the build

PREV=$(curl -fsS -H "Authorization: Bearer $PA_KEY" \
"$BACKEND/analytics/defense-score?from=now-14d&to=now-7d" | jq -r '.realScore')
NOW=$(curl -fsS -H "Authorization: Bearer $PA_KEY" \
"$BACKEND/analytics/defense-score?from=now-7d" | jq -r '.realScore')

DROP=$(echo "$PREV - $NOW" | bc -l)
echo "realScore previous=$PREV current=$NOW drop=${DROP}pp threshold=${THRESHOLD_PP}pp"

if (( $(echo "$DROP > $THRESHOLD_PP" | bc -l) )); then
echo "::error::Defense Score (real) dropped ${DROP}pp — exceeds threshold ${THRESHOLD_PP}pp"
exit 1
fi

Drop this into a GitHub Actions step:

- name: ProjectAchilles defense-score gate
env:
PA_KEY: ${{ secrets.PROJECTACHILLES_API_KEY }}
BACKEND: https://<tenant>.agent.projectachilles.io/api
THRESHOLD_PP: 5
run: ./scripts/defense-score-gate.sh

Recipe — SIEM exporter (paginate and forward)

Pull all enriched executions for the last hour and forward as NDJSON. Pattern works for Splunk HEC, Elastic ingest pipelines, anything that consumes JSON-per-line:

#!/usr/bin/env bash
# export-executions-ndjson.sh — emit recent results to stdout as NDJSON
set -euo pipefail

: "${PA_KEY:?}"
: "${BACKEND:?}"
FROM=$(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%SZ)

page=1
while :; do
body=$(curl -fsS -H "Authorization: Bearer $PA_KEY" \
"$BACKEND/analytics/executions/paginated?from=$FROM&page=$page&pageSize=100")
# Flat shape: one EnrichedTestExecution per .data[] entry — emit as NDJSON.
echo "$body" | jq -c '.data[]'
has_next=$(echo "$body" | jq -r '.pagination.hasNext')
[ "$has_next" = "true" ] || break
page=$((page + 1))
done

Run on a cron, pipe to curl against your HEC endpoint or eventgen etc.

Recipe — Risk-acceptance creep watcher

Page when score − rawScore exceeds a threshold for too many days in a row:

CREEP=$(curl -fsS -H "Authorization: Bearer $PA_KEY" \
"$BACKEND/analytics/defense-score?from=now-7d" \
| jq '.score - .rawScore')
echo "current creep: ${CREEP}pp"
# Alert if > 2pp — too many results are being hand-waved via Accept Risk.

Recipe — Full per-host drill-down

"Show me every security test that's run on this host and what happened" is the most common operational question. The workflow uses two endpoints because the two stores hold different shapes:

  • /api/analytics/* — Elasticsearch-backed, filter by hostname (hostnames=), gives enrichment (MITRE techniques, severity, score, Defender detection).
  • /api/agent/admin/tasks — SQLite-backed, filter by agent UUID (agent_id=), gives raw TaskResult (stdout/stderr/timing/binary hash).

You'll usually want both. Here's a top-to-bottom workflow.

1. Identify the host

curl -s -H "Authorization: Bearer $PA_KEY" "$BACKEND/agent/admin/agents" \
| jq '.data[] | {id, hostname, status, os, version, last_heartbeat_at}'

Pick the row you want and save both identifiers:

export HOST='workstation-01'           # for /analytics/* filters
export AGENT_ID='paste-the-uuid-here' # for /agent/admin/tasks

2. Per-test pass/fail summary

One row per test, restricted to this host, with protected vs. unprotected counts. The cleanest high-level view of what the host's protection looks like:

curl -s -H "Authorization: Bearer $PA_KEY" \
"$BACKEND/analytics/defense-score/by-test?hostnames=$HOST&from=now-30d" \
| jq -r '.[] | [.test_name, .severity, .protectedCount, .unprotectedCount] | @tsv' \
| column -t -s $'\t'

3. Every individual run with full enrichment

/executions/paginated is the detail view — every run with its MITRE techniques, tactics, severity, error code, and Defender flag. The default flat shape returns one row per ES document; append &grouped=true for the bundle-aware view (see §7.b for the shape comparison).

curl -s -H "Authorization: Bearer $PA_KEY" \
"$BACKEND/analytics/executions/paginated?hostnames=$HOST&from=now-30d&pageSize=100" \
| jq '.data[] | {
test_name,
hostname,
is_protected,
severity,
techniques,
tactics,
timestamp,
error_code,
error_name,
bundle_name,
control_id,
defender_detected
}'

4. Just the failures

curl -s -H "Authorization: Bearer $PA_KEY" \
"$BACKEND/analytics/executions/paginated?hostnames=$HOST&result=unprotected&from=now-30d&pageSize=100" \
| jq -r '.data[] | [
.severity,
.test_name,
(.techniques // [] | join(",")),
.timestamp
] | @tsv' \
| column -t -s $'\t'

5. Critical-only triage

When a host has hundreds of test runs, start with what actually matters:

curl -s -H "Authorization: Bearer $PA_KEY" \
"$BACKEND/analytics/executions/paginated?hostnames=$HOST&severities=critical,high&result=unprotected&from=now-30d&pageSize=100" \
| jq '.data[] | {test_name, severity, techniques, timestamp, control_id}'

6. Filter by MITRE technique

For chasing a specific attack pattern across the host's history (e.g. T1486 = Data Encrypted for Impact):

curl -s -H "Authorization: Bearer $PA_KEY" \
"$BACKEND/analytics/executions/paginated?hostnames=$HOST&techniques=T1486&from=now-30d" \
| jq '.data[] | {test_name, is_protected, defender_detected, timestamp, error_name}'

7. Forensic detail — what the binary actually did

The analytics view drops stdout / stderr / timing at ingestion. To see them, switch to the admin tasks path:

# Recent tasks on this host with a stderr preview
curl -s -H "Authorization: Bearer $PA_KEY" \
"$BACKEND/agent/admin/tasks?agent_id=$AGENT_ID&limit=50" \
| jq '.data.tasks[] | {
id,
test_name: .payload.test_name,
status,
created_at,
completed_at,
exit_code: .result.exit_code,
duration_ms: .result.execution_duration_ms,
stderr_preview: (.result.stderr // "")[0:300]
}'

# Full TaskResult for one specific run (paste the task id from above)
TASK_ID='...'
curl -s -H "Authorization: Bearer $PA_KEY" "$BACKEND/agent/admin/tasks/$TASK_ID" | jq
Exit code conventions
  • 100protected: a defender (EDR or otherwise) blocked the malicious action.
  • 101unprotected: the test successfully performed the malicious action.
  • Anything else — inconclusive: configuration error, missing dependency, validator itself was blocked. The analytics endpoints' result=unprotected filter treats only exit code 101 as a real failure; the admin tasks path returns every exit code as-is.
Bundle tests

Bundle tests (cyber-hygiene packs, multi-stage intel-driven tests) fan out into multiple ES documents — one per control or stage, each with is_bundle_control: true and its own control_id. With the default flat shape, each control appears as its own row in .data[] — perfect for per-control inspection. Append &grouped=true to collapse them: each bundle run becomes one groups[] entry with a representative (one control) and members[] (all controls of that run). Switch between the two depending on whether you want per-control rows or per-run rollups.

8. One-shot: full host report as a single JSON file

For after-action reviews or handing off to a SIEM ingestion pipeline:

{
echo '{"host":"'$HOST'","generated_at":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","by_test":'
curl -s -H "Authorization: Bearer $PA_KEY" \
"$BACKEND/analytics/defense-score/by-test?hostnames=$HOST&from=now-30d"
echo ',"executions":'
curl -s -H "Authorization: Bearer $PA_KEY" \
"$BACKEND/analytics/executions/paginated?hostnames=$HOST&from=now-30d&pageSize=500"
echo ',"raw_tasks":'
curl -s -H "Authorization: Bearer $PA_KEY" \
"$BACKEND/agent/admin/tasks?agent_id=$AGENT_ID&limit=500"
echo '}'
} | jq > "host-$HOST-report-$(date +%Y%m%d).json"

One file containing per-test summary, every individual run with enrichment, and the raw TaskResult (with stdout/stderr) for each task. Drop it into an evidence locker, attach it to a ticket, or feed it to a SIEM.

Rate limits

SurfaceLimitNotes
Bearer pa_… authentication60 attempts / minute per IPCounts every request bearing such a header, even unauthenticated probes
Global /api/*1000 / 15 minutes per IPApplies after auth attaches
Agent device endpoints (/api/agent/*)Separate (per-agent) limiterNot relevant for API keys

A naive polling script at 1 req/s sits right at the API-key auth ceiling. Drop to one request every 2–3 seconds for safety, or batch with pageSize= to fetch more per call.

Error handling

CodeMeaningCommon cause
200Success
201CreatedPOST to /api/api-keys (admin-only)
400Bad requestInvalid query parameter or body schema
401UnauthenticatedMissing / malformed / revoked / expired key
403ForbiddenKey valid but lacks the required permission for this endpoint (e.g. read key trying to POST)
429Rate-limitedEither auth or global limiter; back off
502Bad gatewayBackend reachable through wrong host (see §2) or transient infra
503Service unavailableBackend healthy but a downstream (e.g. Elasticsearch) isn't configured or reachable

Response envelope for failures:

{ "success": false, "error": "human-readable message" }

Note: a bad / unknown / revoked API key returns 401 without revealing which of those it was — the response body is identical for all four cases. This is intentional, to prevent leaking whether a particular key value existed.

Revocation

Revoke from Settings → API Keys → Revoke. The next request bearing the key gets a 401 — there is no cache to invalidate.

# After revocation in the UI, this prints 401:
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer $PA_KEY" "$BACKEND/analytics/defense-score"

Security best practices

  • Pick read scope unless you genuinely need writes. Most automations need only reads.
  • Use a distinct name per consumer. "Splunk forwarder (prod)", "CI defense-gate", etc. Makes the last_used_at audit column meaningful.
  • Never commit a key. The pa_ prefix is intentionally chosen to be greppable in git history scanners — but the right answer is not to commit in the first place. Store in a secret manager (GitHub Actions secrets, Vercel env vars, HashiCorp Vault, AWS Secrets Manager, etc.).
  • Rotate keys when staff churn. A key outlives its creator's tenure — revoke and regenerate when team members leave or roles change.
  • Each deployment has its own keys. A key created against one environment will not authenticate against another — by design. Don't reuse keys across environments.
  • TLS-only. Keys are sent in plaintext on every request. Never call the API over plain HTTP.

See also