For pros and the tools they use

NorTech Pro API and webhooks

Connect the software you already use to your NorTech Pro Business account. Read and write your own customers, estimates, jobs and invoices, get a signed message the moment something happens, download QuickBooks files, and subscribe to your calendar.

Get started

API access is part of the Business plan and above. Create a key in the pro app under Developers, choose what it may do, and copy it. It is shown once. Every request goes to https://nortech.site/api/v1/pro and returns JSON.

Try it
curl https://nortech.site/api/v1/pro/customers?limit=10 \
  -H "Authorization: Bearer ntk_xxxxxxxx_your_key"

curl -X POST https://nortech.site/api/v1/pro/customers \
  -H "Authorization: Bearer ntk_xxxxxxxx_your_key" \
  -H "Idempotency-Key: signup-2026-10-0001" \
  -H "Content-Type: application/json" \
  -d '{"name":"Dana Whitfield","email":"dana@example.com","phone":"+14085550142"}'

The full machine-readable description is the OpenAPI 3 document at /api/v1/openapi.json. Import it into Postman, Insomnia or a code generator. Using Zapier or Make? Start with the Zapier and Make guide.

Amounts are whole cents. Times are UTC in ISO 8601. The API only ever shows records that belong to the pro who owns the key: your own customers, not marketplace customers.

Authentication

Send the key as a bearer token on every request. A key looks like ntk_ab12cd34_ followed by 40 characters. NorTech keeps only a hash of it, so a lost key cannot be shown again: make a new one and revoke the old.

Authorization: Bearer ntk_ab12cd34_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

A key can be limited to a list of IP addresses or ranges, revoked at any time, or rotated: rotating creates a new key with the same settings and the old key keeps working for 24 hours so nothing breaks while you switch over. GET /me returns the key's name and scopes and is the connection test to use in other tools.

Scopes

Each key carries the scopes you pick, one read and one write scope per resource. Write does not include read. A call outside the key's scopes answers 403 insufficient_scope.

ResourceScopesWhat you can do
Customerscustomers:read, customers:writelist, get, create, update, delete
Propertiesproperties:read, properties:writelist, get, create, update, delete
Estimatesestimates:read, estimates:writelist, get, create, update
Jobsjobs:read, jobs:writelist, get, create, update
Invoicesinvoices:read, invoices:writelist, get, create, update
Paymentspayments:readlist, get
Price bookprice_book:read, price_book:writelist, get, create, update, delete
Calendar eventscalendar_events:readlist
Webhookswebhooks:read, webhooks:writelist, get, create, delete

Errors

Every error has the same shape and an HTTP status that matches its type. Quote the request_id if you contact support.

{
  "error": {
    "type": "invalid_request",
    "code": "validation_failed",
    "message": "The request has 1 problem. See issues.",
    "request_id": "req_3f9a1c52e07b4d18a6",
    "param": "email",
    "issues": [{ "param": "email", "message": "Must be a string." }]
  }
}
StatusTypeMeaning
400invalid_requestThe request is malformed or a field is wrong. See issues for each field.
401authenticationThe key is missing, wrong, revoked, expired, or used from an address that is not allowed.
402plan_requiredThe plan no longer includes the API. Existing data stays; upgrade to use the API again.
403permissionThe key does not carry the scope this call needs.
404not_foundNo such record for your account.
409conflictThe record cannot be changed in its current state, or an Idempotency-Key was reused with a different request.
429rate_limitToo many requests. Wait for the Retry-After seconds.
500serverSomething went wrong on our side.

Pagination

List calls return the newest first. Pass limit (1 to 100, default 25) and, for the next page, cursor set to next_cursor from the previous answer. When has_more is false you have everything. Cursors are opaque and stay valid while records are added.

{
  "object": "list",
  "data": [ { "object": "customer", "id": "0b7f1c9e-...", "name": "Dana Whitfield" } ],
  "has_more": true,
  "next_cursor": "eyJ0IjoiMjAyNi0xMC0wMVQxNjoyMDowMC4wMDBaIiwiaWQiOiIwYjdmMWM5ZSJ9"
}

Most lists also take updated_since (an ISO 8601 time), which is the cheap way to poll for changes.

Idempotency

Add an Idempotency-Key header (8 to 200 characters) to any POST or PATCH you may need to retry. The first answer is stored for 24 hours and repeated for the same key and the same request, with Idempotent-Replayed: true. The same key with a different request answers 409 idempotency_key_reused. Server errors are not stored, so a retry runs again.

