00 Developers

The Tech Ops API

Read your tickets, clients, assets, invoices and more from your own systems, and hear about changes as they happen.

If you run an accounting package, a BI dashboard, a customer portal of your own, or you're a supplier or a building owner who wants a live view of the work, this is how your system talks to Tech Ops. Version 1 reads. Writing (making tickets, changing status, adding notes) is next.

What you can read

A key sees what its role lets it see, the same as a person with that role in the app. A key with no price switches gets no money fields.

Included with every paid plan

The API is part of every paid plan, trial included. No add-on, no per-call charge. A company can have 5 live keys. If your plan ends or goes read-only, keys stop answering until it's back.

Getting a key

Any admin in your company makes keys in the app: Admin settings › Data and integrations › Integrations & API › API keys.

  1. Give it a name you'll recognize later (“QuickBooks sync”, “Power BI”).
  2. Pick what it may see. API read-only sees everything an office manager reads (no profit margins). Or pick any role your company uses.
  3. Optional: an expiry date, and the addresses it may call from (like 203.0.113.0/24).
  4. Copy the key. It's shown once. We keep only a fingerprint of it, so if it's lost, roll it.
Giving a customer access? Make a client key. A client key is tied to one of your clients and sees only what that client sees in their portal: their sites, their tickets, their equipment, and the quotes and invoices you've sent them. Never a draft, never another client.

Roll makes a new key and keeps the old one working for 24 hours so you can swap it without downtime. Revoke stops a key on its very next call. Every admin gets an email when a key is made, rolled or revoked, and the first time a key is used from a new address. Each key shows its last 100 calls in the app.

Quick start

Send the key as a bearer token. Every address starts with https://emchorizons.com/tech_ops_api/v1.

curl -H "Authorization: Bearer tok_live_YOUR_KEY" \
  https://emchorizons.com/tech_ops_api/v1/me

Open tickets changed since yesterday morning, 200 at a time:

curl -H "Authorization: Bearer tok_live_YOUR_KEY" \
  "https://emchorizons.com/tech_ops_api/v1/tickets?active=true&updated_since=2026-10-30T12:00:00Z&limit=200"

An answer looks like this:

{
  "data": [
    { "id": 812, "number": "TK-00812", "title": "Panel in trouble",
      "status": "Open", "priority": "high", "client_id": 41, "site_id": 77,
      "assigned_user_ids": [12], "due_date": "2026-10-31",
      "sla_breached": false, "updated_at": "2026-10-30T15:04:11Z", ... }
  ],
  "next_cursor": "WyIyMDI2LTEwLTMwIDE1OjA0OjExIiw4MTJd",
  "has_more": true
}

Find a unit by the serial on its plate, or by the code on its QR label:

curl -H "Authorization: Bearer tok_live_YOUR_KEY" \
  "https://emchorizons.com/tech_ops_api/v1/assets?serial=FP-123"

Pages, changes, errors, limits

Pages

Lists come oldest change first. limit is 1 to 500 (100 if you leave it out). To get the next page, send back next_cursor as cursor, unchanged. On the last page next_cursor is null and has_more is false.

Only what changed

Every list takes updated_since (ISO-8601, UTC if you give no zone) and returns records changed at or after that time. Keep the newest updated_at you saw and send it next time. You may see a record twice, so save by id. Tickets, clients, sites, assets, quotes, people and items removed in the app stay readable with active: false.

Shapes

Errors

Every error has the same shape: {"error": {"code": "NOT_FOUND", "message": "..."}}. The message says what to do.

StatusCodeMeans
400VALIDATIONA bad or unknown parameter, or a cursor we didn't give you.
401UNAUTHORIZEDNo key, or a key we don't know.
401KEY_REVOKEDAn admin revoked it.
401KEY_EXPIREDPast its expiry, or rolled more than 24 hours ago.
402PLAN_REQUIREDThe company's plan has ended or is read-only.
403IP_NOT_ALLOWEDCalled from an address the key doesn't allow.
403FORBIDDENThe key's role can't see this (the message names the switch), or a client key asked for a staff-only list.
404NOT_FOUNDNo such address or record. Another company's record, or one the key can't see, is also 404.
405METHOD_NOT_ALLOWEDVersion 1 only reads (GET).
429RATE_LIMITEDToo many calls. Wait the seconds in Retry-After.
500SERVER_ERROROur fault. The message has a reference; send it to support.
503API_UNAVAILABLEDown for a moment. Try again shortly.

