How to obtain a token, keep it fresh, name the company every request acts on, and read a 401 or 403 correctly.
The shape of it
Every API call is an HTTPS request to https://bohari.co.ke/api carrying a JSON Web Token in Authorization: Bearer <token> and, for anything belonging to a company, an X-Company-Id header naming which company. The interactive reference at bohari.co.ke/api/docs is generated from the same controllers this guide describes; its Authorize dialog takes both the bearer token and the company id, and remembers them across page loads.
There are no API keys or personal access tokens. An integration signs in the way a person does, with an email and password, and receives a token that carries that user's identity and memberships. Self-registration was removed, so the account has to be invited by a company administrator. Give an integration its own user with a role holding only the permissions it needs; see Roles & Permissions.
Signing in
curl -X POST https://bohari.co.ke/api/auth/login \
--header "Content-Type: application/json" \
--data '{"email":"integration@yourcompany.co.ke","password":"...","rememberMe":true}'
{
"access_token": "eyJhbGciOi...",
"refresh_token": "eyJhbGciOi...",
"user": {
"id": "0f8a...", "email": "integration@yourcompany.co.ke", "isPlatformAdmin": false,
"companies": [
{ "companyId": "3c4d1a2b-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "companyName": "Mama Njeri's Bistro",
"roleName": "Finance", "isPrimary": true, "permissions": ["view_inventory", "view_procurement"] }
]
},
"requires2FA": false
}
Two things in that response matter for the rest of your integration. companies[].companyId is the value you'll send as X-Company-Id. And refresh_token only appears because the request said rememberMe: true without useCookie; that's the bearer-client path, and it's the one to use from a server.
Login accepts 10 attempts a minute per IP address. Every failed sign-in answers 401 Invalid email or password, whether the email is unknown, the password is wrong, the account is deactivated or it's locked; the server spends the same bcrypt work in each case so the response carries no hint. Five consecutive failures lock the account for 30 minutes (both numbers are platform settings).
Token lifetimes and refresh
| Token | Who gets it | Lifetime |
|---|---|---|
| Access token, bearer client | mobile app, scripts, servers (no useCookie) | 24 hours |
| Access token, web client | browser (useCookie: true) | 1 hour |
| Refresh token | either client, when rememberMe is set | 7 days, single use |
| 2FA-pending token | between password and TOTP code | 5 minutes |
| 2FA-setup token | when the platform mandates 2FA and the user hasn't enrolled | 15 minutes |
A platform administrator can set a session timeout that caps the two access lifetimes. The web path stores its refresh token in an httpOnly cookie named __session, scoped to /api/auth so it never rides along on ordinary calls; bearer clients receive theirs in the response body and present it back:
curl -X POST https://bohari.co.ke/api/auth/refresh \
--header "Content-Type: application/json" \
--data '{"refreshToken":"<the refresh_token you were given>"}'
The answer is a new access_token and a new refresh_token, and the one you just presented is dead. Present it a second time more than a minute later and the server treats that as a stolen token being replayed: every session for that user is revoked and the next call from anywhere gets a 401. Replays inside the minute are read as a client race and simply refused. Store the newest token, and never refresh from two processes sharing one chain.
Access tokens are self-contained, but not unrevocable. A password change or reset invalidates every token issued before it, and POST /api/auth/logout with the refresh token in the body revokes that token. Refresh is limited to 30 calls a minute.
Two-factor accounts
If the user has 2FA enabled, login returns requires2FA: true, an empty access_token and a tempToken. Finish with POST /api/auth/2fa/verify-login, sending { "tempToken": "...", "token": "123456", "rememberMe": true }; the response has the same shape as a normal login. The temp token is typed 2fa_pending and every other endpoint refuses it with 401 Token cannot be used for API access, as it does a refresh token presented as a bearer.
If the platform mandates 2FA and the user hasn't enrolled, you get requires2FASetup: true and a setup-scoped token. That token authenticates but is answered 403 everywhere except GET /api/auth/me and the two enrollment endpoints. For a headless integration, enrol the account once, interactively, or leave 2FA off it.
Naming the company: X-Company-Id
The platform is multi-tenant, and one token can belong to several companies. Nothing in the URL says which one a request is for; the X-Company-Id header does, on every tenant-scoped call:
curl https://bohari.co.ke/api/webhooks \
--header "Authorization: Bearer $ACCESS_TOKEN" \
--header "X-Company-Id: 3c4d1a2b-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
The rules, in the order the server applies them:
- The value must be a UUID. A malformed one is discarded before any guard runs, so the request then behaves as if you'd sent no header at all.
- No header on a tenant-scoped route gets 400
Company context required. Provide X-Company-Id header. - A header naming a company the user isn't a member of gets 403
You do not have access to this company.Membership is checked against thecompanyIdslist baked into the token at sign-in, so a user added to a new company needs a fresh token (a refresh recomputes the list) before that id works. - Permissions are then resolved for that company only. Holding
manage_settingsin one company says nothing about another.
Platform administrators skip the membership check and can name any company. They still have to name one: the tenant-scoped services beneath the guards refuse to run without a company in context.
Permissions, licences, and 401 versus 403
Tenant-scoped controllers run four guards in a fixed order: JWT, tenant, module licence, permission. Each route declares the permissions it accepts with @RequirePermission(...); several names on one route means any one of them will do. A route that forgets the decorator is denied by default rather than left open. Platform administrators pass every permission and licence check. The 403 message names what was missing, for example Insufficient permissions. Required: manage_settings or Module "WEBHOOKS" is not licensed for your organization. A suspended company can still read and export, but anything that records or changes answers 403 as well.
| Status | Meaning | Do |
|---|---|---|
| 401 | The token is missing, expired, revoked, or of a type that can't call the API | Refresh, or sign in again |
| 403 | The token is fine; this user, company, licence or token scope isn't allowed here | Change the header, the role or the licence, not the token |
| 400 | The request is malformed, including a missing X-Company-Id | Fix the request |
Request bodies are validated strictly: an unknown field is a 400, not something ignored. The generated schemas at /api/docs are the source of truth for field names.
FAQ
Is there a long-lived API key I can put in a config file?
No. Sign in with a dedicated user and rememberMe: true, store the refresh token, and replace it on every refresh. Each refresh issues a new 7-day token, so a service that runs at least weekly never needs the password again.
Which flow do I use from a server, cookie or bearer?
Bearer. Leave useCookie out. The cookie flow exists for browsers, where an httpOnly cookie keeps the refresh token away from page scripts; a server gains nothing from it and would need a cookie jar.
My token works for one company and gets 403 on another I was just added to.
The membership list is inside the token. Call POST /api/auth/refresh or sign in again and the new token carries the new company.
Do platform administrators need X-Company-Id?
Yes, whenever the route is about a company's data. The guard lets them through without one, but the service beneath can't act on "no company" and answers 400.
Why did every session for my integration user die at once? Almost certainly a refresh token was used twice more than a minute apart, which the server reads as theft and answers by revoking everything for that user. Two instances sharing one credential is the usual cause; give each its own user or its own refresh chain.