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
-
Send both headers on every call.
Header Value 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
Authorizationform is ignored under/api/v1. -
Optionally send
X-Request-Id, 1 to 128 characters fromA-Z a-z 0-9 . _ - :. It is echoed and recorded; anything else, or none, gets a generated id. Quote it to support. -
On
POST /workflows/{workflow_id}/runs, also sendIdempotency-Key. It is required there and nowhere else; see Start a run. -
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" -
Know which version you are calling. It is in the path,
/api/v1. The contract date isapi_versiononGET /health(2026-09-24today) andinfo.versionin the OpenAPI file. There is no version header. -
In Python, put the two headers on a session and reuse it:
import osimport requestsBASE = 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:
| Order | Result | When |
|---|---|---|
| 1 | 503 deployment_updating | Not governed (details.reason: "not_governed"), no key yet (not_enabled), or the key store cannot be read (keys_table_unreachable). Retry-After: 60 |
| 2 | 401 unauthorized | Bearer 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 |
| 3 | 403 forbidden | Source address outside the allow-list. "This key may not perform this request." |
| 3 | 403 outside_time_window | Current 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} |
| 4 | 429 rate_limited | The per-minute limit is full. Retry-After counts to the next minute |
| 5 | 403 forbidden | The 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 fromA-Z a-z 0-9 _ -, separated from the key id by the first dot; key external id 8 to 256 characters fromA-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
401for every call, compare both values with what was stored at the reveal, the key's status and deployment in the console, anddeployment_idonGET /health. A401from a known time means a rotation overlap ended or a new external id was issued. - Browser callers.
/api/v1accepts calls from a page on any origin: the preflightOPTIONSis answered before the credentials are checked, with the requestingOriginreflected inAccess-Control-Allow-Origin,Access-Control-Allow-Credentials: true, every method and the requested headers allowed andAccess-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.
Related
- API keys: what the key carries; rotation and revocation.
- Run a workflow from your system: the routes and their scopes.
- Limits, errors and codes: the error envelope and every code.