If the whole service is down you may instead get a 503 with {"success": false, "error": {"code": "DB_CONNECTION_FAILED"}}. Treat any 503 the same way: wait and retry.

Rate limits

120 calls a minute and 10,000 a day, per key, across every endpoint. Two keys have two allowances. Every answer tells you where you stand:

X-RateLimit-LimitCalls allowed a minute (120).
X-RateLimit-RemainingCalls left this minute.
X-RateLimit-ResetWhen the minute resets (unix seconds).
X-RateLimit-Limit-DayCalls allowed a day (10,000).
X-RateLimit-Remaining-DayCalls left today.
Retry-AfterOn a 429: seconds to wait.

A nightly sync with updated_since and limit=500 uses a handful of calls. If you need more, write to me.

Webhooks

Rather than asking every few minutes, let us tell you. An admin adds your address in Admin settings › Data and integrations › Integrations & API › Webhooks and picks the events. We POST JSON to it when they happen:

ticket.createdA ticket was made.
ticket.status_changedA ticket's status changed (with the status before).
ticket.closedA ticket was closed.
trip.completedA technician finished a visit.
estimate.approvedA quote was approved.
invoice.sentAn invoice went out.
invoice.paidAn invoice was paid in full.
client.createdA client was added.
POST /your/endpoint
Content-Type: application/json
X-TechOps-Event: ticket.created
X-TechOps-Delivery: 5531
X-TechOps-Signature: sha256=3f1c...e9

{"id":"evt_204","event":"ticket.created","created_at":"2026-10-31T14:00:00Z",
 "company_id":7,"data":{"id":812,"ticket_number":"TK-00812", ...}}

Answer with any 2xx within 10 seconds. If you don't, we try again after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours. The event id stays the same on every try, so you can skip ones you've already handled. An endpoint that fails 20 times in a row is switched off and the admins are told.

Checking the signature

Each endpoint has a secret, shown once when it's added. X-TechOps-Signature is sha256= followed by the HMAC-SHA256 of the raw request body, keyed with that secret, in lowercase hex. Work it out yourself from the bytes you received (before parsing the JSON) and compare in constant time. If it doesn't match, drop the request.

PHP

$body = file_get_contents('php://input');
$want = 'sha256=' . hash_hmac('sha256', $body, $secret);
if (!hash_equals($want, $_SERVER['HTTP_X_TECHOPS_SIGNATURE'] ?? '')) {
    http_response_code(401); exit;
}

Node.js

const crypto = require('crypto');
// rawBody: the request body as a Buffer, before JSON parsing
const want = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const got = req.get('X-TechOps-Signature') || '';
const ok = got.length === want.length &&
  crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want));

Python

import hmac, hashlib
want = 'sha256=' + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
ok = hmac.compare_digest(want, request.headers.get('X-TechOps-Signature', ''))

What's coming next

Tell me what you'd build with it: [email protected].

Versions and notice

Version 1 is stable. I'll add fields and endpoints as they come, so ignore fields you don't know. I won't remove or change anything in v1 without 12 months' notice by email to every admin and on the changelog. Every answer carries X-TechOps-Api-Version.

The full description is also an OpenAPI 3.1 file you can load into Postman, Insomnia or a code generator. Use of the API falls under the terms of use: don't resell access, and keys that hammer the service or are abused get limited or revoked.

Reference

Base address: https://emchorizons.com/tech_ops_api/v1. Every call is a GET with Authorization: Bearer <key>. Generated from the OpenAPI file, version 1.0.0.

Your key

The key you're calling with GET/me

Its name, kind, role, company, expiry and limits. A cheap way to test a key.

Fields

FieldNotes
key_name
string
key_prefix
string
The first 13 characters of the key.
kind
string: staff, client
role
string, or null
client_id
integer, or null
The client a client key is bound to.
company
object
expires_at
string, or null (date-time)
rate_limits
object
api_version
string

