Skip to content
Linra Omni Vendor Docs

Authentication & capabilities

The vendor token model, its 5-minute lifetime, and how capability-scoping works.

POST /api/v1/auth/token exchanges a clientId + clientSecret for a short-lived JWT bearer token (audience vendor-api). Send it as Authorization: Bearer <token> on every other request to the vendor Gateway (api-omni-vendors.linra.net).

sequenceDiagram
participant Vendor as Your integration
participant Gateway as Vendor Gateway
participant Identity as Identity (issuer)
Vendor->>Gateway: POST /api/v1/auth/token<br/>{ clientId, clientSecret }
Gateway->>Identity: Validate credential
alt valid + active vendor
Identity-->>Gateway: capabilities, vendorId
Gateway-->>Vendor: 200 accessToken (aud=vendor-api, 5 min TTL)
Vendor->>Gateway: GET/POST/PATCH ... Authorization: Bearer accessToken
Gateway->>Gateway: check capability claim for this route
Gateway-->>Vendor: 200/201 payload, or 403 FORBIDDEN_CAPABILITY_NOT_GRANTED
else invalid credential or inactive vendor
Identity-->>Gateway: rejected
Gateway-->>Vendor: 401 UNAUTHORIZED_INVALID_CREDENTIALS / UNAUTHORIZED_VENDOR_INACTIVE
end
Note over Vendor,Gateway: Token expires after 5 minutes — cache it and re-fetch proactively, never on every request.
// POST /api/v1/auth/token
{ "clientId": "vnd_...", "clientSecret": "..." }
// 200 OK
{
"state": "SUCCESS",
"payload": {
"accessToken": "eyJhbGciOi...",
"tokenType": "Bearer",
"expiresIn": 300
}
}

A wrong clientId/clientSecret, or an inactive vendor, fails with 401:

// 401 Unauthorized
{ "state": "UNAUTHORIZED_INVALID_CREDENTIALS", "payload": null, "details": {} }
// or, for an inactive vendor:
{ "state": "UNAUTHORIZED_VENDOR_INACTIVE", "payload": null, "details": {} }

Both are deliberately the SAME generic shape whether the clientId doesn’t exist or the secret is simply wrong — this endpoint never tells you which half failed, so a caller can’t enumerate valid client ids one guess at a time. Calling this endpoint too often (more than 10 times in 60 seconds) fails with 429 and a Retry-After header — see Rate limits & errors.

Same trade-off the partner plane makes, applied here: your integration already holds a permanent clientId + clientSecret, so re-authenticating with that credential is the standard machine-to-machine pattern — there’s no separate short-lived-session actor that a refresh token would exist to serve. A short-lived, non-refreshable token bounds the damage of a leaked token to at most 5 minutes, with no server-side revocation infrastructure required.

Cache the token in memory and refresh proactively — a little before expiry, not reactively after a 401:

let cached = null; // { accessToken, expiresAt }
async function getAccessToken() {
const now = Date.now();
if (cached && cached.expiresAt - 30_000 > now) {
return cached.accessToken;
}
const res = await fetch('https://api-omni-vendors-stg.linra.net/api/v1/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clientId: CLIENT_ID, clientSecret: CLIENT_SECRET }),
});
const { payload } = await res.json();
cached = {
accessToken: payload.accessToken,
expiresAt: now + payload.expiresIn * 1000,
};
return cached.accessToken;
}

If a call still comes back 401, treat it as “the cache was stale for some other reason” and fetch once more before giving up — don’t loop.

Unlike the partner plane’s onBehalfOf, the vendor token request accepts no delegation field. Vendor has no hierarchy the way a partner does — your credential’s identity is always your own.

Your credential is issued with a fixed set of capabilities (Offers, Stock, Cost, Orders, Account — any combination, see the Vendor Integration Standard). The issued token’s claims carry exactly that set — every vendor endpoint checks it from the token alone, with no additional lookup back to your credential record. Account gates only the credentials-write door (generate/activate-deactivate/delete a credential) — the other four gate the commercial (Offers/Cost/Stock/Orders/Webhooks) endpoints.

A missing capability is a 403, not a partial/filtered response. If your credential doesn’t hold Orders, calling GET /api/v1/scent/orders doesn’t return an empty or truncated list — it rejects outright:

// 403 Forbidden
{
"state": "FORBIDDEN_CAPABILITY_NOT_GRANTED",
"payload": null,
"details": { "requiredCapability": "Orders" }
}

If you believe your integration needs a capability your current credential doesn’t have, contact your onboarding contact — capabilities are set when a credential is issued, and self-service generation (POST /api/v1/credentials, Account capability) can only ever match the UNION of what your OTHER active credentials already hold, never add a genuinely new one to an existing credential or grant a superset on a new one (see the Standard’s Onboarding section).

Because capabilities are baked into the token at issuance time, a capability change to your credential takes effect on your NEXT token fetch, not retroactively on an already-issued token — one more reason the short 5-minute lifetime is convenient rather than costly.