Appearance
Cookbook
Worked examples for common flows on the v2 API. The main examples below are in TypeScript using native fetch; a few common shapes are shown across multiple languages first. All examples assume you've set an auth header — either OAuth Bearer or kauth <token>. See Getting Started.
The live API reference renders per-endpoint code samples in every common language (curl, JavaScript fetch, Python httpx, Ruby net/http, PHP, Go, etc.) — copy from there for any specific endpoint.
A request in five languages
The same request — list open projects modified in the last month — in the languages we get asked about most:
ts
const res = await fetch(
'https://developers.knowify.com/api/v2/projects?where[BusinessState]=Open&limit=10',
{ headers: { Authorization: `Bearer ${token}` } },
);
const { data, meta } = await res.json();bash
curl 'https://developers.knowify.com/api/v2/projects?where[BusinessState]=Open&limit=10' \
-H "Authorization: Bearer ${TOKEN}"python
import httpx
res = httpx.get(
"https://developers.knowify.com/api/v2/projects",
params={"where[BusinessState]": "Open", "limit": 10},
headers={"Authorization": f"Bearer {token}"},
)
res.raise_for_status()
data, meta = res.json()["data"], res.json()["meta"]ruby
require "net/http"
require "json"
uri = URI("https://developers.knowify.com/api/v2/projects")
uri.query = URI.encode_www_form("where[BusinessState]" => "Open", "limit" => 10)
req = Net::HTTP::Get.new(uri, "Authorization" => "Bearer #{token}")
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
body = JSON.parse(res.body)go
req, _ := http.NewRequest("GET", "https://developers.knowify.com/api/v2/projects?where[BusinessState]=Open&limit=10", nil)
req.Header.Set("Authorization", "Bearer "+token)
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
var body struct {
Data []map[string]any `json:"data"`
Meta map[string]any `json:"meta"`
}
json.NewDecoder(res.Body).Decode(&body)For everything else below, examples are in TypeScript with fetch. The same patterns translate to any language — the wire format (URLs, JSON bodies, headers) is what matters.
A small helper
ts
const BASE = 'https://developers.knowify.com';
const AUTH_HEADER = `Bearer ${ACCESS_TOKEN}`;
// or: const AUTH_HEADER = `kauth ${KAUTH_TOKEN}`;
const api = async <T>(path: string, init: RequestInit = {}): Promise<T> => {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: {
Authorization: AUTH_HEADER,
'Content-Type': 'application/json',
...init.headers,
},
});
if (!res.ok) {
const problem = await res.json();
throw Object.assign(new Error(problem.title), problem);
}
return res.status === 204 ? (undefined as T) : res.json();
};Who am I?
Verify your token and identify the calling user.
ts
const me = await api<{
account_id: string;
client_id: string;
scopes: string[];
user: { Id: number; Name: string; Email: string } | null;
}>('/api/v2/me');
console.log(me.user?.Name, me.scopes);For a quick token-validity probe without the user lookup:
ts
await api('/api/v2/valid'); // throws on 401, returns the OAuth context otherwiseList with a filter
Get the 50 most recent open projects:
ts
const { data, meta } = await api<{ data: Project[]; meta: Meta }>(
'/api/v2/projects?where[BusinessState]=Open&order[0][0]=DateCreated&order[0][1]=DESC&limit=50'
);
console.log(`${meta.total} open projects; showing ${data.length}`);For more complex filters, use the POST form:
ts
const { data } = await api<{ data: Project[] }>('/api/v2/projects/query', {
method: 'POST',
body: JSON.stringify({
where: {
$or: [
{ BusinessState: 'Open' },
{ BusinessState: 'Draft' },
],
ClientId: { $in: [42, 101, 7] },
DateModified: { $gte: '2026-01-01T00:00:00Z' },
},
order: [['DateCreated', 'DESC']],
fields: ['Id', 'ProjectName', 'ClientId', 'BusinessState'],
limit: 100,
}),
});Paginate through all records
meta.has_more is the signal — keep going until it's false:
ts
async function* paginate<T>(path: string, limit = 100): AsyncIterable<T> {
let offset = 0;
while (true) {
const url = `${path}${path.includes('?') ? '&' : '?'}limit=${limit}&offset=${offset}`;
const { data, meta } = await api<{ data: T[]; meta: Meta }>(url);
for (const item of data) yield item;
if (!meta.has_more) return;
offset += data.length;
}
}
for await (const project of paginate<Project>('/api/v2/projects?where[BusinessState]=Open')) {
// ...
}Incremental sync — only what changed
Pull every record modified since your last sync timestamp:
ts
let cursor = lastSyncTimestamp; // e.g. '2026-04-01T00:00:00Z'
for await (const item of paginate<Item>(`/api/v2/list-items?since=${cursor}`)) {
await indexLocally(item);
}
// Bump cursor to "now" for next sync
cursor = new Date().toISOString();since filters on DateModified >= cursor. Combine with where and order like any other query.
Get one project, with its client included
ts
const project = await api<Project & { Client: Client }>(
'/api/v2/projects/102345?include[]=Client'
);
console.log(project.ProjectName, project.Client?.Name);Create a project
ts
const created = await api<Project>('/api/v2/projects', {
method: 'POST',
body: JSON.stringify({
ProjectName: 'New Job',
ClientId: 42,
BusinessState: 'Draft',
BudgetAmount: 50000,
}),
});
// `created` is the full project; `Location` response header is also set:
console.log(`Created /api/v2/projects/${created.Id}`);Create a service job
ts
const job = await api<ServiceTicket>('/api/v2/service-tickets', {
method: 'POST',
body: JSON.stringify({
ClientId: 42,
Title: 'Quarterly HVAC maintenance visit',
Date: '2026-09-15',
IsTM: false, // fixed-price — see ServiceItems below
Address1: '45 Broadway',
City: 'New York',
StateProvince: 'NY',
Zip: '10006',
ServiceItems: [
{ Description: 'HVAC diagnostic', Price: 150, Quantity: 1 },
{ Description: 'Replacement filter', Price: 45, Quantity: 2 },
],
ServiceAssets: [{ Id: 336 }], // the client's existing asset, looked up by Id
}),
});
console.log(job.Location?.GeoJson); // resolved automatically from the addressLocation.GeoJsonis resolved automatically via geocoding whenever an address is given andLocationis omitted — only setLocationyourself if you already have exact coordinates.DisplayDatedefaults toDate(or the current time, if noDate) when you don't set it — this drives the "Created On" column in the app's Manage Service Jobs table; a job created without it shows blank there.ServiceItemsis the planned fixed-price line-item breakdown (labor/parts/tasks/charges) — required (at least one line) whenIsTMis false. Maintenance visits get theirs auto-copied from the agreement instead.ServiceAssetslinks the client's own equipment being serviced (e.g. a specific HVAC unit) — looked up byIdfrom the client's existing assets; this call doesn't create new ones.
Schedule technicians on a service job
Pass a nested Allocations array on the service-ticket create call itself, with IsV2: true — upstream ignores Allocations without it:
ts
const job = await api<ServiceTicket>('/api/v2/service-tickets', {
method: 'POST',
body: JSON.stringify({
ClientId: 42,
Title: 'Quarterly HVAC maintenance visit',
Date: '2026-09-15',
IsTM: false,
IsV2: true,
Allocations: [
{
ResourceId: 41195,
AllocationType: 'TimeRange',
StartTime: '2026-09-15T09:00:00',
EndTime: '2026-09-15T11:00:00',
Duration: 2,
IsBillable: true,
},
{
ResourceId: 41195,
AllocationType: 'TimeRange',
StartTime: '2026-09-15T14:00:00',
EndTime: '2026-09-15T16:00:00',
Duration: 2,
IsBillable: true,
},
],
}),
});The ticket and its allocations are created atomically — a bad allocation fails the whole create rather than leaving a partially-scheduled ticket. To schedule several technicians on the same time block (as opposed to one tech across several blocks, shown above), give each of their allocation entries the same GroupId.
Read back what was actually scheduled with a follow-up query, since upstream can fill in defaults not present in the request:
ts
const allocations = await api<Allocation[]>(`/api/v2/allocations?where[ServiceTicketId]=${job.Id}`);Equipment-type resources (vehicles, tools) schedule the same way — Allocations doesn't distinguish resource type. That's a different concept from ServiceAssets above, which links the client's equipment being serviced, not your own.
Add a technician to an already-scheduled service job
PATCH the ticket with only the new entries — the API preserves the existing schedule automatically, you don't need to look it up and resend it yourself:
ts
const updated = await api<ServiceTicket>(`/api/v2/service-tickets/${job.Id}`, {
method: 'PATCH',
body: JSON.stringify({
Allocations: [
{ ResourceId: 56144, AllocationType: 'TimeRange', StartTime: '2026-09-15T09:00:00', EndTime: '2026-09-15T11:00:00', Duration: 2, IsBillable: true },
],
}),
});Omitting Allocations entirely on a PATCH leaves scheduling untouched, same as any other field you don't send.
Update a project
ts
const updated = await api<Project>(`/api/v2/projects/${id}`, {
method: 'PATCH',
body: JSON.stringify({ BusinessState: 'Open' }),
});Read-only fields (Id, DateCreated, DateModified, computed totals, lifecycle states) are silently ignored if you send them — safe to do PATCH with an entire entity object.
Delete a project
ts
await api(`/api/v2/projects/${id}`, { method: 'DELETE' }); // 204, returns undefinedSoft-delete — the record stops appearing in normal queries but isn't physically removed.
Time check-in / check-out
Start a tracked work session:
ts
const entry = await api<TimeEntry>('/api/v2/time-entries/check-in', {
method: 'POST',
body: JSON.stringify({
ResourceId: myResourceId,
ProjectId: 102345,
Notes: 'On site, starting framing',
}),
});
const entryId = entry.Id;Close it later:
ts
const closed = await api<TimeEntry>(`/api/v2/time-entries/${entryId}/check-out`, {
method: 'POST',
body: JSON.stringify({ Notes: 'Wrapped up north-wall framing' }),
});
console.log(`Worked ${closed.Time}`); // duration stringBatch-approve a stack of submitted entries:
ts
await api<{ approved: number[] }>('/api/v2/time-entries/approve', {
method: 'POST',
body: JSON.stringify({ Ids: [102345, 102346, 102347] }),
});List outstanding invoices for a client
Combining a where filter and a named scope:
ts
const { data } = await api<{ data: Invoice[] }>(
'/api/v2/invoices/query',
{
method: 'POST',
body: JSON.stringify({
where: { ClientId: 42, OutstandingAmount: { $gt: 0 } },
order: [['InvoiceDate', 'ASC']],
limit: 100,
}),
},
);For combined regular + AIA invoices, use the dedicated endpoint:
ts
const { data } = await api<{ data: AnyInvoice[] }>(
'/api/v2/invoices/all?where[ClientId]=42&where[OutstandingAmount][$gt]=0'
);
// data[i].InvoiceType is "Standard" or "AIA" — use (Id, InvoiceType) as the unique key.Batch query — multiple resources in one request
Fetch projects, invoices, and open time entries in a single round-trip:
ts
const result = await api<{
projects: { data: Project[]; meta: Meta };
invoices: { data: Invoice[]; meta: Meta };
'time-entries': { data: TimeEntry[]; meta: Meta };
}>('/api/v2/query', {
method: 'POST',
body: JSON.stringify({
projects: { where: { BusinessState: 'Open' }, limit: 50 },
invoices: { where: { OutstandingAmount: { $gt: 0 } }, limit: 50 },
'time-entries': { where: { BusinessState: 'Submitted' }, limit: 50 },
}),
});
console.log(result.projects.meta.total, 'open projects');
console.log(result.invoices.meta.total, 'outstanding invoices');Batch requires the read meta-scope (covers all per-resource :read). All entries validate before any DB work runs — a single bad entry fails the whole batch with a 400.
Error handling
ts
try {
await api('/api/v2/projects/99999999');
} catch (err: any) {
// RFC 7807 problem details — err carries `code`, `title`, `detail`, `status`
if (err.code === 'project_not_found') {
console.log('Not found:', err.detail);
} else if (err.status === 401) {
// Reauth needed
} else {
console.error('Unexpected error:', err);
}
}For validation errors, err.issues is an array of { path, message, code } entries describing each failed field:
ts
try {
await api('/api/v2/projects', {
method: 'POST',
body: JSON.stringify({ /* missing required fields */ }),
});
} catch (err: any) {
for (const issue of err.issues ?? []) {
console.log(issue.path.join('.'), '—', issue.message);
}
}