Getting started
The API is for a business on Nozl to connect its own systems: a website, a booking or accounting tool, a spreadsheet, or an automation service such as Zapier, Make or n8n. Everything it reads and writes is that business's own.
- Sign in to Nozl as head office and open Settings › API and webhooks.
- Choose Create API key, name it for what will use it, and copy the key. It's shown once.
- Make your first request: it says which business and key it is.
curl https://nozl.co.nz/api/v1/me \
-H "Authorization: Bearer nozl_live_…"{
"object": "api_key",
"business": {
"id": "8c4b0e1f-2a3d-4e5f-9a8b-7c6d5e4f3a21",
"name": "Harbourside Cleaning"
},
"key": {
"id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"name": "Our website",
"prefix": "nozl_live_Xk3f",
"access": "read",
"created_at": "2026-11-02T01:12:40.512Z"
}
}API keys
Send the key on every request as Authorization: Bearer nozl_live_…. Keys start nozl_live_. Nozl keeps only a fingerprint of each key, so a lost key can't be shown again: revoke it and make another.
- Read only: Read customers, quotes, jobs, invoices, payments and branches.
- Read and write: Everything Read only does, and add customers.
Keep keys on your server, never in a web page or an app people can download. Anyone with the key can read your business's customers. Revoking a key stops it at once.
Requests and answers
- Every address starts
https://nozl.co.nz/api/v1, over HTTPS. - Answers are JSON. Send JSON too, with
Content-Type: application/json. - Money is in cents, as whole numbers:
37145is $371.45 NZD. Amounts named…_centsare before GST unless they're a total. - Times are ISO 8601 in UTC (
2026-11-04T19:30:00.000Z); days on their own areyyyy-mm-ddin the branch's timezone. - Ids are UUIDs. Phone numbers are E.164 (+64…).
- Records that are removed, merged into another, or archived aren't given.
Lists and paging
Lists come newest first, a page at a time. has_more says there's another page; ask for it with page.
{
"object": "list",
"data": [
"…"
],
"page": 1,
"limit": 25,
"has_more": true
}| Parameter | What it does |
|---|---|
| page | Which page, from 1. |
| limit | How many on a page, 1 to 100 (25 to start). |
| branch_id | Only this branch's. |
| updated_since | Only those changed since this date or time (ISO 8601), for keeping a copy in step. |
Errors and limits
Something wrong is a 4xx with what and why; a 5xx is Nozl's problem, and worth trying again shortly.
{
"error": {
"code": "invalid_filter",
"message": "status takes one or more of draft, sent, paid, overdue, void, separated by commas."
}
}| Parameter | What it does |
|---|---|
| 400 | A filter isn't right, or the body isn't JSON. |
| 401 | No key, or a key that's wrong or revoked. |
| 403 | A Read only key asking to add something. |
| 404 | Nothing of yours with that id. |
| 422 | What was sent didn't pass a check (the message says which field). |
| 429 | More than 120 requests a minute with one key. Wait the Retry-After seconds. |
| 500 | Something went wrong on Nozl's side. |
Branches
GET/api/v1/branches
{
"id": "0b6f1a52-3c3e-4b1a-9a57-6a1f0c2d9e10",
"object": "branch",
"name": "Nelson",
"code": "NEL",
"is_franchise": false,
"timezone": "Pacific/Auckland",
"phone": "+6435450101",
"email": "nelson@harbourside.co.nz",
"created_at": "2026-01-12T22:00:00.000Z"
}Customers
GET/api/v1/customers
| Parameter | What it does |
|---|---|
| q | Name, email, phone or contact person contains this. |
| created_since | Only those added since this date or time. |
| page | Which page, from 1. |
| limit | How many on a page, 1 to 100 (25 to start). |
| branch_id | Only this branch's. |
| updated_since | Only those changed since this date or time (ISO 8601), for keeping a copy in step. |
GET/api/v1/customers/{id}
POST/api/v1/customers
address (put on the map for you). Needs a Read and write key. It's added at branch_id, or your first branch, and answers 201 with the customer. Someone already in Nozl with the same name and the same email, phone or address is merged into them, as in the app.| Parameter | What it does |
|---|---|
| name | Needed: the customer's or company's name. |
| type | residential (to start) or commercial. |
| first_name, last_name | The contact person. |
| email, phone | Their email, and a phone (021 123 4567 or +64…). |
| source | web, phone, email, referral, door_knock, repeat or other. |
| tags | Up to 20 words. |
| notes | Anything to know. |
| branch_id | Which branch they're with. |
| address | street (needed with an address), suburb, city, postcode, access_notes. |
curl https://nozl.co.nz/api/v1/customers \
-H "Authorization: Bearer nozl_live_…" \
-H "Content-Type: application/json" \
-d '{"name":"Ruth Mason","first_name":"Ruth","last_name":"Mason","email":"ruth.mason@example.net","phone":"021 555 0192","source":"web","tags":["website"],"notes":"Asked about a house wash in December.","address":{"street":"16 Brougham Street","suburb":"Nelson Central","city":"Nelson","postcode":"7010"}}'{
"id": "5d2c8e7a-91f4-4c3b-8f0e-2a6b7c9d1e33",
"object": "customer",
"type": "residential",
"name": "Hannah and Rob Walker",
"contact": {
"first_name": "Hannah",
"last_name": "Walker",
"name": "Hannah Walker"
},
"email": "hannah.walker@example.net",
"phone": "+64211234567",
"source": "web",
"tags": [
"windows",
"gutters"
],
"notes": "Dog in the back yard, friendly.",
"billing_address": null,
"branch": {
"id": "0b6f1a52-3c3e-4b1a-9a57-6a1f0c2d9e10",
"name": "Nelson"
},
"properties": [
{
"id": "7e1d2c3b-4a5f-4e6d-8c7b-9a0f1e2d3c4b",
"object": "property",
"address": {
"unit": null,
"street_number": "14",
"street": "Rutherford Street",
"suburb": "Nelson Central",
"city": "Nelson",
"postcode": "7010",
"full": "14 Rutherford Street, Nelson Central, Nelson 7010"
},
"lat": -41.2735,
"lng": 173.2854,
"property_type": "house",
"storeys": 2,
"access_notes": "Side gate code 1234"
}
],
"created_at": "2026-10-20T03:41:09.218Z",
"updated_at": "2026-10-28T21:05:33.004Z"
}Quotes
A quote changed after it was sent becomes a new version with the same number. Lists give the version in use of each quote (and quotes still being written) unless all_versions=true.
GET/api/v1/quotes
| Parameter | What it does |
|---|---|
| status | One or more of draft, locked, sent, viewed, accepted, declined, expired, separated by commas. |
| customer_id | Only this customer's. |
| all_versions | true for every version. |
| page | Which page, from 1. |
| limit | How many on a page, 1 to 100 (25 to start). |
| branch_id | Only this branch's. |
| updated_since | Only those changed since this date or time (ISO 8601), for keeping a copy in step. |
GET/api/v1/quotes/{id}
{
"id": "c3d4e5f6-a7b8-4c9d-8e0f-1a2b3c4d5e6f",
"object": "quote",
"number": "000208",
"version": 1,
"first_version_id": "c3d4e5f6-a7b8-4c9d-8e0f-1a2b3c4d5e6f",
"in_use": true,
"status": "accepted",
"customer": {
"id": "5d2c8e7a-91f4-4c3b-8f0e-2a6b7c9d1e33",
"name": "Hannah and Rob Walker"
},
"property_id": "7e1d2c3b-4a5f-4e6d-8c7b-9a0f1e2d3c4b",
"branch": {
"id": "0b6f1a52-3c3e-4b1a-9a57-6a1f0c2d9e10",
"name": "Nelson"
},
"job_id": "e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a8b",
"taxed": true,
"subtotal_cents": 34000,
"discount_cents": 0,
"early_acceptance_discount_cents": 1700,
"gst_cents": 4845,
"total_cents": 37145,
"valid_until": "2026-11-19",
"sent_at": "2026-10-20T04:02:11.000Z",
"viewed_at": "2026-10-20T06:15:42.000Z",
"accepted_at": "2026-10-21T08:30:05.000Z",
"declined_at": null,
"decline_reason": null,
"url": "https://nozl.co.nz/q/Gq7Y…",
"lines": [
{
"description": "Windows, inside and out",
"part_no": "WIN-2",
"qty": 1,
"unit": "each",
"unit_price_cents": 28000,
"total_cents": 28000,
"optional": false,
"chosen": null,
"group": null
},
{
"description": "Tracks and screens",
"part_no": "TRK",
"qty": 12,
"unit": "each",
"unit_price_cents": 500,
"total_cents": 6000,
"optional": true,
"chosen": true,
"group": "Extras"
}
],
"created_at": "2026-10-20T03:45:00.000Z",
"updated_at": "2026-10-21T08:30:05.000Z"
}Jobs
GET/api/v1/jobs
scheduled_from and scheduled_to. Each has its booked dates (bookings) and who's on them.| Parameter | What it does |
|---|---|
| status | One or more of unscheduled, scheduled, in_progress, stopped_safety, done, invoiced, paid, cancelled. |
| customer_id | Only this customer's. |
| scheduled_from, scheduled_to | Booked to start in this time (ISO 8601), soonest first. |
| page | Which page, from 1. |
| limit | How many on a page, 1 to 100 (25 to start). |
| branch_id | Only this branch's. |
| updated_since | Only those changed since this date or time (ISO 8601), for keeping a copy in step. |
GET/api/v1/jobs/{id}
{
"id": "e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a8b",
"object": "job",
"number": "000208",
"status": "scheduled",
"customer": {
"id": "5d2c8e7a-91f4-4c3b-8f0e-2a6b7c9d1e33",
"name": "Hannah and Rob Walker"
},
"property": {
"id": "7e1d2c3b-4a5f-4e6d-8c7b-9a0f1e2d3c4b",
"address": {
"unit": null,
"street_number": "14",
"street": "Rutherford Street",
"suburb": "Nelson Central",
"city": "Nelson",
"postcode": "7010",
"full": "14 Rutherford Street, Nelson Central, Nelson 7010"
},
"lat": -41.2735,
"lng": 173.2854
},
"branch": {
"id": "0b6f1a52-3c3e-4b1a-9a57-6a1f0c2d9e10",
"name": "Nelson"
},
"quote_id": "c3d4e5f6-a7b8-4c9d-8e0f-1a2b3c4d5e6f",
"invoice_id": null,
"repeat_booking_id": null,
"description": "Windows, inside and out. Tracks and screens.",
"customer_note": null,
"estimated_hours": 3,
"scheduled_start": "2026-11-04T19:30:00.000Z",
"scheduled_end": "2026-11-04T22:30:00.000Z",
"bookings": [
{
"id": "f6a7b8c9-d0e1-4f2a-8b3c-4d5e6f7a8b9c",
"starts_at": "2026-11-04T19:30:00.000Z",
"ends_at": "2026-11-04T22:30:00.000Z",
"crew": {
"id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"name": "Nelson A"
},
"person": null,
"note": null
}
],
"started_at": null,
"completed_at": null,
"billing": null,
"created_at": "2026-10-21T08:30:05.000Z",
"updated_at": "2026-10-22T01:10:12.000Z"
}Invoices
GET/api/v1/invoices
| Parameter | What it does |
|---|---|
| status | One or more of draft, sent, paid, overdue, void. |
| customer_id | Only this customer's. |
| page | Which page, from 1. |
| limit | How many on a page, 1 to 100 (25 to start). |
| branch_id | Only this branch's. |
| updated_since | Only those changed since this date or time (ISO 8601), for keeping a copy in step. |
GET/api/v1/invoices/{id}
{
"id": "a7b8c9d0-e1f2-4a3b-8c4d-5e6f7a8b9c0d",
"object": "invoice",
"number": "000117",
"status": "paid",
"customer": {
"id": "5d2c8e7a-91f4-4c3b-8f0e-2a6b7c9d1e33",
"name": "Hannah and Rob Walker"
},
"property_id": "7e1d2c3b-4a5f-4e6d-8c7b-9a0f1e2d3c4b",
"job_id": "e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a8b",
"quote_id": "c3d4e5f6-a7b8-4c9d-8e0f-1a2b3c4d5e6f",
"branch": {
"id": "0b6f1a52-3c3e-4b1a-9a57-6a1f0c2d9e10",
"name": "Nelson"
},
"taxed": true,
"subtotal_cents": 34000,
"discount_cents": 1700,
"discount_label": null,
"loyalty_cents": 0,
"gst_cents": 4845,
"total_cents": 37145,
"paid_cents": 37145,
"credited_cents": 0,
"owing_cents": 0,
"issue_date": "2026-11-05",
"due_date": "2026-11-19",
"sent_at": "2026-11-04T22:40:12.000Z",
"paid_at": "2026-11-04T22:41:03.000Z",
"voided_at": null,
"url": "https://nozl.co.nz/i/Pw2T…",
"lines": [
{
"description": "Windows, inside and out",
"part_no": "WIN-2",
"qty": 1,
"unit": "each",
"unit_price_cents": 28000,
"total_cents": 28000
}
],
"payments": [
{
"id": "b8c9d0e1-f2a3-4b4c-8d5e-6f7a8b9c0d1e",
"object": "payment",
"invoice_id": "a7b8c9d0-e1f2-4a3b-8c4d-5e6f7a8b9c0d",
"job_id": "e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a8b",
"amount_cents": 37145,
"refunded_cents": 0,
"method": "eftpos",
"status": "confirmed",
"reference": null,
"received_on": "2026-11-05",
"taken_at": "2026-11-04T22:41:00.000Z",
"voided_at": null,
"void_reason": null,
"created_at": "2026-11-04T22:41:03.000Z"
}
],
"created_at": "2026-11-04T22:39:50.000Z",
"updated_at": "2026-11-04T22:41:03.000Z"
}Payments
A payment's status is confirmed once it counts, pending for a bank transfer the office hasn't seen arrive yet, or voided.
GET/api/v1/payments
| Parameter | What it does |
|---|---|
| invoice_id | Only this invoice's. |
| created_since | Only those recorded since this date or time. |
| page, limit, branch_id | As for every list. |
GET/api/v1/payments/{id}
{
"id": "b8c9d0e1-f2a3-4b4c-8d5e-6f7a8b9c0d1e",
"object": "payment",
"invoice_id": "a7b8c9d0-e1f2-4a3b-8c4d-5e6f7a8b9c0d",
"job_id": "e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a8b",
"amount_cents": 37145,
"refunded_cents": 0,
"method": "eftpos",
"status": "confirmed",
"reference": null,
"received_on": "2026-11-05",
"taken_at": "2026-11-04T22:41:00.000Z",
"voided_at": null,
"void_reason": null,
"created_at": "2026-11-04T22:41:03.000Z"
}Webhooks
A webhook is Nozl telling an address of yours that something happened, as it happens, so you needn't keep asking. Add one in Settings › API and webhooks › Add endpoint: an https address and the events it wants (or every event). Each endpoint has a signing secret, starting whsec_.
Each event is a POST with this JSON. data is the record as the API gives it, when the event was first sent.
{
"id": "d0e1f2a3-b4c5-4d6e-8f7a-8b9c0d1e2f3a",
"object": "event",
"type": "job.completed",
"created_at": "2026-11-04T22:35:14.120Z",
"data": {
"id": "e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a8b",
"object": "job",
"number": "000208",
"status": "done",
"customer": {
"id": "5d2c8e7a-91f4-4c3b-8f0e-2a6b7c9d1e33",
"name": "Hannah and Rob Walker"
},
"property": {
"id": "7e1d2c3b-4a5f-4e6d-8c7b-9a0f1e2d3c4b",
"address": {
"unit": null,
"street_number": "14",
"street": "Rutherford Street",
"suburb": "Nelson Central",
"city": "Nelson",
"postcode": "7010",
"full": "14 Rutherford Street, Nelson Central, Nelson 7010"
},
"lat": -41.2735,
"lng": 173.2854
},
"branch": {
"id": "0b6f1a52-3c3e-4b1a-9a57-6a1f0c2d9e10",
"name": "Nelson"
},
"quote_id": "c3d4e5f6-a7b8-4c9d-8e0f-1a2b3c4d5e6f",
"invoice_id": null,
"repeat_booking_id": null,
"description": "Windows, inside and out. Tracks and screens.",
"customer_note": null,
"estimated_hours": 3,
"scheduled_start": "2026-11-04T19:30:00.000Z",
"scheduled_end": "2026-11-04T22:30:00.000Z",
"bookings": [
{
"id": "f6a7b8c9-d0e1-4f2a-8b3c-4d5e6f7a8b9c",
"starts_at": "2026-11-04T19:30:00.000Z",
"ends_at": "2026-11-04T22:30:00.000Z",
"crew": {
"id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"name": "Nelson A"
},
"person": null,
"note": null
}
],
"started_at": null,
"completed_at": "2026-11-04T22:35:14.000Z",
"billing": null,
"created_at": "2026-10-21T08:30:05.000Z",
"updated_at": "2026-10-22T01:10:12.000Z"
}
}| Parameter | What it does |
|---|---|
| Nozl-Event | The event's type, such as job.completed. |
| Nozl-Delivery | This delivery's id (the same on every retry). |
| Nozl-Signature | t=<time>,v1=<signature>: see Checking signatures. |
- Answer with any 2xx within 10 seconds. Do slow work after you've answered.
- An event can arrive more than once, and not always in order. Use its
idto skip one you've had, and the record'supdated_atto tell which is newer. - Send test on an endpoint sends a
webhook.testevent, to check it's set up.
Events
| Event | When |
|---|---|
| customer.created | A customer is added, in Nozl, by a lead form, door knocking or the API. |
| quote.sent | A quote is sent to the customer. |
| quote.accepted | A quote is accepted, online or for the customer in the office. |
| quote.declined | A quote is declined. |
| job.created | A job is made, from an accepted quote or on its own. |
| job.scheduled | A job is first put on the calendar. |
| job.started | A job is started. |
| job.completed | A job is marked done. |
| job.cancelled | A job is cancelled. |
| invoice.sent | An invoice is sent (or issued on the job). |
| invoice.paid | An invoice is paid in full. |
| invoice.overdue | An invoice is past its due date and still owing. |
| invoice.voided | An invoice is voided. |
| payment.received | A payment counts: recorded, paid online, or a bank transfer the office has confirmed. |
Checking signatures
Check every webhook came from Nozl before acting on it. Nozl-Signature is t= the time it was sent (seconds since 1970) and v1= the HMAC-SHA256, in hex, of the time, a full stop and the body exactly as it arrived, with your endpoint's signing secret as the key. Work it out yourself, compare, and turn away anything more than 5 minutes old.
import crypto from "node:crypto";
// In your webhook's handler: the body as text, exactly as it arrived.
function fromNozl(body, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const sent = Number(parts.t);
if (!sent || Math.abs(Date.now() / 1000 - sent) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${sent}.${body}`).digest("hex");
return parts.v1?.length === expected.length && crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
}
if (!fromNozl(rawBody, request.headers["nozl-signature"], process.env.NOZL_WEBHOOK_SECRET)) {
return response.status(400).end();
}Retries
Anything but a 2xx (or no answer within 10 seconds) is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours, 24 hours: 8 tries over about a day and a half, then given up. Every delivery, with what your endpoint answered, is listed under the endpoint in Settings, with Resend. Turning an endpoint off stops its deliveries; events while it's off aren't kept for it.
Questions? hello@nozl.co.nz