Getting started
Authentication
Every call carries a token:
GET /v1/answers/current-place HTTP/1.1
Host: api.contextkit.com
Authorization: Bearer cka_3f9a…
One token is one user's approval of one app. It is not an API key for your service. If ten people connect your app, you hold ten tokens.
Connecting a user
ContextKit uses OAuth 2.1 with PKCE. Four steps to connect, then one you repeat.
This page is the flow over plain HTTPS. The Node SDK does all of it in three calls.
1. Register your app
On the Apps page of the developer portal, list the scopes your app may ever request. That list is the maximum a user can grant.
New apps start in sandbox: only you can connect them. Going to production is a review of the app itself, run from its Go live tab — see Going to production.
2. Register your redirect URIs
POST /v1/oauth/clients/{app_id}
Content-Type: application/json
{ "redirectUris": ["https://api.example.com/contextkit/callback"] }
This returns your client secret, starting cks_. Store it on your server.
Redirect URIs are matched exactly, not by prefix. https://app.example.com will not match https://app.example.com/callback.
3. Send the user to the consent screen
https://contextkit.com/authorize
?client_id=<your app id>
&redirect_uri=https://api.example.com/contextkit/callback
&scope=location.place.current%20location.rules.place
&code_challenge=<base64url(sha256(verifier))>
&code_challenge_method=S256
&state=<your CSRF value>
&external_user_id=<your id for this user> (optional)
Generate a random code_verifier of 43–128 characters first and keep it — the code_challenge above is its SHA-256, base64url encoded.
state is required, not optional as in plain OAuth: it is the only thing that ties the callback to the session that started it, so a link without one is refused before the consent screen renders. Make it unguessable (8–512 characters), keep it in the user's session, and reject any callback whose state does not match.
external_user_id is your own id for the user (up to 128 characters). We store it against the connection and show it to you — on GET /v1/me and in your app's Users tab — and never to the user. Send it so you can recognise a returning user even if you lose the tokens you stored.
That pair is what makes the code safe to send through a browser. The authorization code comes back on a redirect URL, and redirect URLs leak: into browser history, Referer headers, proxy logs, or an app that registered your custom scheme. A stolen bare code could be exchanged for tokens by whoever holds it. With PKCE the exchange also needs the verifier, which never left you — and a second exchange that does present the right verifier tells us the code was copied, which is why we then revoke everything it issued.
The user picks which scopes to approve, which places you may ask about, and when access expires — separately for standard and sensitive scopes, see Two expiries. They can approve fewer scopes than you asked for. If you ask for a sensitive scope, the consent screen also shows the description of each purpose registered for it.
A user who has not yet set up the ContextKit app on a phone is told so on the consent screen and can still approve. Until a phone is sending us location, every answer for that user is unknown, so treat unknown as the normal state for a freshly connected user rather than an error.
We return the browser to your redirect_uri with ?code=…&state=…. Check state matches what you sent.
4. Exchange the code for tokens
From your server, not the browser:
POST /v1/oauth/token
Content-Type: application/json
{
"grantType": "authorization_code",
"clientId": "…",
"clientSecret": "cks_…",
"code": "…",
"codeVerifier": "<the original verifier>",
"redirectUri": "https://api.example.com/contextkit/callback"
}
{
"access_token": "cka_…",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "ckr_…",
"refresh_token_expires_at": null,
"sensitive_scopes_expire_at": null,
"scope": "location.place.current location.rules.place",
"sub": "3f1c…"
}
scope lists the scopes you can use right now. sensitive_scopes_expire_at is when the sensitive tier ends, or null when the connection holds no sensitive scope — see Two expiries.
Store both tokens now. We keep only their SHA-256 and cannot show them again. Store sub too: it is this user's id as your app sees them — stable across reconnects, and different for every app, so two apps cannot match their user tables through us. It is not the user's ContextKit identity, and nothing about them can be looked up from it.
The access token lasts an hour. The refresh token lasts as long as the grant's standard tier — refresh_token_expires_at is null when the user chose no expiry. The sensitive tier ending does not end the refresh token.
The code is valid for 60 seconds and can be used once. A second attempt fails, and if it proves the PKCE verifier we treat the code as leaked and revoke everything it issued.
Reconnecting an already-connected user rotates the token family in place. Their standing rules and access-log history survive.
5. Refresh before the hour is up
Same endpoint, different grant type:
POST /v1/oauth/token
Content-Type: application/json
{
"grantType": "refresh_token",
"clientId": "…",
"clientSecret": "cks_…",
"refreshToken": "ckr_…"
}
The response is identical in shape to step 4 — including a new refresh token. Refresh tokens rotate: the one you sent is spent, and you must store the one you get back. scope is re-reported every time, because the user may have narrowed your access since you last looked.
Presenting a spent refresh token is how we detect a leak. Do it and we revoke that whole token family, flag the connection and email the user, who will have to reconnect you. Two exceptions keep honest clients safe: a retry within 30 seconds of a rotation is treated as a race rather than a leak, and a token that was already revoked just fails quietly.
If you refresh from several processes at once, that 30-second window covers you — but storing the newest token you receive is what keeps you out of trouble.
Why refresh at all
The access token is short-lived so that a copy which escapes — into a log file, a backup, an error report — stops working on its own. Revoking a connection was always instant, but that only ever helped for a leak somebody noticed. Rotation adds the part that was missing: a token used twice is evidence, and we act on it.
It also keeps the longer-lived credential off the wire. The access token rides on every API call; the refresh token touches one endpoint, occasionally.
Who is this user?
Any live access token may ask, whatever its scopes:
GET /v1/me
Authorization: Bearer cka_…
{
"sub": "3f1c…",
"external_user_id": "user_42",
"scopes": ["location.place.current", "location.rules.zone"],
"held_scopes": ["location.place.current", "location.rules.zone", "location.lookup"],
"places_version": 3,
"grant_expires_at": null,
"sensitive_expires_at": "2026-10-13T09:00:00.000Z",
"sensitive_lapsed_at": "2026-10-13T09:00:00.000Z",
"renewal_grace_ends_at": "2026-10-27T09:00:00.000Z",
"connected_at": "2026-09-12T20:11:04.000Z"
}
This user's sensitive tier has lapsed: location.lookup is still in held_scopes, so renewing it is one confirmation, but it is no longer in scopes. For a connection with no sensitive scope the three sensitive fields are null and held_scopes equals scopes.
Use it to re-identify a user after losing the token you stored, or to read back what the connection currently allows. It tells you nothing about the user that you did not already send us.
What a token carries
The token holds the scopes the user actually approved. The effective set is the intersection of their grant and your app's current declared scopes, so if either side narrows, live tokens narrow immediately.
Calling an endpoint you don't have the scope for returns 403, not 404. You can tell "not allowed" from "doesn't exist".
Two expiries
A connection's scopes expire in two tiers, chosen separately by the user on the consent screen.
| Tier | Options | Default | When it ends |
|---|---|---|---|
| Standard — every scope that answers a question | 1, 7, 30, 90 days, never | 30 | the whole connection ends |
Sensitive — location.latest.read, location.history.read, location.lookup | 1, 7, 30, 90 days | 30 | only the sensitive scopes stop; the rest carry on |
All sensitive scopes on a connection share one expiry. There is no "never" for the sensitive tier, and 90 days is the longest a user can choose — raw coordinates are the access a user is most likely to forget they gave.
When the standard tier ends, the connection ends: 401, as described under Failures. When the sensitive tier ends, nothing else changes. A travel app keeps its arrival rules; only its calls to /v1/locations/* start failing.
Effective and held scopes
- Held scopes are everything on the grant (
held_scopeson/v1/me). - Effective scopes are what you can use now: the held scopes minus the sensitive ones once
sensitive_expires_athas passed. The token response'sscopeand/v1/me'sscopesare always the effective set.
A sensitive call after the lapse answers 403 with error: "scope_expired", not missing_scope, so you can tell the user "your exact-location access ended on 13 October" rather than "you never allowed this":
{
"statusCode": 403,
"error": "scope_expired",
"message": "access to location.lookup expired; ask the user to renew it",
"sensitive_expired_at": "2026-10-13T09:00:00.000Z",
"renewal_grace_ends_at": "2026-10-27T09:00:00.000Z"
}
Renewal and the 14-day grace
Access stops on the date. It is not extended while you ask.
For 14 days after sensitive_expires_at the lapsed scopes stay held. Renewing in that window is one confirmation: same connection, same rules and places, same standard expiry. When renewal_grace_ends_at passes, the sensitive scopes are removed from the grant, and asking for them again is an ordinary consent, like the first time.
To renew — before the date or during the grace — send the user to the consent screen exactly as in step 3, asking for the scopes the connection already holds. Exchange the code as usual and store the new tokens; the old ones are revoked.
The consent screen picks its form from what you ask for:
| Mode | When | The user sees |
|---|---|---|
new | no live connection | the full consent screen |
renew_sensitive | a live connection, and you asked for exactly the scopes it holds | a renewal: the sensitive expiry to choose again, and what you used the access for over the last 90 days, by purpose |
change | a live connection, and you asked for a different set of scopes | the full consent screen for the new set |
A renewal leaves the standard expiry as it was; a change sets both again. Either way the connection, its rules and its places are kept.
Every renewal screen links to the user's access log at contextkit.com/you, where they can read every request you made and the purpose you gave for it. If your purposes read honestly, renewal is an easy yes.
Telling your users
ContextKit sends users no reminder that access is ending. You get webhook events instead — sensitive.expiring 14 and 7 days before, sensitive.lapsed on the date, and sensitive.removed when the grace ends — so any reminder comes from your product, in your voice.
The Acceptable Use Policy limits how: at most one non-blocking, dismissible notice per sensitive.expiring event, saying when access ends and how to renew. No blocking screens, no incentives, and no wording that suggests a feature is gone for good.
Failures
| Status | Meaning | Retry? |
|---|---|---|
400 | invalid_purpose on a sensitive call | No, fix the call |
401 | Token missing, malformed, revoked, or expired | Refresh once; if that fails, the user must reconnect |
403 | missing_scope: never granted | No |
403 | scope_expired: the sensitive tier lapsed | No — renew |
404 | Doesn't exist, or isn't visible to you | No |
429 | Rate or budget limit | Yes, with backoff |
Expired and revoked both return 401, and you cannot tell them apart. Try one refresh: if it succeeds the token had simply aged out, and if it returns invalid_grant the connection is genuinely gone and the user must reconnect. Do not loop — a second failure means the same thing as the first.
{
"statusCode": 403,
"error": "missing_scope",
"message": "grant is missing scope location.visits.read"
}
message is a string, or an array when several validation errors apply.
When a user disconnects
There is no warning. Your calls start returning 401, and your rules stop firing.
Treat a 401 that survives one refresh attempt as the end of the connection, and stop calling. Hammering a dead token spends your rate limit on nothing, and doing it across many users at once is the pattern our abuse detection exists to catch.
If your developer application promised to delete the user's data on disconnection, this is the moment to do it.
Disconnecting a user yourself
When a user disconnects ContextKit from inside your product, tell us, so the connection ends on our side too and stops appearing in their access log as live. This is RFC 7009 token revocation, and it revokes the whole connection, not just the token you present: every access and refresh token dies and every rule stops.
POST /v1/oauth/revoke
Content-Type: application/json
{
"clientId": "…",
"clientSecret": "…",
"token": "ckr_…"
}
Either kind of token works — send whichever you still hold. The response is 200 with an empty body once your client credentials check out, whatever the token's state: unknown, expired or already revoked all answer the same way, so the route cannot be used to probe. A 401 means the client credentials were wrong, not the token.
With the SDK this is one call on the user's handle:
await ckUser.disconnect();
After it resolves, drop the stored tokens; the handle refuses further calls with TokenRevokedError.
Keeping tokens safe
- Encrypt them at rest.
- Never put one in a URL. Query strings end up in access logs, proxy logs and browser history. Use the
Authorizationheader. - One token per user. Never share one.
- Store the new refresh token every time you refresh. The old one is spent, and presenting it again is what we treat as a leak.