API reference
Sign-in and session
Sign in with an emailed code, receive a session cookie, see who you are, sign out. The cookie drives the console and key management; agents use a key instead.
POST /v1/auth/request-otp
Request a sign-in code
Emails a six digit code to the address; the account is created on first successful sign-in.
The code works once and expires after ten minutes. Five wrong attempts lock the pending code.
When the agreement gate is enforced, the body may carry legal_attestations keyed by document id. GET /v1/legal/documents lists what is required and where to read it.
Parameters
None.
Request body
emailstring required- No description in the schema.
legal_attestationsobject of LegalAttestation or null- No description in the schema.
LegalAttestation fields
acceptedboolean- Default
false. content_hashstring- Default
"". versionstring- Default
"".
Example request
curl
curl -sS -X POST "https://api.afaprotocol.com/v1/auth/request-otp" \
-H "Content-Type: application/json" \
-d '{
"email": "ops@example.com"
}'
Python
import requests
API = "https://api.afaprotocol.com"
payload = {
"email": "ops@example.com"
}
r = requests.post(f"{API}/v1/auth/request-otp", json=payload, timeout=30)
r.raise_for_status()
print(r.json())
Example response
HTTP 200
{
"expires_in_minutes": 10,
"status": "sent"
}
Errors
| Status | Code | Meaning |
|---|---|---|
| 422 | validation error | A required field is missing or a value has the wrong type. |
| 422 | legal-acceptance-required | A required agreement is not accepted; the body names the document ids and the text route. |
| 502 | otp_send_failed | The email provider refused the send. No code was issued. |
What would show this is false
Request twice for the same address. The second code replaces the first, and the first then fails with invalid_code.
POST /v1/auth/signout
End the session
Clears the session cookie.
Parameters
None.
Request body
None.
Example request
curl
curl -sS -X POST "https://api.afaprotocol.com/v1/auth/signout" \
-H "Cookie: afa_jwt=<session cookie>"
Python
import requests
API = "https://api.afaprotocol.com"
cookies = {"afa_jwt": "<session cookie>"}
r = requests.post(f"{API}/v1/auth/signout", cookies=cookies, timeout=30)
r.raise_for_status()
print(r.json())
Example response
HTTP 200
{
"status": "signed_out",
"user_id": "2f6e1a0c-9b4d-4e8a-8c31-5d7f0a2b9c14"
}
Errors
| Status | Code | Meaning |
|---|---|---|
| 401 | missing_token / token_expired | No session cookie, or one older than eight hours. |
What would show this is false
GET /v1/me with the old cookie returns 401 missing_token after this call.
POST /v1/auth/verify-otp
Exchange the code for a session
Checks the code and sets the afa_jwt session cookie on success.
The cookie is HttpOnly, SameSite=Strict, sent only over HTTPS, and valid for eight hours.
Parameters
None.
Request body
codestring required- No description in the schema.
emailstring required- No description in the schema.
Example request
curl
curl -sS -X POST "https://api.afaprotocol.com/v1/auth/verify-otp" \
-H "Content-Type: application/json" \
-d '{
"code": "482913",
"email": "ops@example.com"
}'
Python
import requests
API = "https://api.afaprotocol.com"
payload = {
"code": "482913",
"email": "ops@example.com"
}
r = requests.post(f"{API}/v1/auth/verify-otp", json=payload, timeout=30)
r.raise_for_status()
print(r.json())
Example response
HTTP 200
{
"expires_at": "2026-09-01T22:00:11+00:00",
"status": "ok",
"user": {
"created_at": "2026-08-30T09:14:02+00:00",
"email": "ops@example.com",
"id": "2f6e1a0c-9b4d-4e8a-8c31-5d7f0a2b9c14",
"last_login": "2026-09-01T14:00:11+00:00"
}
}
Errors
| Status | Code | Meaning |
|---|---|---|
| 422 | validation error | A required field is missing or a value has the wrong type. |
| 400 | invalid_code / otp_expired / no_pending_otp | Wrong code, a code older than ten minutes, or no code pending for this address. |
| 429 | too_many_attempts | Five wrong codes; request a new one. |
What would show this is false
Replay the same code after a success: 400 no_pending_otp. The code was consumed.
GET /v1/me
Who am I
Returns the account the credential resolves to, for a cookie or a key.
A key and a cookie both resolve to the same account. A request carrying both is treated as the key's machine.
Parameters
None.
Request body
None.
Example request
curl
curl -sS -X GET "https://api.afaprotocol.com/v1/me" \
-H "X-API-Key: afa-beta-EXAMPLE-e4qs"
Python
import requests
API = "https://api.afaprotocol.com"
headers = {"X-API-Key": "afa-beta-EXAMPLE-e4qs"}
r = requests.get(f"{API}/v1/me", headers=headers, timeout=30)
r.raise_for_status()
print(r.json())
Example response
HTTP 200
{
"created_at": "2026-08-30T09:14:02+00:00",
"email": "ops@example.com",
"is_admin": false,
"last_login": "2026-09-01T14:00:11+00:00",
"user_id": "2f6e1a0c-9b4d-4e8a-8c31-5d7f0a2b9c14"
}
Errors
| Status | Code | Meaning |
|---|---|---|
| 401 | missing_token / invalid_or_expired_api_key | No credential, an expired session, or a revoked or expired key. |
What would show this is false
Send a revoked key: 401 invalid_or_expired_api_key. Send nothing: 401 missing_token. The two refusals are distinct on purpose.