Getting started

Node SDK

@tinbee/contextkit-sdk wraps every route on this site for a Node backend: the connect flow, token refresh, the answer and rule calls, and webhook verification.

pnpm add @tinbee/contextkit-sdk

Node 20 or later. Server-side only — the client secret must never reach a browser. Source and issues: github.com/tinbee/contextkit-sdk-node, Apache-2.0.

Every call has a timeout (10 s by default), and every failure is a ContextKitError subclass that says what to do (see Errors). Answers of unknown are values, not exceptions.

The app client

One instance per app. Holds the client credentials and builds a per-user handle.

import { ContextKit } from "@tinbee/contextkit-sdk";

const ck = new ContextKit({
  clientId: process.env.CONTEXTKIT_CLIENT_ID!,
  clientSecret: process.env.CONTEXTKIT_CLIENT_SECRET!,
  // apiBaseUrl, authorizeBaseUrl, timeoutMs, fetch — all optional
});

Connecting a user

The three steps of Authentication, typed.

import { generateCodeVerifier, generateState } from "@tinbee/contextkit-sdk";

const redirectUri = "https://api.example.com/contextkit/callback";

// 1. Send them to consent. Both values go in the user's session: the
//    callback is a separate request and needs them back.
const state = generateState();
const codeVerifier = generateCodeVerifier();
session.contextkit = { state, codeVerifier };
redirect(
  ck.authorizeUrl({
    redirectUri,
    scopes: ["location.verify.zone", "location.place.current"],
    state,
    codeVerifier,
    externalUserId: user.id, // optional: your id for this user, shown to you in the portal
  }),
);

// 2. On the callback (a new request): refuse a mismatched state, then
//    exchange the code with the verifier the session kept.
if (query.state !== session.contextkit.state) throw new Error("state mismatch");
const tokens = await ck.exchangeCode({
  code: query.code,
  codeVerifier: session.contextkit.codeVerifier,
  redirectUri,
});
await db.users.update(user.id, { contextkit: tokens }); // persist the WHOLE set

// 3. Make calls as that user.
const ckUser = ck.forUser(tokens, {
  onTokens: (next) => db.users.update(user.id, { contextkit: next }),
});

tokens.sub is the user's id as your app sees them: stable across reconnects, different for every app.

Persist whatever onTokens hands you. Refresh tokens rotate on every use; the handle refreshes before the access token expires, retries once on a 401, and shares one refresh across concurrent calls — presenting a spent refresh token twice is treated as a leak and ends the connection.

Ending a connection

await ckUser.disconnect(); // revokes the whole grant: every token, every rule
await ck.revokeToken(storedToken); // the same, when all you hold is a token

After disconnect() the handle refuses further calls with TokenRevokedError. Call it when a user disconnects inside your product, so the connection ends on ContextKit too.

Asking

// maxAgeS is optional everywhere it appears: how stale a fix may be and
// still count, 60–86 400 s, default 900.
await ckUser.answers.verifyZone({ lat: 48.35, lon: 11.79, radiusM: 500, label: "MUC arrivals", maxAgeS: 600 });
// → { state: "inside" | "outside" | "unknown", reason, asOf, ageSeconds }

await ckUser.answers.currentPlace();
// → { state: "at_place" | "no_place" | "unknown", place, asOf, ageSeconds }

await ckUser.answers.places();
// → { version, places: [{ id, label, kind }] } — cache by version

await ckUser.answers.presence(placeId, { maxAgeS: 300 });
// → { state: "yes" | "no" | "unknown", asOf, ageSeconds }

Parameter names are camelCase; the SDK sends the snake_case wire form the Answers page documents. Ranges are checked before any request: radiusM 100–10 000, maxAgeS 60–86 400.

Derived history

// Every field is optional; page with the cursor the last response returned.
await ckUser.visits.list({ from: "2026-09-01T00:00:00Z", to: "2026-09-30T00:00:00Z", limit: 100 });
// → { visits, nextCursor } — pass nextCursor back as { cursor } for the next page

await ckUser.visits.lookupPlace({ at: "2026-09-12T14:05:00Z", toleranceS: 900 });
// → which shared place the user was at, at that instant

Sensitive tier

Only with the location.*.read and location.lookup scopes. Every call names a registered purpose — the key of a purpose you created in the portal. The user reads its description at consent, in their access log and when they renew, so it is how they decide whether to trust you. purpose is required, and a missing or malformed key throws before any request is made.

await ckUser.locations.latest({ purpose: "live_share" });

// range: from and to are required (at most 31 days apart); the rest is optional.
// One point per five minutes by default; minIntervalS: 0 asks for every fix.
await ckUser.locations.range({ from: "2026-09-12T00:00:00Z", to: "2026-09-13T00:00:00Z", limit: 500, purpose: "trip_route" });
await ckUser.locations.days({ from: "2026-09-01", to: "2026-09-30", tz: "Europe/Rome", purpose: "trip_route" });
await ckUser.locations.at({ at: "2026-09-12T14:05:00Z", toleranceS: 300, purpose: "photo_places" });

The sensitive tier expires on its own date, at most 90 days out, separately from the rest of the connection (Two expiries). After that these calls throw ScopeExpiredError while every other call keeps working. tokens.sensitiveScopesExpiresAt (epoch ms, or null) says when.

Who is this user?

const me = await ckUser.me();
// → { sub, externalUserId, scopes, heldScopes, placesVersion, expiresAt,
//     sensitiveExpiresAt, sensitiveLapsedAt, renewalGraceEndsAt, connectedAt }

scopes is what calls may use now; heldScopes also includes a lapsed sensitive tier that can still be renewed in one confirmation. Any live token may call it.

Rules