Tickets and assets

List tickets GET/tickets GET/tickets/{id}

Every ticket the key may see: all of them when its role has "See every ticket", otherwise only ones it's on (a key is on none). A client key sees its client's tickets.

Takes limit, cursor and updated_since (see Pages above).

Filters

ParameterWhat it does
statusStatus slug or name, comma-separated (open, completed, ...).
status_idStatus ids, comma-separated.
prioritylow, medium, high, urgent or critical; comma-separated.
assigned_user_idTickets assigned to these user ids.
client_idstring
site_idstring
asset_idstring
due_fromDue date on or after (YYYY-MM-DD).
due_toDue date on or before (YYYY-MM-DD).
created_fromCreated at or after (ISO-8601).
created_toCreated before (ISO-8601).
closed_fromClosed at or after (ISO-8601).
closed_toClosed before (ISO-8601).
activetrue for live records, false for removed ones. Leave it out for both.

Fields

FieldNotes
id
integer
number
string
Like TK-00123.
title
string
description
string, or null
status
string, or null
The status name.
status_slug
string, or null
status_id
integer, or null
priority
string: low, medium, high, urgent, critical
call_type
string, or null
regular, after_hours or emergency.
client_id
integer, or null
site_id
integer, or null
asset_id
integer, or null
project_id
integer, or null
parent_ticket_id
integer, or null
assigned_user_ids
array of integer
due_date
string, or null (date)
scheduled_time
string, or null
HH:MM on the due date, company time.
case_reference
string, or null
external_ref
string, or null
Another system's reference, when one was imported or set.
visit_count
integer
Visits (trips) on the ticket, not counting declined ones.
completed_visit_count
integer
Finished visits. 1 on a closed ticket = fixed first time.
sla_response_due_at
string, or null (date-time)
sla_response_met_at
string, or null (date-time)
sla_resolution_due_at
string, or null (date-time)
sla_resolution_met_at
string, or null (date-time)
sla_breached
boolean
A response or resolution due time passed before it was met.
active
boolean
created_at
string (date-time)
updated_at
string (date-time)
closed_at
string, or null (date-time)
List assets (equipment, units, devices) GET/assets GET/assets/{id}

Needs "See assets". Look one up by its serial, its Asset ID (asset_number) or the code on its QR label (label_code) to map another system's device to ours.

Takes limit, cursor and updated_since (see Pages above).

Filters

ParameterWhat it does
client_idstring
site_idstring
parent_idstring
statusin_service, needs_attention, out_of_service or retired; comma-separated.
serialExact serial number.
asset_numberExact Asset ID (like HPT-0001).
label_codeExact QR label code.
activetrue for live records, false for removed ones. Leave it out for both.

Fields

FieldNotes
id
integer
name
string
asset_number
string, or null
The Asset ID on its label, like HPT-0001.
label_code
string, or null
The code in its QR label.
serial
string, or null
make
string, or null
model
string, or null
model_code
string, or null
year
integer, or null
vin
string, or null
plate
string, or null
status
string: in_service, needs_attention, out_of_service, retired
status_note
string, or null
client_id
integer, or null
site_id
integer, or null
parent_id
integer, or null
The asset this one is part of.
catalog_item_id
integer, or null
meter_reading
number, or null
meter_unit
string, or null
mi, km or hr.
meter_read_at
string, or null (date-time)
warranty_start
string, or null (date)
warranty_end
string, or null (date)
warranty_provider
string, or null
ownership
string, or null
owned or rented.
active
boolean
created_at
string (date-time)
updated_at
string (date-time)

Clients, sites and people

List clients GET/clients GET/clients/{id}

Needs the role's "See clients" switch. A client key sees only its own client.

Takes limit, cursor and updated_since (see Pages above).

Filters

ParameterWhat it does
parent_client_idstring
activetrue for live records, false for removed ones. Leave it out for both.

Fields

