GraphQL API
One endpoint serves the same records the portals use: organizations, engagements, exercises, injects, gaps, decisions, after-action reports, deliverables, readiness scores, certificates, and the scoring and compliance engines exposed as queries.
Endpoint
POST https://afteraction.dev/api/graphql- POST runs queries and mutations. Send
{ query, variables, operationName }as JSON. - GET accepts
?query=for query operations only. A mutation over GET is refused with HTTP 405. - Schema introspection is turned off in production, so use this page and your own typed queries rather than an introspecting client.
- Enum values are UPPER_CASE (
COMPLETED,IN_PROGRESS). Field names are snake_case, matching the stored records.
Authentication
Every field except health requires an authenticated caller. There are two ways in.
Signed-in session (cookie)
Requests from a browser signed in to After Action are authenticated by the session cookie and see exactly what that user sees in the portal. Because a cookie is sent automatically, cookie-authenticated POSTs must:
- use
Content-Type: application/json(anything else is refused with HTTP 415), and - come from an allowed origin — the
Origin/Referercheck rejects cross-site requests with HTTP 403.
Mutations are never accepted over GET, so a link cannot trigger one.
API key
Send the key in the X-API-Key header. GraphQL reads that header only (not Authorization). The key must carry the graphql scope; a valid key without it gets a FORBIDDEN error on every field.
- Keys are scoped to the organization that created them and act with that key's role.
- Only a hash of the key is stored. The plaintext is shown once, when it is created.
- A key can carry an expiry date. Expired, revoked and unknown keys are treated identically.
- Revocation takes effect on the next request; keys are not cached.
- The
graphqlscope can only be granted to keys created by After Action staff accounts. Carrier partner keys cover the narrower carrier data feed and the MCP server. If you need programmatic GraphQL access, contact us.
Rate limits
Mutations are limited per caller and per mutation field over a one-minute window. Limits are enforced by each application instance.
| Operation | Limit |
|---|---|
| Standard mutations | 30 per minute |
generateDeliverable, computeAndSaveReadiness | 5 per minute |
A limited call returns a RATE_LIMITED error with extensions.retryAfter in seconds.
Example query
Completed exercises with their gaps and readiness score:
query CompletedExercises {
exercises(status: COMPLETED) {
id
title
exercise_date
severity
gaps {
title
severity
status
category
}
readiness_score {
overall_score
scored_at
}
}
}curl -s https://afteraction.dev/api/graphql \
-H "Content-Type: application/json" \
-H "X-API-Key: $AFTER_ACTION_API_KEY" \
-d '{"query":"{ exercises(status: COMPLETED) { id title readiness_score { overall_score } } }"}'health needs no authentication and is a quick way to check connectivity:
curl -s https://afteraction.dev/api/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{ health { status timestamp version } }"}'Errors
GraphQL errors are returned with HTTP 200 in the standard errors array. Fields that failed are null in data; other fields still resolve.
{
"data": null,
"errors": [
{
"message": "Authentication required",
"path": ["exercises"],
"extensions": { "code": "UNAUTHENTICATED" }
}
]
}extensions.code | Meaning |
|---|---|
UNAUTHENTICATED | No valid session or API key. |
FORBIDDEN | Authenticated, but the role or key scope does not allow this field. |
RATE_LIMITED | Too many calls to this mutation; retry after retryAfter seconds. |
Unexpected server errors are reported as Internal server error without internal detail. Transport-level problems use HTTP status codes: 400 for an unparseable document, 403 for a cross-origin cookie request, 405 for a mutation over GET, 415 for a cookie request that is not JSON.
Related
- Authentication and roles
- MCP server — read-only access for AI agents
- Platform status