The Platform API
One identity service behind every Replatform application — the console, the five reference products, and yours. This is the reference for calling it directly, and the SDKs that mean you usually don't have to.
https://platform-api.replatform.co ·
every endpoint below is under /v1.Quickstart
Five minutes from nothing to a verified request.
-
Get a client registered
Ask a platform admin to create your application in the console, or do it yourself if you're one. You'll get a
client_idand, from that client's page, a client secret — shown once, so copy it immediately. -
Exchange it for a service token
# the only call you make with the secret itself curl -X POST https://platform-api.replatform.co/v1/auth/token \ -H "content-type: application/json" \ -d '{"client_id": "your-app", "client_secret": "pcs_..."}' -
Call an endpoint
curl https://platform-api.replatform.co/v1/clients/your-app/roles \ -H "Authorization: Bearer <access_token>" -
That's it for the app-to-platform half
For the user-facing half — someone signing in — see user tokens below. Most applications want the SDK rather than raw curl; see SDKs.
The one thing to understand first
Token issuance is central. Token verification is local.
The platform is called when a user signs in and when their token is refreshed — a couple of times per person per day. Every request after that is verified by your application, offline, against a public key it already has cached. Authorization travels inside the token, so the common case — an authenticated page view — costs no network call to the platform at all.
That's why the SDKs split cleanly into two kinds of call: the handful that reach the network
(login, refresh, issuing a service token) and the one that never does
(verify). If you're deciding what to call on every request, it's verify.
Authentication
Two credentials exist, because two different things authenticate.
Service tokens — your application, acting as itself
Used for audit writes, sending mail, and anything else your application does on its own behalf rather than a signed-in user's. Exchange your client id and secret for one:
No auth header — the body is the credential.
{
"client_id": "your-app",
"client_secret": "pcs_...",
"scope": "audit:write mail:send" // optional — omit for everything you're granted
}
Returns a token good for 15 minutes, scoped to the intersection of what you asked for and what your client is granted in the console. There's no refresh token here — when it expires, ask again.
User tokens — someone signing in
{ "email": "ada@example.com", "password": "...", "client_id": "your-app" }
Returns an access_token (15 minutes) and a refresh_token
(30 days, single-use, rotated on every refresh). The account must have a confirmed email
address — an unconfirmed one gets 403 email_unverified, not a silent failure.
Trade a refresh token for a new pair. Single-use — presenting the same one twice revokes every session descended from it, on the assumption that a replay means it was stolen. Rotate it on every use and you'll never notice.
Verifying a token — the one that runs locally
Fetch /v1/auth/jwks.json once, cache it (the SDKs do this for a day and serve it
stale on error), and verify the RS256 signature yourself. Check, in order:
| Claim | What to check |
|---|---|
aud | Equals your own client_id — never skip this. A token minted for another application must not verify for yours. |
exp / iat | Not expired; not issued in the future (allow ~60s clock skew). |
iss | Equals https://platform-api.replatform.co. |
The roles claim is scoped to your client only — it never reveals that
the holder is an admin somewhere else. That's deliberate: a token handed to an external
integrator shouldn't leak standing elsewhere in the estate.
SDKs
Both wrap the same rules: always check the audience, always serve stale keys rather than fail a sign-in, never let an audit write throw.
Python — replatform-platform
pip install -e packages/platform-client # from the monorepo, for now
from replatform_platform import PlatformClient
platform = PlatformClient(
base_url="https://platform-api.replatform.co",
client_id="your-app",
client_secret=os.environ["PLATFORM_CLIENT_SECRET"],
)
pair = platform.login(email, password) # sign-in
who = platform.verify(pair["access_token"]) # every request after — local, no network
platform.audit("timesheet.approved", actor_email=email)
Node.js — @replatform/platform-client
Dependency-free: global fetch and node:crypto, both in Node 18+. This
is the SDK that let PassIsland — the one product in the estate that isn't Python — join the
platform at all.
import { PlatformClient } from '@replatform/platform-client'
const platform = new PlatformClient({
baseUrl: 'https://platform-api.replatform.co',
clientId: 'your-app',
clientSecret: process.env.PLATFORM_CLIENT_SECRET
})
const who = await platform.verify(accessToken) // local
if (!who.hasRole('vault_owner')) return res.status(403).end()
await platform.audit('vault.opened', { actorAccountId: who.accountId })
Migrating an existing app onto the platform
If you already have your own password check, don't rip it out — dual-run it. The Python SDK's
dual_run_login tries the platform first and falls back to your own check only when
the platform is genuinely unreachable, never when it gives a real answer (wrong password,
locked, unconfirmed):
from replatform_platform.client import dual_run_login
result = dual_run_login(platform, email, password,
local_check=my_own_password_check,
on_path=lambda path: metrics.increment(f"login.{path}"))
When the fallback path serves zero sign-ins for a week, the import is done and the local check can come out.
API reference
/v1/auth
The public signing keys. Cacheable — the only endpoint that is.
Ends the session named in the presented access token.
Ends every session for the account. Existing access tokens still expire on their own schedule — up to 15 minutes — this can't recall one already handed out.
/v1/authz
For the decisions a token can't carry — a grant that changed mid-session, or a resource-level rule. Not for every request: the token already says what roles the holder has here.
{ "subject": "pacct_...", "action": "approve", "session_id": "sess_..." }
// → { "allow": true, "roles": ["manager"], "reason": "role_grant" }
/v1/clients
This application's role catalogue, as defined in the console — so your own role picker never drifts from what's actually granted.
Everyone holding a role in this client, with their platform account id.
/v1/users
404s unless this account holds a role in your client — an application only ever learns about its own users.
Roles across every client. The one cross-application view, restricted to the platform's own client id.
/v1/audit
One event, or {"events": [...]} up to 100 at a time. The
client_id comes from your token, never the body.
Your own recent events. Cross-application search lives in the console.
/v1/mail
Refusal is a 200, not an error: {"sent": false, "reason": "recipient_not_confirmed"}
when the address hasn't confirmed its email. Pass template + variables
to use a message defined in the console, or subject/text/html directly.
What your application may send. Bodies aren't returned — that's the console's to show.
/v1/contact
The public contact log. The sender's address is taken from the connection, never the request body.
Errors
Every error is JSON: {"error": {"code": "...", "message": "..."}}.
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | Missing or wrong-typed field. fields names which. |
| 401 | invalid_credentials | Wrong password, unknown account, or locked — deliberately indistinguishable. |
| 401 | invalid_client / invalid_grant | Bad client secret, or a refresh token that's used, revoked or expired. |
| 403 | email_unverified | Correct password, unconfirmed address. Only returned after the password checks out. |
| 403 | insufficient_scope | Your token doesn't carry a scope this endpoint needs. |
| 403 | invalid_scope | You asked /v1/auth/token for a scope your client isn't granted. |
| 404 | not_found | Including a real account your client has no relationship to — see /v1/users. |
| 429 | too_many_attempts | Rate limited — see below. |
Rate limits & security
- 20 failed sign-ins / 15 minutes per source address, across every account — catches password spraying, which per-account lockout can't.
- 10 failed client-credential attempts / 15 minutes per client.
- A WAF sits in front of everything: a 600 req/5min per-IP cap, plus AWS's managed rule sets for common attacks and known bad inputs, evaluated before a request reaches the API at all.
- No CORS headers are ever returned. This API is server-to-server — a token belongs in your backend, never in a browser.