Rate limits

A key may make 120 requests a minute by default; its owner can set it between 10 and 600. Every answer carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds until the minute resets). When you are over, the answer is 429 with Retry-After.

Resources

Paths are relative to /api/v1/pro. Lists are GET /{path}, one record is GET /{path}/{id}, create is POST /{path}, change is PATCH /{path}/{id} and remove is DELETE /{path}/{id}, where the resource allows it. Fields marked read only are set by NorTech. Estimates show the option the customer chose, else the recommended one, else the first.

Customers /customers

The people and companies you work for. These are your own customers, kept apart from marketplace customers.

Operations: GETGET /{id}POSTPATCH /{id}DELETE /{id}

FieldTypeNotes
idstringThe customer id. Read only.
namestringFull name or company name. Required on create.
emailstringEmail address. Optional.
phonestringPhone number. Optional.
companystringCompany name, if any. Optional.
notesstringPrivate notes. Never shown to the customer. Optional.
tagsstring arrayLabels you use to group customers. Optional.
created_atdatetimeWhen it was created (UTC). Read only.
updated_atdatetimeWhen it last changed (UTC). Read only.

Filters: email, updated_since.

Example
{
  "object": "customer",
  "id": "0b7f1c9e-5a2d-4c1b-9d1e-6f3a2b8c4d10",
  "name": "Dana Whitfield",
  "email": "dana@example.com",
  "phone": "+14085550142",
  "company": "Whitfield Dental",
  "notes": "Gate code 4471",
  "tags": [
    "repeat",
    "commercial"
  ],
  "created_at": "2026-10-01T16:20:00.000Z",
  "updated_at": "2026-10-02T09:05:00.000Z"
}

Properties /properties

The addresses where you do the work. Each belongs to one customer.

Operations: GETGET /{id}POSTPATCH /{id}DELETE /{id}

FieldTypeNotes
idstringThe property id. Read only.
customer_idstringThe customer this address belongs to. Required on create.
labelstringA short name such as "Main house". Optional.
streetstringStreet address. Required on create.
citystringCity. Required on create.
statestringTwo-letter state code. Required on create.
zipstringZIP code. Required on create.
notesstringAccess notes, pets, parking. Optional.
created_atdatetimeWhen it was created (UTC). Read only.

Filters: customer_id.

Example
{
  "object": "property",
  "id": "0b7f1c9e-5a2d-4c1b-9d1e-6f3a2b8c4d10",
  "customer_id": "0b7f1c9e-5a2d-4c1b-9d1e-6f3a2b8c4d10",
  "label": "Main house",
  "street": "1450 Alder Way",
  "city": "San Jose",
  "state": "CA",
  "zip": "95125",
  "notes": "Dog in the yard",
  "created_at": "2026-10-01T16:20:00.000Z"
}

Estimates /estimates

Written quotes you send to your customers. Totals are computed by NorTech from the line items.

Operations: GETGET /{id}POSTPATCH /{id}

FieldTypeNotes
idstringThe estimate id. Read only.
numberstringYour estimate number. Read only.
customer_idstringThe customer. Required on create.
property_idstringWhere the work happens. Optional.
titlestringA short title for the work. Required on create.
statusstringDRAFT, SENT, VIEWED, ACCEPTED, DECLINED or EXPIRED. Only DRAFT estimates can be edited through the API. Sending and acceptance happen in the app or by the customer. Read only. One of DRAFT, SENT, VIEWED, ACCEPTED, DECLINED, EXPIRED.
line_itemsline itemsThe lines of the estimate. Required on create.
subtotal_centsintegerSum of the lines, in cents. Read only.
tax_centsintegerSales tax, in cents. Read only.
total_centsintegerTotal in cents. Read only.
valid_untildateLast day the estimate can be accepted. Optional.
accepted_atdatetimeWhen the customer accepted. Read only.
created_atdatetimeWhen it was created (UTC). Read only.
updated_atdatetimeWhen it last changed (UTC). Read only.

Filters: status, customer_id, updated_since.

