Skip to content

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 otherwise

List 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 address
  • Location.GeoJson is resolved automatically via geocoding whenever an address is given and Location is omitted — only set Location yourself if you already have exact coordinates.
  • DisplayDate defaults to Date (or the current time, if no Date) 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.
  • ServiceItems is the planned fixed-price line-item breakdown (labor/parts/tasks/charges) — required (at least one line) when IsTM is false. Maintenance visits get theirs auto-copied from the agreement instead.
  • ServiceAssets links the client's own equipment being serviced (e.g. a specific HVAC unit) — looked up by Id from 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 undefined

Soft-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 string

Batch-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);
  }
}