Rule events are delivered to your app's webhook endpoints, registered once in the portal — so register one that takes rule.fired before creating rules.

const rule = await ckUser.rules.createZone({
  type: "enter", // "enter" | "exit" | "dwell" (dwell needs dwellMinutes)
  lat: 41.9028,
  lon: 12.4964,
  radiusM: 300,
  label: "Hotel Artemide",
  maxEventAgeS: 1800, // drop rather than deliver late
  activeFrom: new Date(checkIn.getTime() - 24 * 3_600_000), // Date or ISO 8601 string
  activeUntil: new Date(checkIn.getTime() + 12 * 3_600_000),
});

const placeRule = await ckUser.rules.createPlace({
  type: "dwell",
  placeId,
  dwellMinutes: 15,
});
await ckUser.rules.list();
await ckUser.rules.deleteZone(rule.id);
await ckUser.rules.deletePlace(placeRule.id);

webhookUrl is optional. Omit it and the rule delivers to every endpoint subscribed to rule.fired; set it to one of your registered endpoint URLs to deliver there. Anything else is refused with ValidationError (webhook_url_not_registered). New rules have no secret; rule.secret is deprecated and only set on rules created before app endpoints.

activeFrom / activeUntil give a rule a window: outside it the rule is not evaluated, and it counts against the ten-rules-active-at-once cap only while the window is open. See Windows, and the cap.

Connection events

places.changed and the sensitive.* events reach your endpoints for every connection automatically; there is nothing to call per user. CONNECTION_EVENTS lists their names. The sensitive.* events are how you learn the sensitive tier is ending — ContextKit sends the user no reminder of its own. Payloads are on Webhook events.

ckUser.subscriptions.register(), .get() and .remove() still work but are deprecated: once your endpoint receives connection events, remove any subscriptions you created. See the migration note.

Verifying webhooks

Verify over the raw body bytes; re-serialising a parsed body breaks the signature.

import { verifyWebhook, isRuleEvent, isPingEvent, InMemoryReplayGuard } from "@tinbee/contextkit-sdk";

const replayGuard = new InMemoryReplayGuard();

app.post("/hooks/contextkit", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = await verifyWebhook({
      rawBody: req.body,
      signature: req.header("x-contextkit-signature"), // string | undefined
      secret: process.env.CONTEXTKIT_WEBHOOK_SECRET!, // the endpoint's secret, shown once in the portal
      replayGuard,
    });
  } catch {
    return res.status(400).end();
  }
  if (isPingEvent(event)) return res.status(204).end(); // "Send test" from the portal
  if (isRuleEvent(event)) queue.enqueue(event);
  res.status(204).end();
});

verifyWebhook rejects a bad signature, a timestamp older than the tolerance (60 s by default, 300 s at most), and — with a replay guard — an event_id it has seen before. During a secret rotation the header carries one v1 per valid secret, and the delivery verifies if any of them matches the secret you pass, so you can deploy the new secret at any point in the 24-hour overlap.

InMemoryReplayGuard remembers ids in one process; with several instances, back the guard with a shared store.

isRuleEvent / isConnectionEvent / isPingEvent narrow the event type, and one guard per connection event narrows further:

import {
  isPlacesChangedEvent,
  isSensitiveExpiringEvent,
  isSensitiveLapsedEvent,
  isSensitiveRemovedEvent,
} from "@tinbee/contextkit-sdk";

if (isSensitiveExpiringEvent(event)) {
  // event.days_left is 14 or 7. One dismissible notice per event, at most.
  await notices.offerRenewal(event.grant_id, event.sensitive_expires_at);
} else if (isSensitiveLapsedEvent(event)) {
  await features.pauseExactLocation(event.grant_id, event.renewal_grace_ends_at);
} else if (isSensitiveRemovedEvent(event)) {
  await features.removeExactLocation(event.grant_id);
} else if (isPlacesChangedEvent(event)) {
  await places.refresh(event.grant_id);
}

Event bodies keep the wire's snake_case field names, including app_id and endpoint_id.

Errors

ErrorMeaningDo
TokenRevokedErrorThe connection is gone: revoked, expired, disconnected, or replayed.Send the user back to consent
RateLimitedErrorA budget or rate limit; retryAfterSeconds says how long.Wait, then retry
ScopeExpiredErrorThe sensitive tier lapsed; sensitiveExpiredAt, renewalGraceEndsAt. A ScopeError, so check it first.Offer renewal through consent
ScopeErrorThe grant lacks the scope this call needs (missing_scope).Request it at consent
NotFoundErrorUnshared and nonexistent look identical on purpose.Refresh your place list
ValidationErrorThe request shape was wrong; messages says how. error is "invalid_purpose" for a purpose the API refused, with detail.Fix the call
TimeoutError, NetworkErrorTransient.Retry with backoff
ApiErrorAny other non-2xx, with status.Depends on the status

Precondition mistakes in your own code — an unknown scope, a missing state, a missing or malformed purpose, an invalid Date — throw a plain Error before any request is made.

import { ScopeError, ScopeExpiredError } from "@tinbee/contextkit-sdk";

try {
  await ckUser.locations.at({ at: photo.takenAt, purpose: "photo_places" });
} catch (err) {
  if (err instanceof ScopeExpiredError) {
    // Ended on the date the user chose. Renewal is one tap until renewalGraceEndsAt.
    return showRenewal(err.sensitiveExpiredAt, err.renewalGraceEndsAt);
  }
  if (err instanceof ScopeError) return hideExactLocationFeature();
  throw err;
}

Other languages

Every route the SDK calls is documented on this site as plain HTTPS with JSON bodies, so any language works: Authentication for the connect flow and tokens, then Answers, Rules and Webhook events. The webhook signature scheme is on the Webhook events page.

Previous
Quickstart