Example
{
  "object": "estimate",
  "id": "0b7f1c9e-5a2d-4c1b-9d1e-6f3a2b8c4d10",
  "number": "EST-0042",
  "customer_id": "0b7f1c9e-5a2d-4c1b-9d1e-6f3a2b8c4d10",
  "property_id": "5d0a6e4b-77c2-4f1f-8a63-0e9a3d1b7c22",
  "title": "Replace kitchen faucet",
  "status": "DRAFT",
  "line_items": [
    {
      "description": "Faucet and install",
      "quantity": 1,
      "unit_price_cents": 18500,
      "taxable": false
    }
  ],
  "subtotal_cents": 18500,
  "tax_cents": 0,
  "total_cents": 18500,
  "valid_until": "2026-10-31",
  "accepted_at": null,
  "created_at": "2026-10-01T16:20:00.000Z",
  "updated_at": "2026-10-02T09:05:00.000Z"
}

Jobs /jobs

Scheduled and finished work for your own customers. Marketplace jobs appear in calendar events, not here.

Operations: GETGET /{id}POSTPATCH /{id}

FieldTypeNotes
idstringThe job id. Read only.
customer_idstringThe customer. Required on create.
property_idstringWhere the work happens. Optional.
estimate_idstringThe estimate this job came from. Optional.
titlestringA short title. Required on create.
statusstringUNSCHEDULED, SCHEDULED, IN_PROGRESS, COMPLETED or CANCELLED. Optional. One of UNSCHEDULED, SCHEDULED, IN_PROGRESS, COMPLETED, CANCELLED.
scheduled_startdatetimePlanned start (UTC). Optional.
scheduled_enddatetimePlanned end (UTC). Optional.
completed_atdatetimeWhen the work was finished. Read only.
notesstringPrivate notes. Optional.
created_atdatetimeWhen it was created (UTC). Read only.
updated_atdatetimeWhen it last changed (UTC). Read only.

Filters: status, customer_id, updated_since.

Example
{
  "object": "job",
  "id": "0b7f1c9e-5a2d-4c1b-9d1e-6f3a2b8c4d10",
  "customer_id": "0b7f1c9e-5a2d-4c1b-9d1e-6f3a2b8c4d10",
  "property_id": "5d0a6e4b-77c2-4f1f-8a63-0e9a3d1b7c22",
  "estimate_id": null,
  "title": "Replace kitchen faucet",
  "status": "SCHEDULED",
  "scheduled_start": "2026-10-12T17:00:00.000Z",
  "scheduled_end": "2026-10-12T19:00:00.000Z",
  "completed_at": null,
  "notes": "Bring a 3/8 inch supply line",
  "created_at": "2026-10-01T16:20:00.000Z",
  "updated_at": "2026-10-02T09:05:00.000Z"
}

Invoices /invoices

Bills you send to your customers. Payments are recorded by NorTech when the customer pays; read them from the payments resource.

Operations: GETGET /{id}POSTPATCH /{id}

FieldTypeNotes
idstringThe invoice id. Read only.
numberstringYour invoice number. Read only.
customer_idstringThe customer. Required on create.
job_idstringThe job being billed. Optional.
titlestringA short title shown on the invoice. Optional.
statusstringDRAFT, SENT, PARTIAL, PAID, OVERDUE (sent or partly paid and past due) or VOID. Only DRAFT invoices can be edited through the API. Read only. One of DRAFT, SENT, PARTIAL, PAID, OVERDUE, VOID.
line_itemsline itemsThe lines of the invoice. Required on create.
subtotal_centsintegerSum of the lines, in cents. Read only.
tax_centsintegerSales tax, in cents. Read only.
total_centsintegerTotal in cents. Read only.
amount_paid_centsintegerPaid so far, in cents. Read only.
due_datedatePayment due date. Optional.
issued_atdatetimeWhen the invoice was sent. Read only.
paid_atdatetimeWhen it was fully paid. Read only.
created_atdatetimeWhen it was created (UTC). Read only.
updated_atdatetimeWhen it last changed (UTC). Read only.

Filters: status, customer_id, updated_since.

Example
{
  "object": "invoice",
  "id": "0b7f1c9e-5a2d-4c1b-9d1e-6f3a2b8c4d10",
  "number": "INV-0108",
  "customer_id": "0b7f1c9e-5a2d-4c1b-9d1e-6f3a2b8c4d10",
  "job_id": null,
  "title": "Faucet replacement",
  "status": "SENT",
  "line_items": [
    {
      "description": "Labor, 2 hours",
      "quantity": 2,
      "unit_price_cents": 9500,
      "taxable": false
    }
  ],
  "subtotal_cents": 19000,
  "tax_cents": 0,
  "total_cents": 19000,
  "amount_paid_cents": 0,
  "due_date": "2026-10-26",
  "issued_at": "2026-10-12T20:00:00.000Z",
  "paid_at": null,
  "created_at": "2026-10-01T16:20:00.000Z",
  "updated_at": "2026-10-02T09:05:00.000Z"
}

