Skip to content
WorkOrders

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}: …

/developers/openapi.yaml

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.