Authentication
API keys for machines, sessions for people, and the rules that decide which of the two an endpoint will accept.
Every request carries a bearer token:
Authorization: Bearer <credential>There are two kinds of credential, and the API tells them apart by their shape.
Anything starting with mk_ is an organisation API key. Anything else is
treated as a session JWT.
Organisation API keys
An API key is bound to the organisation rather than to a person. It is what you put in a backend integration.
Create one from POST /keys. The full key comes back exactly once, in the
api_key field of the response. We store only its SHA-256 hash, so there is no
way to show it to you again. What you keep afterwards is the key_prefix, the
first twelve characters, which is enough to recognise a key in a list but not to
use it.
POST /keys, GET /keys and DELETE /keys/{key_id} all require an
organisation admin signed in as a user. An API key cannot mint or revoke
another API key.
Authorization
bearerAuth JWT from /auth/login (users) or an org API key (mk_...) for integrations.
In: header
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
curl -X POST "https://example.com/keys" \ -H "Content-Type: application/json" \ -d '{}'{ "api_key": "string", "id": "string", "key_prefix": "string"}Authorization
bearerAuth JWT from /auth/login (users) or an org API key (mk_...) for integrations.
In: header
Response Body
application/json
curl -X GET "https://example.com/keys"[ { "created_at": "string", "id": "string", "key_prefix": "string", "label": "string", "last_used_at": "string", "status": "string" }]Authorization
bearerAuth JWT from /auth/login (users) or an org API key (mk_...) for integrations.
In: header
Path Parameters
Response Body
application/json
curl -X DELETE "https://example.com/keys/string"{ "ok": true}User sessions
People sign in with an email and a password and get a JWT back. Its lifetime is
set by the deployment and returned alongside it as expires_in_hours.
Accounts are created by invitation only. An organisation admin sends the
invitation, the recipient hits POST /auth/register with the token from their
email, and comes out the other side already signed in.
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
curl -X POST "https://example.com/auth/login" \ -H "Content-Type: application/json" \ -d '{ "email": "string", "password": "string" }'{ "expires_in_hours": 0, "token": "string", "twofa": { "challenge_id": "string", "enrolment_required": true, "method": "string", "pending_token": "string", "sent_to": "string" }, "user": { "created_at": "string", "email": "string", "id": "string", "last_login_at": "string", "name": "string", "org_id": "string", "role": "string", "status": "string" }}Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
curl -X POST "https://example.com/auth/register" \ -H "Content-Type: application/json" \ -d '{ "password": "string", "token": "string" }'{ "expires_in_hours": 0, "token": "string", "twofa": { "challenge_id": "string", "enrolment_required": true, "method": "string", "pending_token": "string", "sent_to": "string" }, "user": { "created_at": "string", "email": "string", "id": "string", "last_login_at": "string", "name": "string", "org_id": "string", "role": "string", "status": "string" }}Authorization
bearerAuth JWT from /auth/login (users) or an org API key (mk_...) for integrations.
In: header
Response Body
application/json
curl -X GET "https://example.com/auth/me"{ "created_at": "string", "email": "string", "id": "string", "last_login_at": "string", "name": "string", "org_id": "string", "role": "string", "status": "string"}When login does not hand you a token
POST /auth/login normally answers with token set. It answers with token: null and a twofa object in two cases: the account has a second factor
enrolled and now has to prove it, in which case twofa.challenge_id names the
challenge to answer, or the account is required to have one and does not yet, in
which case twofa.enrolment_required is true.
Either way you get a pending_token. It is a real signed token, but it is
scoped to the 2FA endpoints and nothing else, so presenting it anywhere in the
rest of the API is rejected. Send it as the bearer token to POST /auth/login/2fa with the code to finish signing in, or to the enrolment
endpoints to set a factor up first. Confirming enrolment also returns a full
session, so a first-time setup signs you in.
Second factors are mandatory for platform staff only. Your own organisation's users can enrol voluntarily and are otherwise left alone.
Authorization
bearerAuth JWT from /auth/login (users) or an org API key (mk_...) for integrations.
In: header
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
curl -X POST "https://example.com/auth/login/2fa" \ -H "Content-Type: application/json" \ -d '{ "challenge_id": "string", "code": "string" }'{ "expires_in_hours": 0, "token": "string", "twofa": { "challenge_id": "string", "enrolment_required": true, "method": "string", "pending_token": "string", "sent_to": "string" }, "user": { "created_at": "string", "email": "string", "id": "string", "last_login_at": "string", "name": "string", "org_id": "string", "role": "string", "status": "string" }}Authorization
bearerAuth JWT from /auth/login (users) or an org API key (mk_...) for integrations.
In: header
Response Body
application/json
curl -X GET "https://example.com/auth/2fa/status"{ "backup_codes_left": 0, "enrolled": true, "method": "string", "required": true}Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
curl -X POST "https://example.com/auth/2fa/setup/start" \ -H "Content-Type: application/json" \ -d '{ "method": "string" }'{ "method": "string", "otpauth_uri": "string", "secret": "string", "sent_to": "string"}Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
curl -X POST "https://example.com/auth/2fa/setup/confirm" \ -H "Content-Type: application/json" \ -d '{ "code": "string" }'{ "backup_codes": [ "string" ], "expires_in_hours": 0, "token": "string", "user": { "created_at": "string", "email": "string", "id": "string", "last_login_at": "string", "name": "string", "org_id": "string", "role": "string", "status": "string" }}Forgotten passwords
POST /auth/forgot-password always answers {"ok": true}, whether or not the
address belongs to an account. It will not tell a stranger who has one. The
email carries a token for POST /auth/reset-password. Passwords are eight
characters minimum.
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
curl -X POST "https://example.com/auth/forgot-password" \ -H "Content-Type: application/json" \ -d '{ "email": "string" }'{ "ok": true}Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
curl -X POST "https://example.com/auth/reset-password" \ -H "Content-Type: application/json" \ -d '{ "new_password": "string", "token": "string" }'{ "ok": true}Roles, and what an API key cannot do
Users are either org_admin or org_member. Admin gates a specific list of
endpoints, all of them things that spend money, hand out credentials, or change
who can do either.
| Endpoint | Why it is gated |
|---|---|
PUT /config | changes your resale markup |
POST /balance/withdraw | moves money out |
GET/POST /keys, DELETE /keys/{key_id} | manages API credentials |
GET /users, POST /users/invite, PUT /users/{id}/status | manages who has access |
GET /invitations, DELETE /invitations/{id} | manages pending access |
An API key has no user behind it, so it never satisfies an admin check. Calling one of the endpoints above with an API key returns:
{
"error": {
"code": "forbidden",
"message": "org_admin role required (use a user session, not an API key)"
}
}Everything else works identically with either credential: catalog, quotes, purchases, wallets, key delivery, funding, balance reads and stats.
Rate limits and suspension
Requests are counted per organisation, per minute, against the limit on your org
record. You can read it as rate_limit_rpm from GET /config. Going over
returns 429 with code rate_limited. The counter is shared across every
credential your org holds, so ten API keys do not buy ten times the quota.
A suspended organisation gets 403 forbidden on everything, with the message
org suspended.
Last updated on