Core concepts
Tenants, actors, resources, and the CRUD conventions every tool follows.
This page explains the mental model behind every Zoop API call. Read it once before you write any integration code — the terms show up everywhere in the API reference, so it's worth five minutes now.
Tenants
A tenant is one Zoop account — one contractor business. All data in Zoop belongs to exactly one tenant. When you make an API call, you operate on one tenant only, and that tenant is determined by your access token, not by anything in the URL.
There is no way to ask the API to operate across multiple tenants in a single request. If you build a platform that manages many Zoop accounts, you need one token per tenant.
Actors
Every API request is made by an actor — the identity your token represents. Think of it as "who is doing this action?" There are two kinds:
A user actor (actorKind: 'user') represents a real person who is a member of the tenant. The token carries:
- A
userIdidentifying the person. - A
rolewithin the tenant:owner,office, ortech. - A set of scopes — permissions that control what the token can read or write.
User actors are issued by OAuth access tokens and by user-bound API keys (prefixed zoop_uk_).
Many write operations — anything that stamps an author or assignee on a record — require a user actor. Tenant-key tokens cannot call those tools.
A tenant actor (actorKind: 'tenant') represents the tenant account itself — not any specific person. It has no userId and no role. Use this for automated scripts or backend services where there is no human in the loop. It is issued only by tenant-bound API keys (prefixed zoop_tk_).
Tenant actors can call any read tool and any write tool that does not require user attribution. Tools that require a user actor return an invalid_input error if you call them with a tenant-key token.
If your integration creates jobs, notes, or other records that need an author attached, use a user-bound API key (zoop_uk_…) or OAuth — not a tenant key (zoop_tk_…). The API will reject the call otherwise.
Resources
Zoop organizes its data into 13 resources — the core objects you read and write through the API. Each resource maps to a set of tools and a pair of scopes (permissions): one for reading (read:<resource>) and one for writing (write:<resource>). Your token must hold the relevant scope or the call will be rejected.
| Resource | What it represents | Scope pair |
|---|---|---|
| Customers | People and companies you do work for | read:customers / write:customers |
| Contacts | Phone/email contacts on a customer | (sub-resource of customers) |
| Locations | Service addresses on a customer | (sub-resource of customers) |
| Tags | Labels in the tenant's tag library | (sub-resource of customers) |
| Notes | Free-text notes attached to any subject | read:notes / write:notes |
| Jobs | Individual work orders | read:jobs / write:jobs |
| Job series | Recurring job templates | read:job_series / write:job_series |
| Invoices | Bills sent to customers | read:invoices / write:invoices |
| Quotes | Estimates sent before work starts | read:quotes / write:quotes |
| Catalog items | Pricebook entries (services, products, bundles) | read:catalog_items / write:catalog_items |
| Catalog categories | Folders for catalog items | read:catalog_categories / write:catalog_categories |
| Plans | Recurring billing plan templates | read:plans / write:plans |
| Tax rates | Tax rates applied to invoices and quotes | read:tax_rates / write:tax_rates |
Contacts, locations, and tags are sub-resources of customers — they share the read:customers / write:customers scopes and cannot exist outside a tenant's customer domain.
Notes are the exception: they have their own scope pair because a note can attach to many subject types, not just customers.
The five operations every resource supports
Every resource exposes exactly five operations (sometimes called CRUD — create, read, update, delete). They always follow the same shape:
| Verb | What it does |
|---|---|
| list or search | Returns multiple records with pagination. |
| get | Fetches one full record by ID. |
| create | Creates a new record. |
| update | Partially updates an existing record — only the fields you send are changed. |
| remove verb | Removes or terminates the record. Usually archive, but a few resources use a different verb (see below). |
Tool names follow the pattern <resource>.<verb>, for example customers.search, jobs.create, invoices.void.
List and search
List tools return one page of records at a time. Every list response includes pagination metadata so you can fetch the next page. There are two pagination styles — cursor-based and page-based — depending on the resource. See Pagination below for examples.
Get
Fetch one record by its UUID (the unique ID assigned to every record). If the record does not exist in this tenant, or has been soft-deleted, the tool returns a not_found error — never an empty success.
Create
Creates the record. You do not need to compute totals or derived values — the server calculates those (totals, tax, next billing dates, and similar). Records that have a lifecycle — invoices and quotes — always start in draft status.
Update
Sends a partial update: only the fields you include in the request are changed — everything else stays as-is. There is one important exception: collection fields (line items, bundle components, plan items, assigned users) use replace-wholesale semantics — if you send a collection, the entire existing collection is replaced with exactly what you sent. Omit the field entirely if you do not want to change it.
{
"title": "Repipe master bath"
}
{
"line_items": [
{ "catalog_item_id": "abc123", "quantity": 1 }
]
}
Remove verb
The default remove verb is archive, which soft-deletes the record (hides it without destroying it). Six resources use a different verb because their data model requires it:
| Tool | Verb | Why |
|---|---|---|
jobs.cancel | cancel | Jobs have a status machine; the terminal state is cancelled. |
invoices.void | void | Invoices use void as their soft-delete. One-way. |
job_series.end | end | Series have no archive column; end caps the schedule and cancels future instances. One-way. |
plans.cancel | cancel | Plans have a status machine; cancel is terminal with no resume. |
tags.delete | delete | Hard delete — the tags table has no soft-delete column. Removes the tag and all its assignments permanently. |
catalog_categories.delete | delete | Hard delete — re-parents children and clears items' category_id. Permanent. |
tags.delete and catalog_categories.delete are permanent. There is no undo. All other remove verbs either soft-delete the record (it disappears from results but its history is intact) or make a one-way status transition that also keeps history intact.
Soft delete vs. hard delete
Soft delete
Most resources support soft-delete via archive. Archiving a record does not destroy data — it hides it. When you archive a record:
- It disappears from default list and get responses.
- Its data is preserved; any records that reference it stay intact.
- Some list tools accept an
include_archivedflag if you need to see archived records.
Hard delete
Tags and catalog categories do not support soft-delete. Deleting them permanently removes the row — there is no recovery:
tags.delete— removes the tag and removes all tag assignments on customers.catalog_categories.delete— removes the category, moves any child categories up to the deleted category's parent, and clears thecategory_idon any catalog items that were in the category.
Pagination
When a list tool returns many records, results are split into pages. There are two pagination styles, depending on the resource:
Used by customers.search and notes.list.
A cursor is an opaque token (a string) that marks your position in the result set — you do not need to decode it. On your first request you omit it. The response includes a nextCursor value and a hasMore flag. Pass that cursor in your next request to get the following page.
{
"query": "smith"
}
{
"data": [ ... ],
"nextCursor": "eyJpZCI6IjEyMyJ9",
"hasMore": true
}
{
"query": "smith",
"cursor": "eyJpZCI6IjEyMyJ9"
}
Used by jobs, invoices, quotes, plans, job series, catalog items, and catalog categories.
Pass a page integer starting at 1. The response includes the records for that page.
{
"page": 2
}
Error kinds
When a tool call fails, the response includes a kind field that tells you what went wrong and what to do. Here are all five kinds:
| Kind | Meaning | What to check |
|---|---|---|
invalid_input | A field failed validation, or a precondition was not met (for example, calling a user-only tool with a tenant-key token). | Check required fields, field types, and whether your token is a user actor. |
not_found | The record does not exist in this tenant, or it has been archived. | Confirm the UUID is correct and belongs to this tenant, and that the record has not been soft-deleted. |
conflict | The request collides with current state. | Common causes: duplicate unique name, illegal status transition, trying to edit a non-draft invoice, or trying to set a second primary contact without demoting the first. |
insufficient_scope | Your token does not hold a required scope. | Add the missing scope to your API key or OAuth token request. See scopes. |
internal | An unexpected server or database error occurred. | Retry with backoff. Details are never sent to the client. |
Authentication failures happen before any tool runs and use standard HTTP status codes: 401 for a missing, invalid, expired, or revoked token; 403 for a wrong tenant or insufficient scope; 429 for rate limiting (the response includes a Retry-After header telling you when to try again).
See Errors for the full error reference.
Identifiers and data types
- All IDs are UUIDs — long, globally unique strings such as
a1b2c3d4-…. They are validated at the API boundary. A malformed UUID returnsinvalid_inputimmediately. - Phone numbers on contacts are normalized to E.164 format — a standardized international format that starts with a
+and the country code (for example,+15125550199for a US number). The raw input is also preserved. - Tax rates are stored as a decimal internally (for example,
0.0825for 8.25%), but the create and update tools accept a human-readablerate_percentagevalue (for example,8.25) and do the conversion for you. - Monetary values on invoices and quotes are computed server-side from line items and the applied tax rate. You do not send a total; you send line items.