Skip to content

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 situationUse
A script, cron job, report export or other automationAPI key
It runs unattended, with nobody around to sign inAPI key
It only ever acts as you, or as one service accountAPI key
A tool other people use, each with their own primaTime accountOAuth
A CLI, desktop or mobile app you distribute to usersOAuth
It must act as whoever is currently using itOAuth
API keyOAuth
SetupGenerate it in your profileImplement the PKCE flow
Sent asX-Api-Key headerAuthorization: Bearer header
ExpiresNever — valid until revokedAccess token lasts 5 minutes; refresh required
Acts asThe user who created itThe user who signs in
OrganizationFixed when the key is createdChosen at sign-in, switchable
Needs a browserNoYes, 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 } } } }"}'
HeaderRequiredValue
Content-TypeYesapplication/json
X-Api-KeyWith an API keyThe key value — no scheme or prefix
AuthorizationWith an access tokenBearer <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

  1. Open your profile in the primaTime app.
  2. Go to API keys and create one.
  3. 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 IDclient_01KZXFWX9BWBE0V7TZMNB19CT7
Redirect URIhttp://127.0.0.1:<port>/callback — any port you bind at runtime
Authorizehttps://auth-api.next.primatime.com/user_management/authorize
Tokenhttps://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>
ParameterRequiredNotes
response_typeYesAlways code
client_idYesThe value above
redirect_uriYesThe address your local server is listening on
providerYesauthkit
code_challengeYesFrom step 1
code_challenge_methodYesS256
stateYesCheck it matches on the callback
organization_idNoPre-select an organization
screen_hintNosign-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" }
}
FieldWhat it isWhere it goes
idThe organization's identifier in primaTimeAnywhere the API expects an organization
authOrganizationIdThe same organization as the authentication service knows itThe 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.

MessageCauseFix
Invalid access tokenCredential 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 requiresFor 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 tokenThe organization is not recognizedContact support
Organization membership not materializedThe membership was not created through a primaTime invitationAsk an administrator to re-invite the user
Email not verifiedThe account's email address is unverifiedComplete 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

primaTime API Documentation