Authentication
Every request (other than the embeddable widget's own endpoint) authenticates one of two ways: a first-party JWT (your users signing in through your own front end) or an API key (server-to-server integrations). Both resolve to the same RequestContext — a tenant id, and either a membership or an API key's own permission subset — so every endpoint behaves identically regardless of which one called it.
First-party JWT flow
Institflow is multi-tenant: one user account can hold memberships in several institutes, so signing in is a three-step handshake rather than a single call.
POST /api/v1/auth/loginwithemail,password, andclient("web"or"mobile"). The response carries a short-livedaccessToken, the signed-inuser, and every activemembershipthe account holds. If there is exactly one,activeTenantIdis already set and you can skip step 2.POST /api/v1/auth/select-tenantwith the chosenmembershipId— returns a newaccessTokenscoped to that tenant. Every subsequent request's tenant context comes from this token, never from a header or query parameter you set yourself.POST /api/v1/auth/refreshrotates the refresh token and issues a freshaccessTokenbefore the old one expires. A refresh token that gets presented twice (reuse — a strong signal it was stolen) revokes the whole session family immediately (SESSION_REVOKED); sign in again.
Send the access token as Authorization: Bearer <accessToken> on every request. GET /api/v1/auth/me returns the current user, their memberships, and their effective permission list for the active tenant — useful for building your own session UI without re-deriving permissions client-side.
Secret API keys
For server-to-server integrations (a script, a backend job, a partner system) create a secret key instead: POST /api/v1/api-keys (requires integration.manage) with a name and a permissions array — a strict subset of your own effective permissions; you can never grant a key more than you yourself hold. The full key (ifl_live_<32 random bytes, base64url>) is returned exactly once, at creation — only its prefix and a hash are ever stored, so if you lose it, revoke it and create a new one.
Authorization: Bearer ifl_live_9c1f2e7a4b8d3c6f0a5e9b2d7c4f1a8eA key is bound to the tenant of the membership that created it and carries only its own permissions — not your full effective set — on every request it authenticates. Revoking a key (DELETE /api/v1/api-keys/:id) takes effect immediately, not after a cache expires.
Publishable keys
A second key kind, ifl_pub_<…>, is safe to embed in front-end code — it can only call the /api/v1/public/* surface: POST /public/registrations (the endpoint the registration widget uses), POST /public/leads (contact-form enquiries), and the read-only course catalogue (GET /public/courses, /public/courses/:id, /public/branches — these answer 404 until the institute switches the public catalogue on). It is sent as X-Api-Key, is restricted to an explicit origin allowlist you configure per key, carries no staff permissions, and is rate-limited per IP and per key. Never use a publishable key for anything else — it deliberately cannot do anything else.
Idempotency-Key
Every POST/PUT/PATCH/DELETE made with an API key must carry an Idempotency-Key header (any unique string — a UUID is the usual choice). First-party JWT callers may send one too, but it is optional for them.
- Missing on a key-authenticated mutation →
428IDEMPOTENCY_KEY_REQUIRED. - Same key, same method/path/body as before → the original response is replayed byte-for-byte (no re-execution — safe to retry a timed-out request blind).
- Same key, a different method/path/body →
409IDEMPOTENCY_KEY_REUSED— you reused a key for a different request. - Same key, arriving while the first request is still running →
409IDEMPOTENCY_KEY_REUSED. The mutation runs once; retry after the first call returns. - A
4xxis remembered too — a deterministic failure replays rather than re-running. A5xxis never cached: retrying it really does re-execute, which is what you want after a server-side failure.
Keys are remembered for 24 hours.
The fingerprint includes the query string
Two requests count as “the same request” only when their method, their full URL including the query string, and their body are byte-for-byte identical — the fingerprint is sha256(METHOD + "\n" + url + "\n" + body). A retry that reorders its query parameters is a different request to the same key, and gets a 409 instead of the replay you expected. Serialize your parameters the same way every time.
Rate limits
Every request is rate-limited by a Redis fixed window — per IP when unauthenticated, per user for JWT callers, per key for API-key callers (600 requests/minute by default). A key can be given its own narrower budget with rateLimitPerMin on POST /api/v1/api-keys or PATCH /api/v1/api-keys/{id} — useful for a key you hand to a third party. It can never be wider than your plan's ceiling; asking for more is a 422. Every response carries the current window's state, and RateLimit-Limit is always that key's own effective budget:
RateLimit-Limit: 600
RateLimit-Remaining: 587
RateLimit-Reset: 42Exceeding the limit returns 429 with error code RATE_LIMITED — back off until RateLimit-Reset seconds have elapsed.
Never share a secret key
A secret key (ifl_live_…) authenticates as whatever permissions it was granted — treat it like a password. Only a publishable key (ifl_pub_…) is safe in browser-visible code.