Skip to Content
SDKs & Integrations@rustrak/client

@rustrak/client

@rustrak/client is the official TypeScript client for the Rustrak REST API. It provides full type safety via Zod, automatic retry logic, cursor-based pagination, and a Result return type that never throws for an expected failure, all in ~28 KB.

npm install @rustrak/client # or pnpm add @rustrak/client # or yarn add @rustrak/client

Requirements: Node.js ≥ 20, TypeScript ≥ 5

Quick Start

Every method returns a Result<T, RustrakError>: a plain object that is either { success: true, data } or { success: false, error }. Check success before reading data, and TypeScript narrows the rest.

import { RustrakClient } from '@rustrak/client'; const client = new RustrakClient({ baseUrl: 'https://your-rustrak-instance.example.com', token: process.env.RUSTRAK_API_TOKEN!, }); const projects = await client.projects.list(); if (!projects.success) { console.error(projects.error.kind); // 'unauthenticated' | 'network' | ... return; } console.log(projects.data.items);

The shape deliberately mirrors Zod’s safeParse, and it is the same shape everywhere: 86 methods, one convention.

Configuration

const client = new RustrakClient({ baseUrl: 'https://rustrak.example.com', // required token: 'your-bearer-token', // required — create one in Settings → Tokens timeout: 30000, // optional, ms (default: 30000) maxRetries: 2, // optional (default: 2) headers: {}, // optional custom headers });

Projects

What each method returns:

client.projects.list() // Result<OffsetPaginatedResponse<Project>> client.projects.get(1) // Result<Project> client.projects.create({ name: 'My App', slug: 'my-app' }) // Result<Project> client.projects.update(1, { name: 'New Name' }) // Result<Project> client.projects.delete(1) // Result<void>

And how you use one:

const project = await client.projects.get(1); if (!project.success) { console.error('Could not load the project:', project.error.message); return; } console.log(project.data.dsn);

A delete returns a Result<void> too. A failed Result<void> is a value, not an exception, so nothing stops you ignoring it. Check success on writes as well as reads:

const deleted = await client.projects.delete(1); if (!deleted.success) { console.error('The project was not deleted:', deleted.error.message); }

Issues

// List with filters const issues = await client.issues.list(projectId, { sort: 'last_seen', // 'digest_order' | 'last_seen' | 'event_count' order: 'desc', // 'asc' | 'desc' filter: 'open', // 'open' | 'resolved' | 'muted' | 'all' page: 1, per_page: 20, }); if (issues.success) { for (const issue of issues.data.items) { console.log(issue.short_id, issue.title); } } const issue = await client.issues.get(projectId, issueId); if (issue.success) { console.log(issue.data.title); } const resolved = await client.issues.updateState(projectId, issueId, { is_resolved: true, }); if (!resolved.success) { console.error('Could not resolve:', resolved.error.message); } const removed = await client.issues.delete(projectId, issueId); if (!removed.success) { console.error('Could not delete:', removed.error.message); }

Events

const events = await client.events.list(projectId, issueId, { order: 'desc' }); if (events.success) { console.log(`${events.data.items.length} events`); } const event = await client.events.get(projectId, issueId, eventId); if (event.success) { console.log(event.data.data); // Full Sentry event payload }

Paginating

let cursor: string | undefined; do { const page = await client.events.list(projectId, issueId, { cursor }); if (!page.success) break; // a failed page is not an empty page process(page.data.items); cursor = page.data.next_cursor; } while (cursor);

That break is the point of the whole API. Stopping on a failure is a decision the caller has to make, and it can no longer be skipped by accident.

Auth Tokens

const tokens = await client.tokens.list(); if (tokens.success) { console.log(`${tokens.data.length} tokens`); } const created = await client.tokens.create({ description: 'CI token' }); if (created.success) { console.log(created.data.token); // Save this: shown only once } const revoked = await client.tokens.delete(1); if (!revoked.success) { console.error('Could not revoke:', revoked.error.message); }

Error Handling

There are no error classes. Every failure is one member of a single closed union, discriminated by kind:

import { isRetryable, type RustrakError } from '@rustrak/client'; const result = await client.projects.list(); if (!result.success) { switch (result.error.kind) { case 'unauthenticated': redirect('/login'); // ONLY this kind means "log in again" break; case 'rate_limited': wait(result.error.retryAfter ?? 30); break; case 'network': // Branch on `reason`, not on `message`: the message is a fixed string by // design, because the underlying one names the host and port. console.log(result.error.reason); // 'timeout' | 'unreachable' break; default: if (isRetryable(result.error)) scheduleRetry(); } }
kindHTTPRetryableDescription
validation400The server rejected the request
unauthenticated401No session, or an invalid/expired token
forbidden403Authenticated, but not allowed
not_found404The resource does not exist
conflict409Uniqueness violation: a taken name or slug
gone410The resource was retired
payload_too_large413Envelope ingestion only
rate_limited429Rate limit exceeded; carries retryAfter?: number
client_errorother < 500Any sub-500 status no other member claims
server_error500+Any 5xx; the server’s own message is discarded
invalid_requestn/aYour input failed a pre-flight check; never sent
networkn/aDNS, connection refused, TLS, timeout, abort
invalid_responsen/aA 2xx arrived with a body the schema rejects

The union is closed, so a switch on kind with a default covers every case that exists. status is a plain number rather than a literal union, so a proxy-generated 502 or a future status is a value you can log rather than a type error.

What the union will not carry

DroppedWhy
the server’s 5xx messageIt can interpolate a pool error, an OS errno, or a filesystem path. Replaced by the fixed SERVER_ERROR_MESSAGE.
a cause on networkfetch builds that message by interpolating the request URL, which is your deployment’s internal host and port. Read reason instead.
Zod issues on invalid_responseThey embed the offending response body.

Field errors

A 4xx that blamed specific inputs carries fields, so a form can mark the input the server rejected instead of showing a toast the user has to translate back into an edit:

const created = await client.projects.create({ name: 'My App' }); if (!created.success && created.error.kind === 'conflict') { for (const field of created.error.fields ?? []) { console.log(field.field, field.code); // e.g. 'slug' 'already_exists' } }

field is a dot path into the request body (slug, credentials.webhook_url), which is exactly what a form library’s setError takes. Pick the copy you show from (field, code) rather than from message; that is what makes it translatable. message is populated only for code: 'custom', where the code set genuinely cannot express the reason.

One rule when wiring this to a form: only call setError for names your form actually registers. A path naming an input the form does not have registers a phantom field that nothing can ever clear, and the form then refuses to submit with nothing on screen explaining why. Route the rest to a form-level error.

Escape hatches

import { unwrap, unwrapOr, mapResult } from '@rustrak/client';

unwrap throws on a failure: the caller explicitly opting back into exceptions. unwrapOr substitutes a fallback.

Do not reach for unwrapOr to make a page compile. unwrapOr(await client.projects.list(), []) renders the same empty state for “this account has no projects” and “the server is unreachable”, which is exactly the regression this API exists to prevent. It is appropriate only where the fallback is genuinely correct regardless of why the call failed: a cached-count optimisation, a best-effort telemetry read, a script that already logged the error.

Next.js Integration

A Result is a plain object with no prototype chain, so it survives structuredClone and therefore React’s server/client boundary. A thrown error class does not: it reaches the browser as an opaque digest, which is why returning the failure is the recommended shape.

Server Component

import { RustrakClient } from '@rustrak/client'; import { redirect } from 'next/navigation'; export default async function ProjectsPage() { const client = new RustrakClient({ baseUrl: process.env.RUSTRAK_API_URL!, token: process.env.RUSTRAK_API_TOKEN!, }); const projects = await client.projects.list(); if (!projects.success) { // `kind` decides. Redirecting on 'network' or 'server_error' turns a flaky // connection into a login loop that logging in cannot fix. if (projects.error.kind === 'unauthenticated') redirect('/auth/login'); return <LoadFailed error={projects.error} />; } return <ProjectsList projects={projects.data.items} />; }

Server Action

'use server'; import { RustrakClient } from '@rustrak/client'; export async function resolveIssue(projectId: number, issueId: string) { const client = new RustrakClient({ baseUrl: process.env.RUSTRAK_API_URL!, token: process.env.RUSTRAK_API_TOKEN!, }); // Returned as-is: the calling component gets the actual failure rather than // an "An error occurred in the Server Components render" digest. return client.issues.updateState(projectId, issueId, { is_resolved: true }); }

Client Component with SWR

'use client'; import useSWR from 'swr'; export function IssuesList({ projectId }: { projectId: number }) { // The fetcher never rejects, so SWR's `error` stays empty: the failure is in // `data`. Unwrap it in the fetcher if you want SWR's retry behaviour. const { data } = useSWR(['issues', projectId], () => client.issues.list(projectId), ); if (!data) return <Spinner />; if (!data.success) return <LoadFailed error={data.error} />; return <List items={data.data.items} />; }

TypeScript Types

All types are exported and inferred from Zod schemas:

import type { Project, Issue, Event, EventDetail, AuthToken, PaginatedResponse, CreateProject, UpdateIssueState, // the Result API Result, RustrakError, RustrakErrorKind, FieldError, FieldErrorCode, } from '@rustrak/client';

Value exports for the same API: Ok, Err, unwrap, unwrapOr, mapResult, isRetryable, FIELD_ERROR_CODES, SERVER_ERROR_MESSAGE, NETWORK_ERROR_MESSAGE, TIMEOUT_ERROR_MESSAGE.

  • @rustrak/mcp — MCP server built on top of this client; gives Claude, Cursor, and Continue direct access to your Rustrak instance
  • API Reference — Full REST API documentation
  • API Tokens — How to create and manage tokens
Last updated on