PlanCover Docs
API

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:

PrincipalHeaderProves
Client: your team or serviceAuthorization: Bearer pcai_<live|test>_…Which integration is calling
Member: the person chattingX-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:

PrefixWorks 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

RoleMay call
client/v1/*, /chat/*, /auth/*, /documents/*
adminOnly 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:

HeaderMeaning
Retry-AfterWhole seconds to wait: 1 to 60 for the key's limit
retry-after-msThe 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

StatuscodeWhen
401invalid_api_keyThe header is missing or not Bearer <key>, or the key is unknown, expired, revoked or for the other environment
403permission_deniedThe key is valid but its role may not call this route
429rate_limitedThe key is over its limit
503upstream_unavailableThe 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_id start 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

RouteWithout X-PlanCover-Session
POST /auth/sessionWorks: this is where a session comes from
POST /chat/streamWorks, as an anonymous member
POST /v1/chat/completionsWorks, as an anonymous member
POST /chat/confirm, POST /chat/resume401 unauthorised
POST /auth/login, POST /auth/verify, POST /auth/logout, GET /auth/me401 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 pointHowStatus
plancover.com support chatIn-chat OTP: /auth/login, /auth/verify, then /chat/resume or a re-askAvailable
Rudal portal, member already signed inToken exchange: the portal's backend hands over the member's PlanCover sign-in once and gets a chat session backNot available yet
WhatsAppAuto-login from the sender's verified numberNot 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:

FieldType
authenticatedtrue
name, role, companystring or null
portalsinteger: how many portals the member can reach
expires_attimestamp 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.

On this page