Authentication
Session tokens for apps, store API tokens for scripts, and one permission model behind both.
Every authenticated request carries a bearer token:
Authorization: Bearer <token>The API accepts two kinds of token:
| Kind | Get it from | Reaches | Lifetime | Use it for |
|---|---|---|---|---|
| Session token | POST /v1/auth/login | Every store and organization the person belongs to | expires_in (a day by default); renewed with a refresh token | Apps a person signs in to: the admin, mobile apps, your own UI |
Store API token (mobi_st_…) | Settings → API tokens in the store, or POST /v1/stores/{store_id}/api-tokens | One store | Until revoked or its optional expiry | Scripts, integrations and automations for that store |
Either way, the token can never do more than the user it belongs to. A store
API token is also held to its store: a call that checks permissions anywhere
else (another store, or an organization-level call such as the
organization's subscription or creating a store) answers 403, with code
missing_permission (creating an organization answers forbidden). There are
no organization-wide or user-wide API tokens; for those calls, sign
in and use a session token.
Signing in
curl -X POST https://api.mobiari.com/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "ana@example.com", "password": "…", "device_name": "Stock sync laptop"}'device_name is optional; it labels the session in the user's session
list. The response is told apart by status:
{
"status": "authenticated",
"token": "eyJhbGciOiJIUzI1NiJ9…",
"token_type": "Bearer",
"expires_in": 86400,
"refresh_token": "mobi_rt_2f8K…",
"refresh_token_expires_in": 2592000,
"session_id": "0199a4f2-6c1e-7d3a-9b1f-3e2c8a5d7f10",
"user": { "id": "…", "email": "ana@example.com", "...": "…" }
}| Field | Meaning |
|---|---|
token | The access token. Send it as Authorization: Bearer <token>. |
expires_in | Seconds until token expires. Read it; don't hard-code a lifetime. |
refresh_token | Trades for a new pair at POST /v1/auth/refresh. Starts with mobi_rt_. Works once. |
refresh_token_expires_in | Seconds until the refresh token expires if it isn't used. |
session_id | This sign-in, as listed by GET /v1/me/sessions. |
Two-factor authentication
If the account has a second factor on, the password step answers with a challenge instead of tokens:
{
"status": "two_factor_required",
"challenge": "eyJ…",
"methods": ["totp", "whatsapp"],
"expires_in": 300
}Finish within expires_in seconds with one of the methods:
| Method | Calls |
|---|---|
totp | POST /v1/auth/2fa/totp/verify with challenge and totp_code (from the authenticator app) |
whatsapp | POST /v1/auth/otp/send with challenge, then POST /v1/auth/otp/verify with challenge and code |
Both verify calls return the signed-in session (token, refresh_token,
…). New methods may be added: ignore values of methods you don't support.
Refreshing a session
Before token expires, or when a call returns 401 unauthorized, trade
the refresh token for a new pair:
curl -X POST https://api.mobiari.com/v1/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refresh_token": "mobi_rt_2f8K…"}'The response is a new session: the same fields as a successful sign-in,
without status.
Refresh tokens rotate. Each one works once: the response carries a new
refresh_token, and you must store it in place of the old one.
Reuse revokes the session. Presenting a refresh token that was already
used is treated as theft: the whole session (every token descended from
that sign-in) is revoked and the call fails with 401
refresh_token_reused. The user signs in again.
| Error | Meaning | What to do |
|---|---|---|
401 invalid_refresh_token | Unknown or expired refresh token | Sign in again |
401 refresh_token_reused | Already used or revoked; the session is now signed out on every device holding it | Sign in again, and fix the client if it wasn't an attack |
Refresh from one place at a time
Two tabs or threads refreshing with the same token at once is
indistinguishable from a stolen token: the second call gets
refresh_token_reused and signs the user out. Funnel refreshes through
a single in-flight promise (or a lock), and have every waiting request
use its result.
let refreshing: Promise<Session> | null = null;
function refresh(session: Session): Promise<Session> {
refreshing ??= fetch('https://api.mobiari.com/v1/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: session.refresh_token }),
})
.then(async (res) => {
if (!res.ok) throw new SignedOut((await res.json()).code);
return saveSession(await res.json()); // persist the NEW refresh_token
})
.finally(() => {
refreshing = null;
});
return refreshing;
}Signing out and sessions
curl -X POST https://api.mobiari.com/v1/auth/logout \
-H "Content-Type: application/json" \
-d '{"refresh_token": "mobi_rt_2f8K…"}'Logout revokes the session: its refresh token and the access tokens issued for it stop working. It answers 204 even for an unknown or already revoked token, so it is safe to retry.
| Method | Path | Purpose |
|---|---|---|
GET | /v1/me/sessions | The user's signed-in devices (a Page), most recently used first, with device_name, last_used_at and current. |
DELETE | /v1/me/sessions/{session_id} | Sign one device out. |
Store API tokens are not sessions and don't appear in this list.
Store API tokens
Each token belongs to one store and acts as the person who created it. Create one in the store under Settings → API tokens, or through the API:
curl -X POST "https://api.mobiari.com/v1/stores/$STORE_ID/api-tokens" \
-H "Authorization: Bearer $BP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Stock sync script",
"scopes": ["products.read", "orders.read"],
"expires_at": null
}'The response includes the raw token once. The server keeps only a
sha256 hash; if you lose the value, rotate the token.
mobi_st_<43 characters, URL-safe base64>Keep the mobi_st_ prefix: it is what lets secret scanners (GitHub and
others) recognize a leaked token. Tokens created before 2026-09-29 start
with bod_ and keep working; rotating one gives it a mobi_st_ value.
How a token relates to its user
A token is always issued on behalf of a real user, and it can never do more than that user can. If the user is removed from the store, every token they issued stops working. There are no orphan tokens.
- Audit attribution. Changes made with a token are recorded against the user who issued it, together with the token's name ("Aline, via the POS terminal").
- Restricted tokens. For an integration that must stay narrow even if you're the owner, issue the token with only the scopes it needs, or invite a dedicated "integrations" member with limited permissions and issue the token as them.
Scopes
scopes are permission codes, the same ones on the team permissions page
(products.read, orders.refund, …); GET /v1/permissions/catalog lists
them all. ["*"] means everything the issuing user can do. When a request
comes in:
effective permissions = the user's permissions ∩ the token's scopesThe narrowing also applies to the store owner: an owner's token with only
products.read is genuinely read-only. You can't grant a token a
permission you don't have yourself.
Rotate and revoke
| Action | Endpoint | Effect |
|---|---|---|
| List | GET /v1/stores/{store_id}/api-tokens | Name, scopes, last 4 characters, last_used_at; never the raw value |
| Rotate | POST /v1/stores/{store_id}/api-tokens/{token_id}/rotate | Same token, new value; the old value stops working immediately. Use after a leak. |
| Revoke | DELETE /v1/stores/{store_id}/api-tokens/{token_id} | Gone for good. 204 No Content. |
curl -X DELETE \
"https://api.mobiari.com/v1/stores/$STORE_ID/api-tokens/$TOKEN_ID" \
-H "Authorization: Bearer $BP_TOKEN"Requests with a revoked token fail with 401 right away; there is no grace period.
401 or 403?
| Response | Cause | Fix |
|---|---|---|
401 unauthorized | Missing header, expired session token, revoked or mistyped token | Refresh the session, or check the token (did the mobi_st_ prefix get stripped?) |
401 invalid_refresh_token / refresh_token_reused | Refresh failed | Sign in again |
403 forbidden / missing_permission | The token is valid, but the user or the token's scopes lack the permission, or a store API token was used outside its store | Grant the permission, issue a token with that scope, or use a token for the right store (a session token for organization-level calls) |
Errors follow the shape described in API conventions.