Payments /payments

Money received against your invoices. Read only.

Operations: GETGET /{id}

FieldTypeNotes
idstringThe payment id. Read only.
invoice_idstringThe invoice that was paid. Read only.
amount_centsintegerAmount in cents. Read only.
methodstringCARD, BANK, CASH, CHECK or OTHER. Read only. One of CARD, BANK, CASH, CHECK, OTHER.
refunded_centsintegerPart of this payment that was refunded, in cents. Read only.
referencestringThe note the pro left on the payment, such as a check number. Read only.
received_atdatetimeWhen the money was received. Read only.
created_atdatetimeWhen it was created (UTC). Read only.

Filters: invoice_id, received_since.

Example
{
  "object": "payment",
  "id": "0b7f1c9e-5a2d-4c1b-9d1e-6f3a2b8c4d10",
  "invoice_id": "7a3e9c10-2b44-4d6f-8f10-5c7d9a0b1e33",
  "amount_cents": 19000,
  "method": "CARD",
  "refunded_cents": 0,
  "reference": null,
  "received_at": "2026-10-14T15:30:00.000Z",
  "created_at": "2026-10-01T16:20:00.000Z"
}

Price book /price-book

The services and materials you quote and bill. The same list feeds your estimates and invoices.

Operations: GETGET /{id}POSTPATCH /{id}DELETE /{id}

FieldTypeNotes
idstringThe price book item id. Read only.
namestringItem name. Required on create.
descriptionstringLonger description shown on estimates. Optional.
unitstringUnit of measure: each, hour, sqft, ft. Optional.
unit_price_centsintegerPrice per unit in cents. Required on create.
taxablebooleanWhether sales tax applies. Optional.
activebooleanInactive items are hidden when building an estimate. Optional.
created_atdatetimeWhen it was created (UTC). Read only.

Filters: active.

Example
{
  "object": "price_book_item",
  "id": "0b7f1c9e-5a2d-4c1b-9d1e-6f3a2b8c4d10",
  "name": "Faucet replacement",
  "description": "Supply and install a customer-chosen faucet.",
  "unit": "each",
  "unit_price_cents": 18500,
  "taxable": false,
  "active": true,
  "created_at": "2026-10-01T16:20:00.000Z"
}

Calendar events /calendar-events

Everything on your calendar: jobs for your own customers and your marketplace jobs. Read only.

Operations: GET

FieldTypeNotes
idstringEvent id. Read only.
kindstringOWN_JOB for your own customers, MARKETPLACE_JOB for a NorTech booking. Read only. One of OWN_JOB, MARKETPLACE_JOB.
titlestringWhat the event is. Read only.
startdatetimeStart (UTC). Read only.
enddatetimeEnd (UTC). Read only.
locationstringAddress, when you may see it. Read only.
job_idstringThe job record behind the event. Read only.

Filters: from, to.

Example
{
  "object": "calendar_event",
  "id": "own-0b7f1c9e-5a2d-4c1b-9d1e-6f3a2b8c4d10",
  "kind": "OWN_JOB",
  "title": "Replace kitchen faucet",
  "start": "2026-10-12T17:00:00.000Z",
  "end": "2026-10-12T19:00:00.000Z",
  "location": "1450 Alder Way, San Jose, CA 95125",
  "job_id": "0b7f1c9e-5a2d-4c1b-9d1e-6f3a2b8c4d10"
}

Webhooks /webhooks

Subscribe a URL to events from code. This is what Zapier and Make use to start a workflow the moment something happens. Deliveries are signed.

Operations: GETGET /{id}POSTDELETE /{id}

FieldTypeNotes
idstringThe webhook id. Read only.
urlstringA public https URL. Private, local and reserved addresses are refused. Required on create.
eventsstring arrayEvent types to send, such as invoice.paid. Required on create.
descriptionstringA note to yourself. Optional.
activebooleanFalse once the endpoint was switched off. Read only.
secretstringSigning secret. Returned once, when the webhook is created. Read only.
created_atdatetimeWhen it was created (UTC). Read only.
Example
{
  "object": "webhook",
  "id": "0b7f1c9e-5a2d-4c1b-9d1e-6f3a2b8c4d10",
  "url": "https://hooks.example.com/nortech",
  "events": [
    "invoice.paid",
    "job.completed"
  ],
  "description": "Zapier: new paid invoices",
  "active": true,
  "secret": "whsec_Pq3L0...",
  "created_at": "2026-10-01T16:20:00.000Z"
}

