Skip to main content

Authentication

Every call except GET /health carries two credentials: the API key as a bearer token and the key external id as a header. Read this when you write the caller.

Steps​

  1. Send both headers on every call.

    HeaderValue
    AuthorizationBearer ak_<key_id>.<secret>
    X-AlphaAgent-Key-External-Idthe key external id

    A call with only one is refused. A Studio sign-in, a cookie or any other Authorization form is ignored under /api/v1.

  2. Optionally send X-Request-Id, 1 to 128 characters from A-Z a-z 0-9 . _ - :. It is echoed and recorded; anything else, or none, gets a generated id. Quote it to support.

  3. On POST /workflows/{workflow_id}/runs, also send Idempotency-Key. It is required there and nowhere else; see Start a run.

  4. Confirm the credentials with GET /me. Any valid key answers; no scope is needed.

    export STUDIO="<app_domain>"
    export KEY="ak_<key_id>.<secret>"
    export EXT="ake_<external_id>"

    curl -H "Authorization: Bearer $KEY" \
    -H "X-AlphaAgent-Key-External-Id: $EXT" \
    "https://$STUDIO/api/v1/me"
  5. Know which version you are calling. It is in the path, /api/v1. The contract date is api_version on GET /health (2026-09-24 today) and info.version in the OpenAPI file. There is no version header.

  6. In Python, put the two headers on a session and reuse it:

    import os
    import requests

    BASE = f"https://{os.environ['STUDIO']}/api/v1"
    session = requests.Session()
    session.headers.update({
    "Authorization": f"Bearer {os.environ['KEY']}",
    "X-AlphaAgent-Key-External-Id": os.environ["EXT"],
    })

    me = session.get(f"{BASE}/me", timeout=30)
    me.raise_for_status()
    print(me.json()["scopes"], me.json()["workflow_access"])

How a request is checked​

Checks run in this order and stop at the first failure:

OrderResultWhen
1503 deployment_updatingNot governed (details.reason: "not_governed"), no key yet (not_enabled), or the key store cannot be read (keys_table_unreachable). Retry-After: 60
2401 unauthorizedBearer or external id missing or malformed, key id unknown, secret or external id wrong, key revoked or expired, or bound to another deployment. Always "The API key or key external id is missing, invalid, revoked, expired or not bound to this deployment.", with WWW-Authenticate: Bearer
3403 forbiddenSource address outside the allow-list. "This key may not perform this request."
3403 outside_time_windowCurrent UTC hour outside the allowed hours. "This key may only be used inside its allowed hours (UTC)."; details.valid_hours_utc is {"start_hour": h, "end_hour": h}
4429 rate_limitedThe per-minute limit is full. Retry-After counts to the next minute
5403 forbiddenThe key lacks the route's scope. Same message as the address check

A 401 never says which part was wrong, so key ids cannot be discovered. A 429 comes before the scope check, so a forbidden call still counts against the rate limit. Two refusals are decided before any of these, on the body alone: a request body over 1 MiB answers 413 payload_too_large, and a body that is not valid JSON answers 400 invalid_request, whatever credentials the request carries.

The address checked is the one the deployment's load balancer saw; X-Forwarded-For from your own proxies is not trusted, and behind the CloudFront front door it is the viewer address CloudFront recorded. With an allow-list, an address that cannot be parsed is refused. The window is evaluated on the UTC hour, inside when start_hour <= hour < end_hour; an end before the start wraps midnight. The scope each route needs is in the route table; GET /me needs none.

What you should see​

GET /me returns the key as the deployment knows it:

{
"key_id": "ak_<key_id>",
"name": "<name>",
"deployment_id": "<deployment_id>",
"scopes": ["outputs:read", "runs:create", "runs:read", "workflows:read"],
"workflow_access": {"mode": "all_active", "workflow_ids": []},
"expires_at": "2026-12-26T09:14:02+00:00",
"rate_limit_rpm": 60,
"daily_run_quota": 100,
"quota_used_today": 3,
"max_concurrent_runs": 10,
"active_runs": 1
}

workflow_access.mode is all_active or explicit (the listed workflow_ids); quota_used_today and active_runs are live counters.

Notes​

  • Formats: key id ak_ and 6 to 64 letters or digits; secret 16 to 256 characters from A-Z a-z 0-9 _ -, separated from the key id by the first dot; key external id 8 to 256 characters from A-Z a-z 0-9 _ -.
  • Rejections after the key is identified are recorded against it in the administrator's Usage view; rejections before (missing, malformed or unknown credentials) are logged in the deployment only.
  • On 401 for every call, compare both values with what was stored at the reveal, the key's status and deployment in the console, and deployment_id on GET /health. A 401 from a known time means a rotation overlap ended or a new external id was issued.
  • Browser callers. /api/v1 accepts calls from a page on any origin: the preflight OPTIONS is answered before the credentials are checked, with the requesting Origin reflected in Access-Control-Allow-Origin, Access-Control-Allow-Credentials: true, every method and the requested headers allowed and Access-Control-Max-Age: 600; the response to the call itself carries the same origin and credentials headers. The key is a bearer secret and belongs on a server, never in a browser.