Authentication
API keys for your integration, member sessions for the people it talks for, and how members sign in.
Every call carries up to two credentials, for two different principals:
| Principal | Header | Proves |
|---|---|---|
| Client: your team or service | Authorization: Bearer pcai_<live|test>_… | Which integration is calling |
| Member: the person chatting | X-PlanCover-Session: <session id> | Which member the chat acts for |
curl -X POST "$BASE_URL/chat/stream" \
-H "Authorization: Bearer $CHAT_API_KEY" \
-H "X-PlanCover-Session: $SESSION" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{
"conversation_id": "k-3f9c2a",
"channel": "web",
"message": { "role": "user", "content": "what is covered for maternity?" },
"history": []
}'API keys
An API key identifies your integration. It is required on every route except
/health/*, sent as a bearer token:
Authorization: Bearer pcai_test_<43 characters>Format and environments
A key is a fixed prefix followed by 43 random URL-safe characters (32 random bytes). The prefix says where it works:
| Prefix | Works in |
|---|---|
pcai_live_ | prod only |
pcai_test_ | dev and staging |
A key used in the wrong environment is refused exactly like an unknown key.
The random part can itself contain _, so never parse a key by splitting on
_; match the prefix.
Getting a key
Keys are issued by the PlanCover team. Ask for one per integration and per
environment. You see the key once, when it is created; the server stores
only its SHA-256 hash and a display form like pcai_live_…a1B2, so a lost key
cannot be recovered, only replaced.
For operators, keys are created, listed and revoked from the command line:
make api-key args="create --name <caller> --role client [--rate-limit N]"
make api-key args="list"
make api-key args="revoke <id>"list never prints a key or its hash. Revoking is immediate and idempotent.
Keep keys server-side
A key is a server secret. Keep it in a server environment variable, never in a browser bundle, a mobile app or a repository. Browsers and apps call your backend, and your backend calls the chat API with the key.
Roles
| Role | May call |
|---|---|
client | /v1/*, /chat/*, /auth/*, /documents/* |
admin | Only the admin API (/admin/*), for PlanCover's admin portal. Refused on every route here |
A valid key of the wrong role gets 403 permission_denied. Integrations use
client keys.
Rate limit
Each key has a per-minute request limit: its own, if one was set when it was
created, or else the deployment's default of 120 requests per minute
(API_KEY_RATE_LIMIT_PER_MINUTE). A limit of 0 means unlimited.
The limit counts every request the key makes, /auth/session included,
in a fixed one-minute window. Over it, the call is refused before anything
runs, with a 429:
| Header | Meaning |
|---|---|
Retry-After | Whole seconds to wait: 1 to 60 for the key's limit |
retry-after-ms | The same wait in milliseconds |
The member quota on chat turns and the sign-in limits answer with the same
429 and the same two headers. The member quota also has a one-minute window;
the sign-in limits have longer ones, so their wait can be up to an hour for
/auth/login and ten minutes for /auth/verify.
Key errors
| Status | code | When |
|---|---|---|
401 | invalid_api_key | The header is missing or not Bearer <key>, or the key is unknown, expired, revoked or for the other environment |
403 | permission_denied | The key is valid but its role may not call this route |
429 | rate_limited | The key is over its limit |
503 | upstream_unavailable | The key could not be checked right now. It was not judged invalid; retry |
{
"error": {
"code": "invalid_api_key",
"message": "Provide a valid API key as Authorization: Bearer <key>.",
"request_id": "f792521fd91a42e49c90d1da3f1c94b4",
"details": null
}
}The service fails closed: if the database cannot answer, the call is a 503,
never accepted unchecked.
Member sessions
A member session tells the chat API which member a conversation belongs to. It is an opaque id, minted by the server, and sent in its own header on every call for that member:
X-PlanCover-Session: <session id>Minting a session
POST /auth/session needs your API key and no session. It answers 201 with
a fresh id: 32 hexadecimal characters, generated by the server and never chosen
by the caller.
curl -X POST "$BASE_URL/auth/session" \
-H "Authorization: Bearer $CHAT_API_KEY"{ "session": "<32 hex characters>" }A new session is signed out. It can hold a conversation and ask knowledge questions, and reaches no member data until the member signs in on it. Signing in attaches the member's credential to the same session id, so the conversation carries on.
The session id is a credential
Anyone holding a signed-in session id and any valid client key acts as that
member. A session id does not expire: POST /auth/logout ends its sign-in.
Store it like a password, send it only over TLS, and never put it in a URL or
a log line. It
goes in X-PlanCover-Session, never in Authorization: a session sent as the
bearer is refused as 401 invalid_api_key.
Without a session
POST /chat/stream or POST /v1/chat/completions without a session runs as
an anonymous, ephemeral member:
- a fresh identity on every call, so there is no conversation continuity:
two calls naming the same
conversation_idstart two separate threads; - knowledge questions only, with no member data;
- only your key's rate limit applies, not a member quota.
For continuity, mint a session once and send it on every call. An anonymous
member cannot sign in either, because sign-in and /chat/resume need a
session: if your members may ask about their own policies, start with a
minted session.
A session header that is sent but is empty or malformed is a 401, not a
request to be anonymous.
Routes that need a session
| Route | Without X-PlanCover-Session |
|---|---|
POST /auth/session | Works: this is where a session comes from |
POST /chat/stream | Works, as an anonymous member |
POST /v1/chat/completions | Works, as an anonymous member |
POST /chat/confirm, POST /chat/resume | 401 unauthorised |
POST /auth/login, POST /auth/verify, POST /auth/logout, GET /auth/me | 401 unauthorised |
GET /documents/{handle} | 401 unauthorised |
How members sign in
There are three planned ways for a member to get a signed-in session. Only the first is available today.
| Entry point | How | Status |
|---|---|---|
| plancover.com support chat | In-chat OTP: /auth/login, /auth/verify, then /chat/resume or a re-ask | Available |
| Rudal portal, member already signed in | Token exchange: the portal's backend hands over the member's PlanCover sign-in once and gets a chat session back | Not available yet |
| Auto-login from the sender's verified number | Not available yet |
In-chat OTP sign-in
Every step sends the member's session in X-PlanCover-Session, alongside your
API key.
The assistant asks for sign-in. A member question on a signed-out session
streams an auth.required event. If it is
resumable, the turn ends with finish_reason: "awaiting_sign_in".
Request an OTP. POST /auth/login with the member's 10-digit mobile
number. PlanCover texts the member a code.
{ "mobile": "9876543210" }Outside a dev deployment, the answer is the same for a registered and an
unregistered number, so it cannot be used to test whether an account exists. attempts_allowed says how
many guesses the code is worth. otp is always null outside a dev
deployment.
Verify the code. POST /auth/verify with the same mobile number and the
4–6 digit code.
{ "mobile": "9876543210", "otp": "123456" }On success the member's credential is stored, encrypted, against this session, and the answer describes who signed in:
| Field | Type |
|---|---|
authenticated | true |
name, role, company | string or null |
portals | integer: how many portals the member can reach |
expires_at | timestamp or null |
A wrong or expired code is a 401 unauthorised; the session itself is still
valid, so the member can try again, up to attempts_allowed guesses.
The mobile number and the code are never stored in plain form: not in the conversation, a log or a trace.
Carry on the conversation. If the auth.required was resumable, call
POST /chat/resume with the
conversation_id and the turn continues. On /v1/chat/completions, answer the
sign_in tool call instead. Otherwise,
send the question again.
GET /auth/me reports what a session currently carries, so a client can check
before it prompts; a signed-out session answers "authenticated": false.
POST /auth/logout drops the stored credential, here and at PlanCover.
A malformed mobile number or code is a 422 validation_error and never reaches
PlanCover. Too many attempts is a 429 with Retry-After, and so is an
attempt counter that cannot be reached: the cap fails closed.
Token exchange (not available yet)
For a member already signed in to the Rudal portal, the portal's backend will exchange that sign-in for a chat session, so the member does not sign in a second time. It is blocked on the PlanCover backend: there is not yet a way for the chat API to verify such a sign-in, or to hold a sign-in of its own that the portal's logout would not also end. Until it ships, portal members use the in-chat OTP flow.
WhatsApp auto-login (not available yet)
On WhatsApp, the sender's verified number will sign the member in without an
OTP. It is blocked: PlanCover has no endpoint yet that signs a member in by
mobile number without an OTP, and the ownership of the WhatsApp number is
still being settled. Until then, a member question on the whatsapp channel is
answered with a fixed "sign in through the PlanCover app" message and
finish_reason: "sign_in_required", with no auth.required event.