Appearance
Authentication
The primaTime API accepts two kinds of credential:
- An API key — generated by a user in their profile, long-lived, no sign-in flow to implement.
- An OAuth access token — obtained by signing a user in through primaTime's hosted login.
Each is a single header on the request, so switching between them later costs almost nothing.
Which should I use?
| Your situation | Use |
|---|---|
| A script, cron job, report export or other automation | API key |
| It runs unattended, with nobody around to sign in | API key |
| It only ever acts as you, or as one service account | API key |
| A tool other people use, each with their own primaTime account | OAuth |
| A CLI, desktop or mobile app you distribute to users | OAuth |
| It must act as whoever is currently using it | OAuth |
| API key | OAuth | |
|---|---|---|
| Setup | Generate it in your profile | Implement the PKCE flow |
| Sent as | X-Api-Key header | Authorization: Bearer header |
| Expires | Never — valid until revoked | Access token lasts 5 minutes; refresh required |
| Acts as | The user who created it | The user who signs in |
| Organization | Fixed when the key is created | Chosen at sign-in, switchable |
| Needs a browser | No | Yes, for the initial sign-in |
If you are unsure, start with an API key. It is the shorter path, and most integrations never need anything else.
Making a request
Both credentials go to the same endpoint, but in different headers.
An API key goes in X-Api-Key, on its own:
bash
curl -X POST https://api.next.primatime.com/graphql \
-H "Content-Type: application/json" \
-H "X-Api-Key: YOUR_API_KEY" \
-d '{"query": "{ authenticationContext { account { profile { email } } } }"}'An access token goes in Authorization, as a bearer token:
bash
curl -X POST https://api.next.primatime.com/graphql \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-d '{"query": "{ authenticationContext { account { profile { email } } } }"}'| Header | Required | Value |
|---|---|---|
Content-Type | Yes | application/json |
X-Api-Key | With an API key | The key value — no scheme or prefix |
Authorization | With an access token | Bearer <access token> |
An API key is not a bearer token
Sending a key as Authorization: Bearer <key> fails with Invalid access token. Keys go in X-Api-Key only.
Send one credential, not both. If both are present the access token takes precedence, so a stale token fails the request even when the key beside it is valid.
Trying it in the GraphiQL Playground?
Credentials go in the Headers tab of the bottom editor pane, as JSON:
json
{ "X-Api-Key": "YOUR_API_KEY" }Not in the Variables tab — that one carries GraphQL variables, validates them against your query, and flags anything else with Property X-Api-Key is not allowed.
The organization a request applies to comes from the credential — there is no tenant header.
API keys
An API key acts as the user who created it, in one organization, and carries exactly that user's permissions there.
Creating a key
- Open your profile in the primaTime app.
- Go to API keys and create one.
- Copy the key when it is shown — the full value is not displayed again.
Each key belongs to the organization you were working in when you created it. To automate against a second organization, switch to it and create another key.
Using a key
bash
curl -X POST https://api.next.primatime.com/graphql \
-H "Content-Type: application/json" \
-H "X-Api-Key: YOUR_API_KEY" \
-d '{"query": "{ projects(first: 10) { edges { node { id title code } } } }"}'That is the whole integration — no token exchange, no refresh.
Treat a key like a password
It grants full access to your primaTime data in that organization and does not expire. Keep it in an environment variable or a secrets manager, never in source control. If a key is exposed, revoke it in your profile.
Revoking a key
Delete it from API keys in your profile. Any integration using it stops working within a minute, so issue a separate key per integration to avoid taking others down with it.
OAuth
Use the OAuth 2.0 Authorization Code flow with PKCE when the API should act as whichever user is signed in. No client secret is involved, so the flow is safe for CLIs, desktop and mobile apps.
| Client ID | client_01KZXFWX9BWBE0V7TZMNB19CT7 |
| Redirect URI | http://127.0.0.1:<port>/callback — any port you bind at runtime |
| Authorize | https://auth-api.next.primatime.com/user_management/authorize |
| Token | https://auth-api.next.primatime.com/user_management/authenticate |
The client ID is public — include it in your code. Nothing needs to be requested from primaTime.
1. Create a PKCE verifier and challenge
javascript
import { createHash, randomBytes } from 'node:crypto';
const b64url = (buf) => buf.toString('base64url');
const codeVerifier = b64url(randomBytes(32));
const codeChallenge = b64url(createHash('sha256').update(codeVerifier).digest());
const state = b64url(randomBytes(16));Keep codeVerifier and state — both are needed below.
2. Open the authorize URL in a browser
Start a local HTTP server on a free port first, and use its address as redirect_uri.
https://auth-api.next.primatime.com/user_management/authorize
?response_type=code
&client_id=client_01KZXFWX9BWBE0V7TZMNB19CT7
&redirect_uri=http%3A%2F%2F127.0.0.1%3A8976%2Fcallback
&provider=authkit
&code_challenge=<codeChallenge>
&code_challenge_method=S256
&state=<state>| Parameter | Required | Notes |
|---|---|---|
response_type | Yes | Always code |
client_id | Yes | The value above |
redirect_uri | Yes | The address your local server is listening on |
provider | Yes | authkit |
code_challenge | Yes | From step 1 |
code_challenge_method | Yes | S256 |
state | Yes | Check it matches on the callback |
organization_id | No | Pre-select an organization |
screen_hint | No | sign-in or sign-up |
The user signs in and selects an organization, then the browser is redirected to:
http://127.0.0.1:8976/callback?code=01HZ...&state=<state>Reject the callback if state does not match what you sent. The code is single-use and expires in 10 minutes.
3. Exchange the code for tokens
bash
curl -X POST https://auth-api.next.primatime.com/user_management/authenticate \
-H "Content-Type: application/json" \
-d '{
"grant_type": "authorization_code",
"client_id": "client_01KZXFWX9BWBE0V7TZMNB19CT7",
"code": "01HZ...",
"code_verifier": "<codeVerifier>"
}'json
{
"user": { "id": "user_01HZ...", "email": "user@example.com" },
"organization_id": "org_01HZ...",
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"refresh_token": "yAjhKk..."
}Store the refresh token securely — it grants continued access to the user's data.
4. Refresh before the token expires
Access tokens are valid for 5 minutes. Refresh when less than a minute of validity remains, based on the token's exp claim, rather than waiting for a 401.
bash
curl -X POST https://auth-api.next.primatime.com/user_management/authenticate \
-H "Content-Type: application/json" \
-d '{
"grant_type": "refresh_token",
"client_id": "client_01KZXFWX9BWBE0V7TZMNB19CT7",
"refresh_token": "yAjhKk..."
}'The refresh token changes every time
Each refresh returns a new refresh token and invalidates the previous one. Save the new value immediately, or the next refresh fails and the user has to sign in again.
Organizations
A user can belong to several organizations. An access token applies to exactly one of them, chosen at sign-in.
Always sign in with an organization selected
A token with no organization cannot read or write any project, task, time record, client or report. If your client receives one, treat it as a failed sign-in and authenticate again.
Check before using the token:
javascript
const claims = JSON.parse(Buffer.from(accessToken.split('.')[1], 'base64url'));
if (!claims.org_id) throw new Error('No organization selected — sign in again.');Switching organization
accesses lists every organization the user belongs to, and returns two different identifiers for each one:
graphql
query MyOrganizations {
authenticationContext {
accesses(first: 50) {
edges {
node {
organization {
id # use in the API
authOrganizationId # use when requesting a token
profile { title }
}
}
}
}
}
}json
{
"id": "5f2b1c9e-...",
"authOrganizationId": "org_01HZ...",
"profile": { "title": "Acme Corporation" }
}| Field | What it is | Where it goes |
|---|---|---|
id | The organization's identifier in primaTime | Anywhere the API expects an organization |
authOrganizationId | The same organization as the authentication service knows it | The organization_id parameter when requesting a token |
Use authOrganizationId, not id
Passing the primaTime id does not fail immediately — the request proceeds and fails later at the sign-in screen, which is hard to trace. Always take the value from authOrganizationId.
To switch, request a new token with the target organization's authOrganizationId:
bash
curl -X POST https://auth-api.next.primatime.com/user_management/authenticate \
-H "Content-Type: application/json" \
-d '{
"grant_type": "refresh_token",
"client_id": "client_01KZXFWX9BWBE0V7TZMNB19CT7",
"refresh_token": "yAjhKk...",
"organization_id": "org_01HZ..."
}'Signing out
Discard your stored access and refresh tokens. The API has no logout operation.
Current user
Works with either credential:
graphql
query WhoAmI {
authenticationContext {
account {
profile { firstName lastName email }
}
user { id access owner }
organization {
id
profile { title }
}
permissions
}
}Errors
Authentication is checked before your query runs, so a rejected credential returns HTTP 401 with a plain body rather than a GraphQL errors array:
json
{ "error": "Invalid access token", "status": 401 }Check the status code
A client that only reads errors[] sees nothing here. Handle a 401 before parsing the body.
| Message | Cause | Fix |
|---|---|---|
Invalid access token | Credential missing, malformed or sent in the wrong header; an access token that expired; a revoked API key; or the user lacks the access level the operation requires | For an access token, refresh it, then sign in again. For an API key, check it is in X-Api-Key and was not revoked. For mutations, check the user has full access in the organization |
Unknown organization in access token | The organization is not recognized | Contact support |
Organization membership not materialized | The membership was not created through a primaTime invitation | Ask an administrator to re-invite the user |
Email not verified | The account's email address is unverified | Complete email verification, then retry |
A credential that carries no organization passes this check and fails later, inside the response, classified UNAUTHORIZED:
json
{
"errors": [
{
"message": "Missing tenant. Tenant-scoped operations require an organization-scoped access token.",
"extensions": { "classification": "UNAUTHORIZED" }
}
]
}Sign in again with an organization selected.
Read operations are available to any member. Most mutations additionally require full access in the organization.
Next steps
- Tenant Handling — how organization scoping works
- Error Handling — the full error model