Authentication
How applications authenticate with an API key + secret, how credentials rotate and revoke, and how admin sessions differ.
The service has two parallel authentication mechanisms. As an integrating app you use the API key + secret. Operators using the dashboard use an admin session — its login, logout, profile, step-up re-auth, and password-reset endpoints are documented below. Authorization for individual admin actions is covered in the RBAC guide.
App API key + secret
Every app-facing request carries two headers:
X-Api-Key: oi_test_xxx
X-Api-Secret: your-secretX-Api-Keyis a deterministic lookup handle. The service stores only its SHA-256 digest, so the key resolves a credential row without the raw value being recoverable.X-Api-Secretis verified against a bcrypt hash after the key resolves. Both must be valid.
The resolved credential determines the app and the mode
(TEST/LIVE). The mode is never read from the request body — a oi_test_… key
always operates on test data.
Send credentials only over HTTPS and only from your backend. Never embed the secret in a browser, mobile app, or anything a customer can inspect.
A credential is one of many
An app can hold more than one credential per mode. That is what makes rotation and revocation non-disruptive: each credential is an independent row that can expire or be revoked without touching the others.
| Credential status | Behaviour |
|---|---|
ACTIVE, no expiry | Authenticates normally. |
ACTIVE, with expiresAt | Still authenticates until the grace deadline passes (rotated-out key). |
REVOKED | Rejected immediately. |
Rotation (zero-downtime)
When a credential is rotated, a new one is issued immediately and the old
one is stamped with an expiresAt (a grace window, 24h by default). Both keys work
during the window, so you can roll your deployment over with no downtime:
- Operator rotates the credential; you receive the new key + secret (shown once).
- Deploy the new key alongside the old — both authenticate.
- Once all traffic uses the new key, let the old one expire (or have it revoked).
Revocation
Revoking a credential takes effect immediately — the next request with it is
rejected with UNAUTHORIZED. Use this if a secret may be compromised; issue a
replacement by rotating first if you need continuity.
Admin sessions
The dashboard authenticates operators, not apps. An operator logs in with email
- password and receives an opaque session token to send as a bearer header on every subsequent admin call:
Authorization: Bearer SESSION_TOKENApp integrations never deal with admin sessions — these endpoints exist for the
dashboard. They all live under /api/v1/auth and return the standard
response envelope.
| Endpoint | Method | Auth |
|---|---|---|
/auth/login | POST | Public |
/auth/password-reset/request | POST | Public |
/auth/password-reset/confirm | POST | Public |
/auth/me | GET | Session (bearer) |
/auth/logout | POST | Session (bearer) |
/auth/reauth | POST | Session (bearer) |
/auth/login and the two /auth/password-reset/* endpoints are unauthenticated;
/auth/logout, /auth/me, and /auth/reauth require an active session.
The admin session is unrelated to mode. Mode (TEST or
LIVE) is a property of an app API credential, never of an admin session — the
dashboard chooses which mode to view per screen.
Log in
POST /auth/login — public. Returns the session token (shown once) plus the
operator's identity and effective permissions for the dashboard to render against.
Request body — LoginRequest
| Field | Type | Required | Notes |
|---|---|---|---|
email | string | Yes | Must be a syntactically valid email. |
password | string | Yes | Non-blank; verified against the bcrypt hash. |
curl -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"password": "correct horse battery staple"
}'{
"email": "[email protected]",
"password": "correct horse battery staple"
}The data payload is a LoginResultDto:
{
"data": {
"token": "oi_sess_3f9a1c44d2b84e7c9a6f0e2b1d7c5a83",
"tokenType": "Bearer",
"expiresAt": "2026-06-30T20:00:00",
"userId": 42,
"email": "[email protected]",
"name": "Ops Operator",
"superAdmin": false,
"permissions": ["refund:approve", "app:read", "payment:read"]
},
"meta": {
"success": true,
"message": null,
"errorCode": null,
"timestamp": "2026-06-30T12:00:00Z"
},
"pagination": null
}Response — LoginResultDto
| Field | Type | Notes |
|---|---|---|
token | string | Opaque session token. Returned once — store it and send as Authorization: Bearer …. |
tokenType | string | Always Bearer. |
expiresAt | string (date-time) | Absolute session expiry. Sessions also expire after 30m idle; full lifetime is 8h by default. |
userId | number | The operator's id. |
email | string | The operator's email. |
name | string | Display name. |
superAdmin | boolean | true bypasses per-permission checks. |
permissions | string[] | Effective permission keys the UI gates on. |
A wrong password or an unknown/disabled account both return INVALID_CREDENTIALS
(no user enumeration). Too many failures in
the window lock the account and return ACCOUNT_LOCKED (5 attempts / 15m by
default).
Who am I
GET /auth/me — session. Re-reads the current session's identity and
permissions so the dashboard can refresh a permission-aware UI without re-logging-in.
curl http://localhost:8080/api/v1/auth/me \
-H "Authorization: Bearer SESSION_TOKEN"The data payload is a MeDto:
{
"data": {
"userId": 42,
"email": "[email protected]",
"name": "Ops Operator",
"superAdmin": false,
"permissions": ["refund:approve", "app:read", "payment:read"]
},
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:00:00Z" },
"pagination": null
}Response — MeDto
| Field | Type | Notes |
|---|---|---|
userId | number | The operator's id. |
email | string | The operator's email. |
name | string | Display name. |
superAdmin | boolean | true bypasses per-permission checks. |
permissions | string[] | Effective permission keys for the current session. |
Log out
POST /auth/logout — session. Revokes the session tied to the bearer token. The
data is null on success.
curl -X POST http://localhost:8080/api/v1/auth/logout \
-H "Authorization: Bearer SESSION_TOKEN"{
"data": null,
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:00:00Z" },
"pagination": null
}Step-up re-authentication
Sensitive operations — rotating an app credential, overriding the gateway store,
creating or disabling users, assigning roles — require a recent re-auth in
addition to the right permission. If the session's last re-auth is older than the
re-auth window (5m by default), the action is rejected with REAUTH_REQUIRED. The
operator re-enters their password at POST /auth/reauth, which stamps the session
and writes an entry to the append-only audit log;
the original action then succeeds on retry.
Attempt the sensitive action
Call it normally with the bearer token. If your last re-auth is within the window, it just succeeds and no round-trip is needed.
Handle REAUTH_REQUIRED
A 403 with errorCode: "REAUTH_REQUIRED" means the step-up window lapsed. Prompt
the operator for their password.
Re-authenticate
POST /auth/reauth with the password. On success the session's re-auth timestamp is
refreshed and the call is audited (auth.reauth).
Retry
Re-issue the original request with the same bearer token. It now passes the step-up check.
Request body — ReauthRequest
| Field | Type | Required | Notes |
|---|---|---|---|
password | string | Yes | Non-blank; re-verified against the current operator's bcrypt hash. |
curl -X POST http://localhost:8080/api/v1/auth/reauth \
-H "Authorization: Bearer SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "password": "correct horse battery staple" }'{ "password": "correct horse battery staple" }{
"data": null,
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:00:00Z" },
"pagination": null
}Password reset
A two-step, public, self-service flow. Neither step leaks whether an account exists.
POST /auth/password-reset/request — public. Always returns the same uniform
success response whether or not the email matches an account, so it cannot be used to
enumerate users. When the email does match, a single-use reset token (valid 1h by
default) is delivered out-of-band.
Request body — PasswordResetRequestRequest
| Field | Type | Required | Notes |
|---|---|---|---|
email | string | Yes | Must be a syntactically valid email. |
curl -X POST http://localhost:8080/api/v1/auth/password-reset/request \
-H "Content-Type: application/json" \
-d '{ "email": "[email protected]" }'{
"data": null,
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:00:00Z" },
"pagination": null
}POST /auth/password-reset/confirm — public. Completes the reset with the token
and a new, policy-compliant password (minimum 12 characters by default). An invalid
or expired token is rejected.
Request body — PasswordResetConfirmRequest
| Field | Type | Required | Notes |
|---|---|---|---|
token | string | Yes | The single-use token from the request step. |
newPassword | string | Yes | Non-blank; must satisfy the password policy (min length, not breached). |
curl -X POST http://localhost:8080/api/v1/auth/password-reset/confirm \
-H "Content-Type: application/json" \
-d '{
"token": "oi_pwr_8b21d0f6c4ae47119d3e2a5f7c0b9e64",
"newPassword": "a much longer replacement secret"
}'{
"data": null,
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:00:00Z" },
"pagination": null
}Authentication failures
errorCode | Meaning |
|---|---|
UNAUTHORIZED | Missing/invalid key or secret, or a revoked/expired credential. |
INVALID_CREDENTIALS | Wrong password, or unknown/disabled admin account (login). |
ACCOUNT_LOCKED | Too many failed admin logins — account temporarily locked. |
FORBIDDEN | Authenticated, but not permitted (admin RBAC). |
SESSION_EXPIRED | Admin session no longer valid (expired, idle-timed-out, or revoked). |
REAUTH_REQUIRED | Admin must re-authenticate before a sensitive action. |
All failures use the standard response envelope.