Skip to main content

Practical Implementation Guide

This guide documents patterns and gotchas discovered while building a real application on top of the ZeyOS JavaScript client. It supplements the reference documentation with things that only become apparent once you start making actual API calls.

HTTP Method Conventions

ZeyOS uses an unconventional but consistent REST convention that often surprises developers:

OperationHTTP MethodNotes
List recordsPOSTQuery params (filters, sort, fields) go in the request body
Get a recordGET
Create a recordPUTNot POST — ZeyOS uses PUT for creation
Update a recordPATCHPartial updates; only send fields you want to change
Delete a recordDELETE
Check existenceHEADReturns true (no body) on success

The most important one to internalise: list operations are POST requests. This makes sense once you consider that complex queries with nested filters would quickly exceed URL length limits as query strings.

Passing the Request Body for Update Operations

Generated methods accept the natural flat style for operations that have both a path parameter and a request body:

JavaScript
await client.api.updateTicket({ ID: 42, status: 4, priority: 2 });

The client routes known path/query/header parameters to the request URL and sends the remaining non-reserved keys as the body. Explicit body and data keys are still supported when you want to separate those concerns yourself:

JavaScript
await client.api.updateTicket({ ID: 42, body: { status: 4, priority: 2 } });
await client.api.updateTask({ ID: taskId, data: { name: 'New name', duedate: ts } });

For low-level client.request() calls, prefer explicit body because there is no generated operation metadata to tell the client which keys are URL parameters.

filter vs filters

The ZeyOS API exposes two distinct filtering parameters, and the one you need depends on the field type:

ParameterUse forExample
filterSimple scalar fields (integers, strings, enums)filter: { visibility: 0, status: 1 }
filtersGIN-indexed fields — foreign key references and array-type columnsfilters: { ticket: ticketId, project: projectId }

In practice this means:

JavaScript
// Listing tickets — status and visibility are scalar fields → use 'filter'
const tickets = await client.api.listTickets({
filters: { visibility: 0, project: projectId },
sort: ['-lastmodified'],
limit: 500,
});

// Listing tasks for a ticket — 'ticket' is a GIN-indexed FK → use 'filters'
const tasks = await client.api.listTasks({
fields: ['ID', 'tasknum', 'name', 'duedate', 'assigneduser'],
filters: { ticket: ticketId, visibility: 0 },
sort: ['+name'],
limit: 200,
});
tip

When in doubt, use filters. It appears to handle both scalar and FK fields correctly. Using filter for a FK field silently returns unfiltered results rather than throwing an error, which makes this particularly easy to miss.

Always Include visibility: 0

ZeyOS records have a visibility field that controls soft-deletion and archiving. Records with visibility > 0 are typically hidden from normal views. Always include visibility: 0 in your filters unless you intentionally want to retrieve archived or deleted records:

JavaScript
const filter = { visibility: 0 };
// Add resource-specific filters after
if (projectId) filter.project = projectId;

Normalising List Responses

List operations are not perfectly uniform across the whole surface area. Use the shared helper so every call site follows the same response-shape handling:

JavaScript
import { normalizeListResult } from '@zeyos/client';

const result = await client.api.listTickets({ filters: { visibility: 0 } });
const { data: tickets } = normalizeListResult(result);

Use normalizeCountResult() for count-only requests.

Date and Timestamp Handling

ZeyOS stores all dates as Unix timestamps in seconds (not milliseconds). When reading:

JavaScript
// Convert to a JavaScript Date
const date = new Date(ticket.duedate * 1000);

// Format for display
const label = new Date(ticket.duedate * 1000).toLocaleDateString(undefined, {
month: 'short', day: 'numeric', year: 'numeric',
});

// Check if overdue
const isOverdue = ticket.duedate * 1000 < Date.now();

When writing (e.g. from an HTML <input type="date">):

JavaScript
const dueDateVal = form.querySelector('#due-date').value; // '2026-03-15'
const duedate = dueDateVal
? Math.floor(new Date(dueDateVal).getTime() / 1000)
: null;

await client.api.updateTicket({ ID: id, body: { duedate } });

Selecting Fields for Performance

Always pass a fields array in list requests. Without it, every field on every record is returned, which can significantly increase payload size and response time:

JavaScript
// ✗ Returns all fields for every ticket
const tickets = await client.api.listTickets({ limit: 500 });

// ✓ Returns only what you need
const tickets = await client.api.listTickets({
fields: ['ID', 'ticketnum', 'name', 'status', 'priority', 'duedate'],
filters: { visibility: 0 },
limit: 500,
});

For single-record GET operations, field selection is not available — all standard fields are returned. Use query flags like extdata: 1 and tags: 1 to opt into additional data:

JavaScript
const ticket = await client.api.getTicket({ ID: id, extdata: 1, tags: 1 });

Optimistic UI Updates with Server Verification

For immediate feedback on user actions (like drag-and-drop), apply the change to local state first, then confirm with the server and revert if it fails. Use the response body to confirm the actual resulting value:

JavaScript
const fromStatus = ticket.status;

// 1. Optimistic update — instant visual feedback
ticket.status = toStatus;
updateColumn(fromStatus);
updateColumn(toStatus);

