A client, end to end
The other Data API pages cover one topic each. This one puts them together: a client for Acme's work orders, in TypeScript with plain fetch and no dependencies. Every request it makes is one of those pages' examples, so each step links to the details.
1. Sign in, or use a token
A person signs in with their email and password and gets a session token. A program uses a personal access token (tb_…) instead, created in API tokens. Both go in the same header. See Authentication.
const BASE = 'https://ops.acme-field.example'
async function signIn(email: string, password: string): Promise<string> {
const res = await fetch(`${BASE}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password }), // auth routes take their fields directly, not in `data`
})
if (!res.ok) throw new Error('Invalid email or password')
return (await res.json()).data.token
}
2. One function for every call
Every route answers { data, meta? }, or { errors: [...] } on a failure. One helper unwraps both, so the rest of the client never parses responses. See Request and response.
interface ApiError { code: string; message: string; field?: string; source: 'TOTALBASE' | 'POSTGRESQL' }
class DataApiError extends Error {
constructor(public status: number, public errors: ApiError[]) { super(errors[0]?.message ?? `HTTP ${status}`) }
get code() { return this.errors[0]?.code }
/** The column an error is about - show it beside that field. */
get field() { return this.errors[0]?.field }
}
async function api<T>(token: string, path: string, init: RequestInit = {}): Promise<{ data: T; meta?: Record<string, unknown>; etag?: string }> {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', ...init.headers },
})
if (res.status === 204) return { data: undefined as T }
const body = await res.json()
if (!res.ok) throw new DataApiError(res.status, body.errors)
return { data: body.data, meta: body.meta, etag: res.headers.get('etag') ?? undefined }
}
3. List with a filter, a relation and a page
fields chooses columns and follows foreign keys with a dot (site.name reads the site's name in the same request). where filters, order_by sorts, limit and offset page, and count=true adds the total. See Reading and Filtering.
interface Order { id: string; number: string; status: string; priority: string; 'site.name': string; due_at: string }
async function openOrders(token: string, page = 0, pageSize = 20) {
const query = new URLSearchParams({
fields: 'id,number,status,priority,site.name,due_at',
'where[status][_in]': 'NEW,ASSIGNED,IN_PROGRESS',
order_by: 'due_at',
limit: String(pageSize),
offset: String(page * pageSize),
count: 'true',
})
const { data, meta } = await api<Order[]>(token, `/api/public/table/work_orders?${query}`)
return { orders: data, total: meta?.count as number }
}
Your roles decide what comes back. A row filter narrows the rows (a technician sees only their orders); a column without a field grant makes the request 403 FIELD_PERMISSION_DENIED, so ask only for columns your role may read. Docs › OpenAPI lists exactly those, for your roles.
4. Create
POST with { "data": {...} } answers 201 with the row as PostgreSQL stored it, so defaults and values set by triggers (number, due_at) come back too. See Writing.
async function reportOrder(token: string, site: string, branch: string, description: string) {
const { data } = await api<Order>(token, '/api/public/table/work_orders', {
method: 'POST',
body: JSON.stringify({ data: { type: 'REPAIR', priority: 'NORMAL', site, branch, description } }),
})
return data // data.number is "WO-000413", set by the database
}
To create a row with its children in one request (an order with its parts), see Nested writes. For several tables in one transaction, see Transactions.
5. Update without overwriting someone else's change
Read the row by key: its ETag is the row's version. Send it back in If-Match, and if someone changed the row in between, the update answers 409 ROW_VERSION_CONFLICT instead of overwriting it. See Optimistic writes.
async function assign(token: string, id: string, technician: string) {
const { etag } = await api<Order>(token, `/api/public/table/work_orders/${id}`)
const { data } = await api<Order>(token, `/api/public/table/work_orders/${id}`, {
method: 'PATCH',
headers: { 'if-match': etag! },
body: JSON.stringify({ data: { status: 'ASSIGNED', technician } }),
})
return data
}
6. Handle errors by their code
Branch on code, never on the message: codes are stable. The message is written for a person. A constraint's message is its author's own words, and field names the column, so a form can show it beside the field. See Errors.
try {
await assign(token, id, technician)
} catch (error) {
if (!(error instanceof DataApiError)) throw error
switch (error.code) {
case 'ROW_VERSION_CONFLICT': /* reload the row and show the newer values */ break
case 'CHECK_VIOLATION':
case 'NOT_NULL_VIOLATION':
case 'UNIQUE_VIOLATION': /* show error.message under error.field */ break
case 'ACTION_DENIED': /* "No UPDATE grant on public.work_orders for your roles." - hide the action */ break
case 'AUTHENTICATION_REQUIRED':
case 'SESSION_EXPIRED': /* sign in again */ break
case 'RATE_LIMIT_EXCEEDED': /* wait the Retry-After header's seconds */ break
default: throw error
}
}
7. Types from the OpenAPI document
GET /api/openapi.json describes every table, view and function your roles reach, with their columns and types. A generator turns it into TypeScript types, so Order above comes from the database instead of being written by hand:
curl -s https://ops.acme-field.example/api/openapi.json -H "authorization: Bearer $TOKEN" > openapi.json
npx openapi-typescript openapi.json -o data-api.d.ts
The document follows your permissions and changes when the schema or a grant changes, so regenerate it after a change. See OpenAPI.
Next steps
- Filtering: every operator, relative dates and periods.
- Aggregating: totals and groups for a dashboard of your own.
- Calling functions: business logic that runs in PostgreSQL.