Developers

Nozl API and webhooks

Read your customers, quotes, jobs, invoices and payments from your own systems, add customers, and hear the moment things happen.

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.

  1. Sign in to Nozl as head office and open Settings › API and webhooks.
  2. Choose Create API key, name it for what will use it, and copy the key. It's shown once.
  3. 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: 37145 is $371.45 NZD. Amounts named …_cents are before GST unless they're a total.
  • Times are ISO 8601 in UTC (2026-11-04T19:30:00.000Z); days on their own are yyyy-mm-dd in 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
}
ParameterWhat it does
pageWhich page, from 1.
limitHow many on a page, 1 to 100 (25 to start).
branch_idOnly this branch's.
updated_sinceOnly 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."
  }
}
ParameterWhat it does
400A filter isn't right, or the body isn't JSON.
401No key, or a key that's wrong or revoked.
403A Read only key asking to add something.
404Nothing of yours with that id.
422What was sent didn't pass a check (the message says which field).
429More than 120 requests a minute with one key. Wait the Retry-After seconds.
500Something went wrong on Nozl's side.

Branches

GET/api/v1/branches

Every branch of the business, in one list.
{
  "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

Customers, newest first, each with their addresses.
ParameterWhat it does
qName, email, phone or contact person contains this.
created_sinceOnly those added since this date or time.
pageWhich page, from 1.
limitHow many on a page, 1 to 100 (25 to start).
branch_idOnly this branch's.
updated_sinceOnly those changed since this date or time (ISO 8601), for keeping a copy in step.

GET/api/v1/customers/{id}

One customer.

POST/api/v1/customers

Add a customer, and their first address with 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.
ParameterWhat it does
nameNeeded: the customer's or company's name.
typeresidential (to start) or commercial.
first_name, last_nameThe contact person.
email, phoneTheir email, and a phone (021 123 4567 or +64…).
sourceweb, phone, email, referral, door_knock, repeat or other.
tagsUp to 20 words.
notesAnything to know.
branch_idWhich branch they're with.
addressstreet (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

Quotes, newest first.
ParameterWhat it does
statusOne or more of draft, locked, sent, viewed, accepted, declined, expired, separated by commas.
customer_idOnly this customer's.
all_versionstrue for every version.
pageWhich page, from 1.
limitHow many on a page, 1 to 100 (25 to start).
branch_idOnly this branch's.
updated_sinceOnly those changed since this date or time (ISO 8601), for keeping a copy in step.

GET/api/v1/quotes/{id}

One quote, any version.
{
  "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

Jobs, newest first, or by when they're booked with scheduled_from and scheduled_to. Each has its booked dates (bookings) and who's on them.
ParameterWhat it does
statusOne or more of unscheduled, scheduled, in_progress, stopped_safety, done, invoiced, paid, cancelled.
customer_idOnly this customer's.
scheduled_from, scheduled_toBooked to start in this time (ISO 8601), soonest first.
pageWhich page, from 1.
limitHow many on a page, 1 to 100 (25 to start).
branch_idOnly this branch's.
updated_sinceOnly those changed since this date or time (ISO 8601), for keeping a copy in step.

GET/api/v1/jobs/{id}

One job.
{
  "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

Invoices, newest first, with their lines and payments.
ParameterWhat it does
statusOne or more of draft, sent, paid, overdue, void.
customer_idOnly this customer's.
pageWhich page, from 1.
limitHow many on a page, 1 to 100 (25 to start).
branch_idOnly this branch's.
updated_sinceOnly those changed since this date or time (ISO 8601), for keeping a copy in step.

GET/api/v1/invoices/{id}

One invoice.
{
  "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

Payments, newest first.
ParameterWhat it does
invoice_idOnly this invoice's.
created_sinceOnly those recorded since this date or time.
page, limit, branch_idAs for every list.

GET/api/v1/payments/{id}

One payment.
{
  "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"
  }
}
ParameterWhat it does
Nozl-EventThe event's type, such as job.completed.
Nozl-DeliveryThis delivery's id (the same on every retry).
Nozl-Signaturet=<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 id to skip one you've had, and the record's updated_at to tell which is newer.
  • Send test on an endpoint sends a webhook.test event, to check it's set up.

Events

EventWhen
customer.createdA customer is added, in Nozl, by a lead form, door knocking or the API.
quote.sentA quote is sent to the customer.
quote.acceptedA quote is accepted, online or for the customer in the office.
quote.declinedA quote is declined.
job.createdA job is made, from an accepted quote or on its own.
job.scheduledA job is first put on the calendar.
job.startedA job is started.
job.completedA job is marked done.
job.cancelledA job is cancelled.
invoice.sentAn invoice is sent (or issued on the job).
invoice.paidAn invoice is paid in full.
invoice.overdueAn invoice is past its due date and still owing.
invoice.voidedAn invoice is voided.
payment.receivedA 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