For developers
Work that starts
somewhere else
A building management system. A tenant portal. Your own maintenance schedule. Post the request to one endpoint and it becomes a work order like any other — on the board, in front of the crew, answered.
What it is for
Most work arrives as email. Some of it does not.
A property manager whose tenants log faults in a portal already has the request in a database — as structured data, with a site and a reference. Making somebody re-type it into an email so it can be read back out again is the sort of thing that only makes sense from inside whichever system was built first.
Raise it
One POST with a subject and a description. You get back a WO- reference to store against your own record, and the job is on the board before the response reaches you.
Follow it
Read the job back, or read its timeline — who claimed it, when they went on site, what they found, when it closed. The same history the office sees.
Attach to it
Photos, video and PDFs. Send small ones inline with the job so the two arrive together; post larger ones afterwards, up to 100 MB.
A job raised this way is not a second-class one. It is read by the same extraction pass, so a site address buried in a paragraph still gets pulled out. It raises the same notifications, appears on the same board, is searchable and reportable the same way, and shows up in the customer's own portal with replies threading onto it. The office cannot tell it apart from an emailed job, which is the whole design.
Getting a key
The key belongs to the company, not to a person
Nobody is signed in when your server calls us, so the credential is issued to the business rather than to one of its staff. That also means a key survives whoever set it up leaving.
An owner or administrator mints one under Settings → API keys. It takes a name — what the key is for, which is then shown on every job it raises — a choice of full access or read-only, and an optional expiry date.
The key is shown once and is never recoverable. We store only a hash of it. There is no "show it to me again" and there cannot be, because a key that can be read back out of a database is a key that a database backup hands over. Lost one? Mint another and revoke the old — which is also exactly what a leaked one needs.
Revoking takes effect on the next request. Keys start with
wo_
so they are recognisable in a config file, a log, or a commit that
should not have contained one.
Read-only keys are worth using
If the thing you are building only ever displays jobs — a dashboard, a status page for tenants — give it a read-only key. It then cannot raise or annotate work no matter what the code does, and nobody has to take the code's word for it.
- work-orders:read
- List jobs, read one, read its timeline.
- work-orders:write
- Raise a job, add a note, upload a file.
- *
- Both. What the Full access option grants.
Authentication
One header, and one call to prove it
Every request carries
Authorization: Bearer wo_….
Nothing goes in the query string — URLs end up in proxy logs and browser
history, and a credential in one is a credential in all of them.
Run this first
curl -s https://workorders.nz/api/v1/integration/ping \
-H 'Accept: application/json' \
-H "Authorization: Bearer $WORKORDERS_KEY"
Before you write a line of code. It answers three questions that otherwise become a support email: is the key live, whose account does it act for — worth checking before you post a job into somebody else's after a copy-and-paste — and what is it allowed to do.
What comes back
{
"data": {
"company": { "id": 1, "name": "Demo Plumbing & Gas" },
"token": {
"name": "Acme FM platform",
"abilities": ["*"],
"expires_at": null
},
"server_time": "2026-08-06T22:15:55Z"
}
}
server_time is our clock. Every
timestamp we emit is UTC with a trailing
Z, and it is better to find out
your box is an hour out here than in the shape of jobs filed at the
wrong time.
Raising a job
One required field
A subject. Everything else is optional, because a caller with nothing but a one-line fault report should not be turned away over fields it does not have.
POST /api/v1/integration/work-orders
curl -s -X POST \
https://workorders.nz/api/v1/integration/work-orders \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $WORKORDERS_KEY" \
-H 'Idempotency-Key: fm-99213-2026-08-07' \
-d '{
"subject": "No hot water — Level 3 kitchen",
"body": "Reported by the duty manager. The cylinder at 12 Bond Street is cold and there is water on the floor.",
"requester_name": "Dana Whitfield",
"requester_email": "dana@acmefm.example",
"site_address": "12 Bond Street, Wellington",
"urgency": "high",
"external_reference": "FM-99213"
}'
201 Created
{
"data": {
"id": 1,
"reference": "WO-P6J9CE",
"status": "new",
"status_label": "New",
"source": "api",
"urgency": "high",
"subject": "No hot water — Level 3 kitchen",
"summary": null,
"from": {
"name": "Dana Whitfield",
"email": "dana@acmefm.example"
},
"site_address": "12 Bond Street, Wellington",
"assignee": null,
"created_at": "2026-08-06T22:15:55Z",
"updated_at": "2026-08-06T22:15:55Z",
"attachments": []
}
}
Abbreviated — the real body carries the customer, site, and the rest
of the job's fields. Store
reference: it is what every
other endpoint takes and what the customer reads down the phone.
summary and an urgency you did
not send arrive a few seconds later, once the extraction pass has read
the description.
The fields
- subject required
- What is wrong, in a line. Up to 512 characters.
- body optional
- The description. Forward a whole email thread into it if that is what you have — up to 20,000 characters. This is what gets read for an urgency and a site.
- requester_name optional
- Who reported it — a tenant, a duty manager.
- requester_email optional
- Who reported it. We find or create a customer record from this, and it is how replies get back to them.
- site_address optional
- Where, as written. Kept exactly as sent.
- urgency optional
- low, normal, high or emergency. Leave it out and we read one out of the description.
- external_reference optional
- Your own id for the job. We never use it as ours — it is what makes a phone call answerable in both directions.
- attachments optional
- Up to 10 files inline, base64, 5 MB each and 15 MB the request.
Send an Idempotency-Key
If you post a job and never hear back, you do not know whether we made it. Retrying is the only responsible thing to do — and without a key, it is also how one burst pipe becomes three jobs on the board.
Put any string you like in the
Idempotency-Key
header — your own job id is usually right. Retry the identical
request and you get the original response back, marked
Idempotent-Replay: true,
rather than a second job.
Reuse the same key for a different job and you get a 409 instead of a silent success. That is deliberate: quietly replaying there would swallow a real fault report and tell you we had created a work order that does not exist.
The header is optional. We would rather take a work order than refuse one over hygiene — but send it.
Following it
Seven endpoints, and that is all of them
Base URL
https://workorders.nz/api/v1/integration.
Everything is JSON.
- GET ping
- Prove the key. Needs no permissions at all, so a read-only key can still say hello.
- POST work-orders
- Raise a job.
- GET work-orders
- List them, newest first. Filter by status, assignee, customer, date range, or free text; 25 a page, up to 100.
- GET work-orders/{reference}
- One job, with its full timeline.
- GET work-orders/{reference}/events
- The timeline alone, oldest first. This is what to poll to mirror progress — it says who did what, which a status column cannot.
- POST work-orders/{reference}/notes
- Add to the history without changing the status. "Tenant says it has got worse."
- POST work-orders/{reference}/attachments
- Upload one file, up to 100 MB.
References are forgiving
WO-P6J9CE,
wo-p6j9ce and
p6j9ce all find the same job. If
you stored it lower-cased, or dropped the prefix because it looked like
decoration, you are still quoting the right job.
Polling, not webhooks
There is nothing pushed to you yet. Ask for
?updated_since= with the newest
updated_at you have seen, and you
get everything that has changed since. It is inclusive of your cursor,
so you may see a row twice — that is on purpose. A poll should fail
towards showing you something again, never towards losing it.
What we will not let you do
There is no way to assign, complete or reopen a job through this API, and there will not be one.
Every one of those movements is recorded against a named person — who claimed it, who was on site, who signed it off — and a company key has nobody behind it. An endpoint that closed a job on behalf of no one would put a lie into a history that people rely on when something goes wrong on a site.
Add a note instead. It lands on the job in the office and on the technician's phone, and it is honest about where it came from.
When it goes wrong
Real status codes, and a sentence you can show someone
- 401
- The key is missing, wrong, revoked or expired. All four say the same thing on purpose — telling a stranger that a key is real but expired is telling them they have found something.
- 403
- The key is fine but was not minted for this. We name the permission it needs, because you own the account and can go and mint the right one.
- 404
- No such job for you. A job belonging to another company is a 404, never a 403 — a 403 would confirm it exists, and that is a fact about somebody else's business.
- 409
- An Idempotency-Key conflict. Either the key was used for a different job, or the first request has not finished yet and there is nothing to replay.
- 422
- The request was refused. Comes with an errors object mapping each field to a plain sentence about what is wrong with it.
- 429
- Too fast. Wait for Retry-After.
A 422, in full
{
"message": "The subject field is required.",
"errors": {
"subject": ["The subject field is required."]
}
}
Rate limits
120 requests a minute per
key, counted against the key rather than your IP address — so
another customer behind the same corporate firewall cannot use up your
budget. Every response tells you where you are with
X-RateLimit-Remaining.
It is a backstop against a runaway loop rather than a quota. If you genuinely need more, that is a conversation and not a 429 — ask us.
Generate a client
The whole thing, machine-readable
An OpenAPI 3.1 document covering every endpoint, every field, every error and every example on this page. Point your generator at it and you have a typed client in whichever language you are working in.
It is kept honest by our own test suite: a build fails if the spec describes an endpoint we do not serve, or if we add one it does not mention. It cannot quietly fall behind.
openapi: 3.1.0
info:
title: WorkOrders Intake API
version: '1.0'
servers:
- url: https://workorders.nz/api/v1/integration
paths:
/ping: …
/work-orders: …
/work-orders/{reference}: …
Try it against your own system
Start a trial, mint a key in the settings screen, and post a job from wherever the work actually starts. Nothing to sign, and nobody has to sit through a demo first.