Webhooks

Add a webhook URL in the Developers page (or create one with POST /webhooks) and pick the events it should receive. NorTech sends an HTTPS POST with a JSON body and a signature. Answer with any 2xx status within 10 seconds to confirm. The URL must be a public HTTPS address; private, local and cloud-metadata addresses are refused, and redirects are not followed.

EventSent when
customer.createdA customer was added
estimate.acceptedA customer accepted an estimate
job.scheduledA job was scheduled
job.completedA job was completed
invoice.createdAn invoice was created
invoice.paidAn invoice was paid
invoice.overdueAn invoice became overdue
review.receivedA customer left a review
Body
{
  "id": "evt_5f2a9c0b41d37e6a8c1f02d3",
  "type": "invoice.paid",
  "api_version": "2026-10-01",
  "created": "2026-10-14T15:30:02.114Z",
  "data": { "object": { "object": "invoice", "id": "7a3e9c10-...", "number": "INV-0108", "status": "PAID", "total_cents": 19000 } }
}

Headers sent with every delivery: NorTech-Signature, NorTech-Event, NorTech-Event-Id (the same id on every retry, so you can ignore repeats), NorTech-Delivery and NorTech-Attempt.

If your URL does not answer 2xx, NorTech tries again after 1 min, 5 min, 30 min, 2 h, 6 h, 24 h (7 attempts in all). The delivery log in the Developers page shows every attempt with the answer we got, and you can send any delivery again by hand. If 5 deliveries in a row use up all their attempts, the webhook is switched off and you are told; fix the URL and switch it back on.

Verify signatures

Each webhook has a signing secret that starts with whsec_, shown once when you create it (you can reset it any time). The NorTech-Signature header looks like t=1760455802,v1=9f2c.... Compute HMAC-SHA256 of {t}.{raw body} with the secret, compare it to v1 in constant time, and reject anything with a timestamp more than 5 minutes old, which stops a captured request from being replayed.

Node.js
import { createHmac, timingSafeEqual } from 'node:crypto'

// rawBody is the exact text you received. Do not parse and re-serialize it first.
export function verify(secret, header, rawBody, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
  const t = Number(parts.t)
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false
  const expected = createHmac('sha256', secret).update(t + '.' + rawBody).digest('hex')
  const got = String(parts.v1 || '')
  return got.length === expected.length && timingSafeEqual(Buffer.from(got), Buffer.from(expected))
}
Python
import hmac, hashlib, time

def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts.get("t", "0"))
    if not t or abs(time.time() - t) > tolerance:
        return False
    signed = str(t).encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(parts.get("v1", ""), expected)

Calendar link

In the Developers page you get a private calendar address that works in Google Calendar, Apple Calendar and Outlook. It lists your marketplace jobs and the jobs you scheduled for your own customers, and updates on its own. Anyone who has the address can see the calendar, so you can replace it at any time and the old one stops working. The same events are available from GET /calendar-events.

Two-way Google Calendar sync

Events you add in Google Calendar do not flow back into NorTech yet. That needs a Google sign-in app for calendars, which is not set up. The calendar link is one way: from NorTech to your calendar.

QuickBooks files

On the Business plan and above, the Developers page downloads your books in the layouts QuickBooks imports: invoices, payments, customers and items as CSV, one IIF file for QuickBooks Desktop, a general ledger style journal where every entry balances, and a sales tax report by month (accrual or cash basis). You choose the account names (income, sales tax payable, accounts receivable, deposit) once and every file uses them. Text that begins with a formula character is neutralized so a spreadsheet never runs it.

Live QuickBooks connection

A live connection that sends each invoice to QuickBooks as it happens needs NorTech to register an app with Intuit. It is not available yet; the files above work today.

Security

Keys are stored as SHA-256 hashes and compared in constant time. Every key creation, rotation, revocation and webhook change is written to an audit trail you can see in the Developers page, with the address it came from. Signing secrets are encrypted at rest. Secrets never appear in logs. Rate limits are counted in the database, so they hold across servers. Report a security problem through the contact page.