00 Developers
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.
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.
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.
Any admin in your company makes keys in the app: Admin settings › Data and integrations › Integrations & API › API keys.
203.0.113.0/24).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.
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"
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.
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.
2026-10-31T14:00:00Z. Dates that mean a day (due dates, the day worked) are 2026-10-31.20900 is $209.00, in the company's currency.due_from/due_to filter the due date and created_from/created_to filter when it was made.Every error has the same shape: {"error": {"code": "NOT_FOUND", "message": "..."}}. The message says what to do.
| Status | Code | Means |
|---|---|---|
| 400 | VALIDATION | A bad or unknown parameter, or a cursor we didn't give you. |
| 401 | UNAUTHORIZED | No key, or a key we don't know. |
| 401 | KEY_REVOKED | An admin revoked it. |
| 401 | KEY_EXPIRED | Past its expiry, or rolled more than 24 hours ago. |
| 402 | PLAN_REQUIRED | The company's plan has ended or is read-only. |
| 403 | IP_NOT_ALLOWED | Called from an address the key doesn't allow. |
| 403 | FORBIDDEN | The key's role can't see this (the message names the switch), or a client key asked for a staff-only list. |
| 404 | NOT_FOUND | No such address or record. Another company's record, or one the key can't see, is also 404. |
| 405 | METHOD_NOT_ALLOWED | Version 1 only reads (GET). |
| 429 | RATE_LIMITED | Too many calls. Wait the seconds in Retry-After. |
| 500 | SERVER_ERROR | Our fault. The message has a reference; send it to support. |
| 503 | API_UNAVAILABLE | Down 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.
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-Limit | Calls allowed a minute (120). |
X-RateLimit-Remaining | Calls left this minute. |
X-RateLimit-Reset | When the minute resets (unix seconds). |
X-RateLimit-Limit-Day | Calls allowed a day (10,000). |
X-RateLimit-Remaining-Day | Calls left today. |
Retry-After | On 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.
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.created | A ticket was made. |
ticket.status_changed | A ticket's status changed (with the status before). |
ticket.closed | A ticket was closed. |
trip.completed | A technician finished a visit. |
estimate.approved | A quote was approved. |
invoice.sent | An invoice went out. |
invoice.paid | An invoice was paid in full. |
client.created | A 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.
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.
$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;
}
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));
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', ''))
Idempotency-Key so a retry never makes two tickets.external_ref on tickets and assets so your ids live next to ours.stock.changed and inventory.low, for suppliers who restock vans and shelves.Tell me what you'd build with it: [email protected].
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.
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.
/me
Its name, kind, role, company, expiry and limits. A cheap way to test a key.
| Field | Notes |
|---|---|
key_namestring | |
key_prefixstring | The first 13 characters of the key. |
kindstring: staff, client | |
rolestring, or null | |
client_idinteger, or null | The client a client key is bound to. |
companyobject | |
expires_atstring, or null (date-time) | |
rate_limitsobject | |
api_versionstring |
/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).
| Parameter | What it does |
|---|---|
status | Status slug or name, comma-separated (open, completed, ...). |
status_id | Status ids, comma-separated. |
priority | low, medium, high, urgent or critical; comma-separated. |
assigned_user_id | Tickets assigned to these user ids. |
client_id | string |
site_id | string |
asset_id | string |
due_from | Due date on or after (YYYY-MM-DD). |
due_to | Due date on or before (YYYY-MM-DD). |
created_from | Created at or after (ISO-8601). |
created_to | Created before (ISO-8601). |
closed_from | Closed at or after (ISO-8601). |
closed_to | Closed before (ISO-8601). |
active | true for live records, false for removed ones. Leave it out for both. |
| Field | Notes |
|---|---|
idinteger | |
numberstring | Like TK-00123. |
titlestring | |
descriptionstring, or null | |
statusstring, or null | The status name. |
status_slugstring, or null | |
status_idinteger, or null | |
prioritystring: low, medium, high, urgent, critical | |
call_typestring, or null | regular, after_hours or emergency. |
client_idinteger, or null | |
site_idinteger, or null | |
asset_idinteger, or null | |
project_idinteger, or null | |
parent_ticket_idinteger, or null | |
assigned_user_idsarray of integer | |
due_datestring, or null (date) | |
scheduled_timestring, or null | HH:MM on the due date, company time. |
case_referencestring, or null | |
external_refstring, or null | Another system's reference, when one was imported or set. |
visit_countinteger | Visits (trips) on the ticket, not counting declined ones. |
completed_visit_countinteger | Finished visits. 1 on a closed ticket = fixed first time. |
sla_response_due_atstring, or null (date-time) | |
sla_response_met_atstring, or null (date-time) | |
sla_resolution_due_atstring, or null (date-time) | |
sla_resolution_met_atstring, or null (date-time) | |
sla_breachedboolean | A response or resolution due time passed before it was met. |
activeboolean | |
created_atstring (date-time) | |
updated_atstring (date-time) | |
closed_atstring, or null (date-time) |
/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).
| Parameter | What it does |
|---|---|
client_id | string |
site_id | string |
parent_id | string |
status | in_service, needs_attention, out_of_service or retired; comma-separated. |
serial | Exact serial number. |
asset_number | Exact Asset ID (like HPT-0001). |
label_code | Exact QR label code. |
active | true for live records, false for removed ones. Leave it out for both. |
| Field | Notes |
|---|---|
idinteger | |
namestring | |
asset_numberstring, or null | The Asset ID on its label, like HPT-0001. |
label_codestring, or null | The code in its QR label. |
serialstring, or null | |
makestring, or null | |
modelstring, or null | |
model_codestring, or null | |
yearinteger, or null | |
vinstring, or null | |
platestring, or null | |
statusstring: in_service, needs_attention, out_of_service, retired | |
status_notestring, or null | |
client_idinteger, or null | |
site_idinteger, or null | |
parent_idinteger, or null | The asset this one is part of. |
catalog_item_idinteger, or null | |
meter_readingnumber, or null | |
meter_unitstring, or null | mi, km or hr. |
meter_read_atstring, or null (date-time) | |
warranty_startstring, or null (date) | |
warranty_endstring, or null (date) | |
warranty_providerstring, or null | |
ownershipstring, or null | owned or rented. |
activeboolean | |
created_atstring (date-time) | |
updated_atstring (date-time) |
/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).
| Parameter | What it does |
|---|---|
parent_client_id | string |
active | true for live records, false for removed ones. Leave it out for both. |
| Field | Notes |
|---|---|
idinteger | |
namestring | |
parent_client_idinteger, or null | |
phonestring, or null | |
emailstring, or null | |
addressstring, or null | |
citystring, or null | |
statestring, or null | |
zipstring, or null | |
tax_exemptboolean | |
activeboolean | |
created_atstring (date-time) | |
updated_atstring (date-time) |
/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).
| Parameter | What it does |
|---|---|
client_id | string |
parent_site_id | string |
active | true for live records, false for removed ones. Leave it out for both. |
| Field | Notes |
|---|---|
idinteger | |
client_idinteger | |
parent_site_idinteger, or null | |
namestring | |
addressstring, or null | |
citystring, or null | |
statestring, or null | |
zipstring, or null | |
latitudenumber, or null | |
longitudenumber, or null | |
contact_namestring, or null | |
contact_phonestring, or null | |
contact_emailstring, or null | |
activeboolean | |
created_atstring (date-time) | |
updated_atstring (date-time) |
/users
GET/users/{id}
Staff only, names and roles only. Not for client keys.
Takes limit, cursor and updated_since (see Pages above).
| Parameter | What it does |
|---|---|
active | true for live records, false for removed ones. Leave it out for both. |
| Field | Notes |
|---|---|
idinteger | |
namestring | |
first_namestring | |
last_namestring | |
rolestring, or null | |
activeboolean | |
created_atstring (date-time) | |
updated_atstring (date-time) |
/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).
| Parameter | What it does |
|---|---|
status | draft, sent, paid, void or rolled; comma-separated. |
client_id | string |
ticket_id | string |
project_id | string |
due_from | string (date) |
due_to | string (date) |
| Field | Notes |
|---|---|
idinteger | |
numberstring | |
statusstring: draft, sent, paid, void, rolled | |
kindstring, or null | standard, deposit or progress. |
client_idinteger, or null | |
site_idinteger, or null | |
ticket_idinteger, or null | |
project_idinteger, or null | |
estimate_idinteger, or null | |
po_numberstring, or null | |
issued_atstring, or null (date-time) | |
due_datestring, or null (date) | |
sent_atstring, or null (date-time) | |
paid_atstring, or null (date-time) | |
currencystring | USD or CAD. |
created_atstring (date-time) | |
updated_atstring (date-time) | |
subtotal_centsinteger | After discounts, before tax. Needs See prices. |
tax_centsinteger | Needs See prices. |
total_centsinteger | Negative is a credit owed to the client. Needs See prices. |
paid_centsinteger | Payments, deposits and credits applied. Needs See prices. |
balance_centsinteger | Needs See prices. |
/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).
| Parameter | What it does |
|---|---|
status | draft, sent, approved, declined, superseded or withdrawn; comma-separated. |
client_id | string |
project_id | string |
| Field | Notes |
|---|---|
idinteger | |
numberstring | |
revisioninteger | |
statusstring: draft, sent, approved, declined, superseded, withdrawn | |
titlestring, or null | |
client_idinteger, or null | |
site_idinteger, or null | |
project_idinteger, or null | |
ticket_idinteger, or null | The ticket it was started from. |
asset_idinteger, or null | The asset it was started from. |
valid_untilstring, or null (date) | |
sent_atstring, or null (date-time) | |
approved_atstring, or null (date-time) | |
approved_by_namestring, or null | |
declined_atstring, or null (date-time) | |
currencystring | |
activeboolean | |
created_atstring (date-time) | |
updated_atstring (date-time) | |
subtotal_centsinteger | Needs See prices. |
tax_centsinteger | Needs See prices. |
total_centsinteger | Needs See prices. |
/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).
| Parameter | What it does |
|---|---|
invoice_id | string |
client_id | string |
status | paid, reversed or voided; comma-separated. |
{id}: The payment's id, like online_12 or recorded_34.
| Field | Notes |
|---|---|
idstring | online_<n> or recorded_<n>. |
sourcestring: online, recorded | |
invoice_idinteger | |
invoice_numberstring | |
client_idinteger, or null | |
amount_centsinteger | What was kept, after refunds and lost disputes. |
gross_centsinteger | |
refunded_centsinteger | |
currencystring | |
methodstring: card, bank, check, cash, other | |
referencestring, or null | A check number or the office's note; for online payments the processor's payment id. |
statusstring: paid, reversed, voided | |
paid_onstring, or null (date) | |
paid_atstring, or null (date-time) | Online payments only. |
created_atstring (date-time) | |
updated_atstring (date-time) |
/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).
| Parameter | What it does |
|---|---|
user_id | string |
worked_from | Day worked on or after (YYYY-MM-DD). |
worked_to | Day worked on or before (YYYY-MM-DD). |
approved | true or false. |
| Field | Notes |
|---|---|
idinteger | |
user_idinteger | |
date_workedstring (date) | |
duration_secondsinteger | |
entry_typestring, or null | |
placestring, or null | |
sourcestring, or null | manual or trip. |
descriptionstring, or null | |
trip_idinteger, or null | |
ticket_idinteger, or null | |
paycode_idinteger, or null | |
submitted_atstring, or null (date-time) | |
approved_atstring, or null (date-time) | |
approved_byinteger, or null | |
payroll_exported_atstring, or null (date-time) | |
created_atstring (date-time) | |
updated_atstring (date-time) |
/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).
| Parameter | What it does |
|---|---|
sku | string |
barcode | string |
category | string |
item_type | part, container, cable, fluid or object; comma-separated. |
active | true for live records, false for removed ones. Leave it out for both. |
| Field | Notes |
|---|---|
idinteger | |
namestring | |
skustring, or null | |
barcodestring, or null | |
categorystring, or null | |
item_typestring | |
unitstring, or null | |
track_bystring, or null | none, serial or lot. |
low_stock_alertinteger | |
on_handnumber | |
activeboolean | |
created_atstring (date-time) | |
updated_atstring (date-time) | |
sell_price_centsinteger, or null | Needs See prices. |
unit_cost_centsinteger, or null | Needs See costs. |
/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).
| Parameter | What it does |
|---|---|
item_id | string |
location_id | string |
| Field | Notes |
|---|---|
idinteger | |
item_idinteger | |
item_namestring | |
skustring, or null | |
location_idinteger | |
location_namestring | |
location_kindstring | vehicle, warehouse, garage, unit, ... or person. |
quantitynumber | |
updated_atstring (date-time) |
| Event | When | data |
|---|---|---|
ticket.created | A ticket was made | data is a WebhookTicket. |
ticket.status_changed | A ticket's status changed | data is a WebhookTicket with previous_status_id and previous_status. |
ticket.closed | A ticket was closed | |
invoice.sent | An invoice was sent | data: id, invoice_number, status, client_id, ticket_id, project_id, due_date, sent_at, paid_at. |
invoice.paid | An invoice was paid in full | Fetch GET /payments?invoice_id= for the amounts. |
estimate.approved | A quote was approved | data: id, estimate_number, title, status, client_id, project_id, sent_at. |
client.created | A client was added | data: id, name, created_at. |
trip.completed | A technician finished a visit | data: id, ticket_id, ticket_number, technician_id, status, completed_at. |
| Field | Notes |
|---|---|
idinteger | |
ticket_numberstring | |
titlestring | |
prioritystring | |
client_idinteger, or null | |
site_idinteger, or null | |
project_idinteger, or null | |
status_idinteger, or null | |
statusstring, or null | |
due_datestring, or null (date) | |
created_atstring (date-time) | |
closed_atstring, or null (date-time) | |
previous_status_idinteger, or null | ticket.status_changed only. |
previous_statusstring, or null | ticket.status_changed only. |