FieldNotes
id
integer
name
string
parent_client_id
integer, or null
phone
string, or null
email
string, or null
address
string, or null
city
string, or null
state
string, or null
zip
string, or null
tax_exempt
boolean
active
boolean
created_at
string (date-time)
updated_at
string (date-time)
List sites GET/sites GET/sites/{id}

Needs "See clients". A client key sees only its client's sites.

Takes limit, cursor and updated_since (see Pages above).

Filters

ParameterWhat it does
client_idstring
parent_site_idstring
activetrue for live records, false for removed ones. Leave it out for both.

Fields

FieldNotes
id
integer
client_id
integer
parent_site_id
integer, or null
name
string
address
string, or null
city
string, or null
state
string, or null
zip
string, or null
latitude
number, or null
longitude
number, or null
contact_name
string, or null
contact_phone
string, or null
contact_email
string, or null
active
boolean
created_at
string (date-time)
updated_at
string (date-time)
List your team (names and roles) GET/users GET/users/{id}

Staff only, names and roles only. Not for client keys.

Takes limit, cursor and updated_since (see Pages above).

Filters

ParameterWhat it does
activetrue for live records, false for removed ones. Leave it out for both.

Fields

FieldNotes
id
integer
name
string
first_name
string
last_name
string
role
string, or null
active
boolean
created_at
string (date-time)
updated_at
string (date-time)

Invoices, quotes and payments

List invoices GET/invoices GET/invoices/{id}

Needs "See invoices". Amounts need "See prices" and are left out without it. A client key sees its client's sent, paid and void invoices, never drafts.

Takes limit, cursor and updated_since (see Pages above).

Filters

ParameterWhat it does
statusdraft, sent, paid, void or rolled; comma-separated.
client_idstring
ticket_idstring
project_idstring
due_fromstring (date)
due_tostring (date)

Fields

FieldNotes
id
integer
number
string
status
string: draft, sent, paid, void, rolled
kind
string, or null
standard, deposit or progress.
client_id
integer, or null
site_id
integer, or null
ticket_id
integer, or null
project_id
integer, or null
estimate_id
integer, or null
po_number
string, or null
issued_at
string, or null (date-time)
due_date
string, or null (date)
sent_at
string, or null (date-time)
paid_at
string, or null (date-time)
currency
string
USD or CAD.
created_at
string (date-time)
updated_at
string (date-time)
subtotal_cents
integer
After discounts, before tax. Needs See prices.
tax_cents
integer
Needs See prices.
total_cents
integer
Negative is a credit owed to the client. Needs See prices.
paid_cents
integer
Payments, deposits and credits applied. Needs See prices.
balance_cents
integer
Needs See prices.
List quotes (estimates) GET/estimates GET/estimates/{id}

Needs "See estimates" (and "See catalog" for staff keys). Amounts need "See prices". A client key sees its client's sent, approved and declined quotes.

Takes limit, cursor and updated_since (see Pages above).

Filters

ParameterWhat it does
statusdraft, sent, approved, declined, superseded or withdrawn; comma-separated.
client_idstring
project_idstring

Fields

FieldNotes
id
integer
number
string
revision
integer
status
string: draft, sent, approved, declined, superseded, withdrawn
title
string, or null
client_id
integer, or null
site_id
integer, or null
project_id
integer, or null
ticket_id
integer, or null
The ticket it was started from.
asset_id
integer, or null
The asset it was started from.
valid_until
string, or null (date)
sent_at
string, or null (date-time)
approved_at
string, or null (date-time)
approved_by_name
string, or null
declined_at
string, or null (date-time)
currency
string
active
boolean
created_at
string (date-time)
updated_at
string (date-time)
subtotal_cents
integer
Needs See prices.
tax_cents
integer
Needs See prices.
total_cents
integer
Needs See prices.
List payments on invoices GET/payments GET/payments/{id}