try {
// 2. Send PATCH — response body contains the updated record
const updated = await client.api.updateTicket({
ID: ticket.ID,
body: { status: toStatus },
});

// 3. Confirm — use the server's value in case it was clamped or rejected
const confirmedStatus = updated?.status ?? toStatus;
if (confirmedStatus !== toStatus) {
ticket.status = confirmedStatus;
updateColumn(toStatus);
updateColumn(confirmedStatus);
}
} catch (err) {
// 4. Revert on failure
ticket.status = fromStatus;
updateColumn(fromStatus);
updateColumn(toStatus);
showError(`Move failed: ${err.message}`);
}

Persisting Refreshed Tokens

When using token mode with autoRefresh: true in a trusted environment, the client silently refreshes expired access tokens. The refreshed tokens are stored in the MemoryTokenStore but lost on page reload unless you persist them explicitly. Call a sync function after important API operations:

JavaScript
async function syncTokens() {
try {
const ts = await client.auth.getTokenSet();
if (ts?.accessToken) {
localStorage.setItem('zeyos_tokens', JSON.stringify({
accessToken: ts.accessToken,
refreshToken: ts.refreshToken,
expiresAt: ts.expiresAt,
refreshTokenExpiresAt: ts.refreshTokenExpiresAt,
}));
}
} catch {
// Non-critical — silently ignore
}
}

// Usage
const tickets = await client.api.listTickets({ filters: { visibility: 0 } });
await syncTokens(); // Persist any refreshed tokens

Session Detection Without Tokens

If you don't have an OAuth token but the user is already logged into ZeyOS in the same browser, you can detect their session via the userinfo endpoint:

JavaScript
async function trySessionAuth(instanceUrl) {
try {
const res = await fetch(`${instanceUrl}oauth2/v1/userinfo`, {
credentials: 'include',
});
if (res.ok) return await res.json();
} catch {
// No session
}
return null;
}

const userInfo = await trySessionAuth('https://cloud.zeyos.com/demo/');
if (userInfo) {
// Session is active — initialize in session mode
const client = createZeyosClient({
platform: instanceUrl,
auth: { mode: 'session', session: { enabled: true, credentials: 'include' } },
});
}
note

Session mode requires that your app is served from the same origin as ZeyOS, or that the ZeyOS instance is configured to allow cross-origin requests with credentials. If you are on a different domain, token mode is more reliable.

To link users directly to a record inside the ZeyOS web interface, construct a URL in this format:

Text
<INSTANCE_URL>?umi=<MODULE>&page=<PAGE>&id=<RECORD_ID>&tab=<TAB>

Common examples:

JavaScript
const baseUrl = 'https://cloud.zeyos.com/demo/';

// Link to a ticket
`${baseUrl}?umi=tickets&page=details_ticket&id=${ticketId}&tab=0`

// Link to a task (within the tickets module)
`${baseUrl}?umi=tickets&page=details_ticket&id=${taskId}&tab=0`

// Link to an account
`${baseUrl}?umi=accounts&page=details_account&id=${accountId}&tab=0`

Extended Data (extdata)

Many ZeyOS entities support custom fields via extdata. These are returned as a nested object:

JavaScript
// Request extended data in a list
const tickets = await client.api.listTickets({
fields: ['ID', 'name', 'extdata.region', 'extdata.customer_type'],
filters: { visibility: 0 },
});

// Or include all extdata for single-record fetches
const ticket = await client.api.getTicket({ ID: id, extdata: 1 });
console.log(ticket.extdata); // { region: 'EMEA', customer_type: 'Enterprise', ... }

When saving extended data back, pass it as a plain object:

JavaScript
await client.api.updateTicket({
ID: id,
body: {
extdata: { region: 'APAC', customer_type: 'SMB' },
},
});

Common Status and Priority Values

Ticket and task status and priority values are plain integers. The canonical values observed in the ZeyOS API:

Ticket Status

ValueLabel
0Not Started
1Awaiting Acceptance
2Accepted
3Rejected
4Active
5Inactive
6Feedback Required
7Testing
8Cancelled
9Completed
10Failed
11Booked

Ticket Priority

ValueLabel
0Lowest
1Low
2Medium
3High
4Highest

Error Handling Checklist

ZeyosApiError is thrown for all non-2xx responses. Key properties to check:

JavaScript
import { ZeyosApiError } from '@zeyos/client';

try {
await client.api.updateTicket({ ID: id, body: data });
} catch (err) {
if (!(err instanceof ZeyosApiError)) throw err; // Re-throw unexpected errors

if (err.status === 401) {
// Session expired or token invalid — redirect to login
} else if (err.status === 403) {
// Insufficient permissions
} else if (err.status === 404) {
// Record does not exist
} else if (err.status === 409) {
// Conflict — record was modified since last read (check If-Match header usage)
} else {
// Generic error — err.body often contains a human-readable message
console.error(err.body?.message ?? err.message);
}
}
tip

On 401, the client automatically retries with a refreshed token if autoRefresh: true is set, a refresh token is available, and OAuth client credentials are configured. You will only see a 401 error if the refresh also fails — typically meaning the user's session has fully expired.