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

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 / Referer check 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 graphql scope 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.

OperationLimit
Standard mutations30 per minute
generateDeliverable, computeAndSaveReadiness5 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:

GraphQL query
query CompletedExercises {
  exercises(status: COMPLETED) {
    id
    title
    exercise_date
    severity
    gaps {
      title
      severity
      status
      category
    }
    readiness_score {
      overall_score
      scored_at
    }
  }
}
curl with an API key
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:

Health check
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.

Error response
{
  "data": null,
  "errors": [
    {
      "message": "Authentication required",
      "path": ["exercises"],
      "extensions": { "code": "UNAUTHENTICATED" }
    }
  ]
}
extensions.codeMeaning
UNAUTHENTICATEDNo valid session or API key.
FORBIDDENAuthenticated, but the role or key scope does not allow this field.
RATE_LIMITEDToo 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