Online payments (card or bank, through the company's Stripe) and payments the office recorded (check, cash, ...). Needs "See invoices" and "See prices". No card details are ever stored or sent.

Takes limit, cursor and updated_since (see Pages above).

Filters

ParameterWhat it does
invoice_idstring
client_idstring
statuspaid, reversed or voided; comma-separated.

{id}: The payment's id, like online_12 or recorded_34.

Fields

FieldNotes
id
string
online_<n> or recorded_<n>.
source
string: online, recorded
invoice_id
integer
invoice_number
string
client_id
integer, or null
amount_cents
integer
What was kept, after refunds and lost disputes.
gross_cents
integer
refunded_cents
integer
currency
string
method
string: card, bank, check, cash, other
reference
string, or null
A check number or the office's note; for online payments the processor's payment id.
status
string: paid, reversed, voided
paid_on
string, or null (date)
paid_at
string, or null (date-time)
Online payments only.
created_at
string (date-time)
updated_at
string (date-time)

Time

List time entries GET/time-entries GET/time-entries/{id}

Needs the role's time switch. Everyone's entries need "Fix anyone's time"; without it a key sees only its own (none). Not for client keys.

Takes limit, cursor and updated_since (see Pages above).

Filters

ParameterWhat it does
user_idstring
worked_fromDay worked on or after (YYYY-MM-DD).
worked_toDay worked on or before (YYYY-MM-DD).
approvedtrue or false.

Fields

FieldNotes
id
integer
user_id
integer
date_worked
string (date)
duration_seconds
integer
entry_type
string, or null
place
string, or null
source
string, or null
manual or trip.
description
string, or null
trip_id
integer, or null
ticket_id
integer, or null
paycode_id
integer, or null
submitted_at
string, or null (date-time)
approved_at
string, or null (date-time)
approved_by
integer, or null
payroll_exported_at
string, or null (date-time)
created_at
string (date-time)
updated_at
string (date-time)

Inventory

List catalog items with stock on hand GET/inventory/items GET/inventory/items/{id}

Needs "See inventory". on_hand counts the whole company with "Pull for others", otherwise only places the key manages. Sell price needs "See prices", cost needs "See costs". Not for client keys.

Takes limit, cursor and updated_since (see Pages above).

Filters

ParameterWhat it does
skustring
barcodestring
categorystring
item_typepart, container, cable, fluid or object; comma-separated.
activetrue for live records, false for removed ones. Leave it out for both.

Fields

FieldNotes
id
integer
name
string
sku
string, or null
barcode
string, or null
category
string, or null
item_type
string
unit
string, or null
track_by
string, or null
none, serial or lot.
low_stock_alert
integer
on_hand
number
active
boolean
created_at
string (date-time)
updated_at
string (date-time)
sell_price_cents
integer, or null
Needs See prices.
unit_cost_cents
integer, or null
Needs See costs.
List stock by place (van, warehouse, ...) GET/inventory/stock GET/inventory/stock/{id}

One line per item per place. A line whose stock was used up or moved reads quantity 0.

Takes limit, cursor and updated_since (see Pages above).

Filters

ParameterWhat it does
item_idstring
location_idstring

Fields

FieldNotes
id
integer
item_id
integer
item_name
string
sku
string, or null
location_id
integer
location_name
string
location_kind
string
vehicle, warehouse, garage, unit, ... or person.
quantity
number
updated_at
string (date-time)

Webhook events

EventWhendata
ticket.createdA ticket was madedata is a WebhookTicket.
ticket.status_changedA ticket's status changeddata is a WebhookTicket with previous_status_id and previous_status.
ticket.closedA ticket was closed
invoice.sentAn invoice was sentdata: id, invoice_number, status, client_id, ticket_id, project_id, due_date, sent_at, paid_at.
invoice.paidAn invoice was paid in fullFetch GET /payments?invoice_id= for the amounts.
estimate.approvedA quote was approveddata: id, estimate_number, title, status, client_id, project_id, sent_at.
client.createdA client was addeddata: id, name, created_at.
trip.completedA technician finished a visitdata: id, ticket_id, ticket_number, technician_id, status, completed_at.

Ticket events: data

FieldNotes
id
integer
ticket_number
string
title
string
priority
string
client_id
integer, or null
site_id
integer, or null
project_id
integer, or null
status_id
integer, or null
status
string, or null
due_date
string, or null (date)
created_at
string (date-time)
closed_at
string, or null (date-time)
previous_status_id
integer, or null
ticket.status_changed only.
previous_status
string, or null
ticket.status_changed only.