ConceptsCore concepts

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 userId identifying the person.
  • A role within the tenant: owner, office, or tech.
  • 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.

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.

ResourceWhat it representsScope pair
CustomersPeople and companies you do work forread:customers / write:customers
ContactsPhone/email contacts on a customer(sub-resource of customers)
LocationsService addresses on a customer(sub-resource of customers)
TagsLabels in the tenant's tag library(sub-resource of customers)
NotesFree-text notes attached to any subjectread:notes / write:notes
JobsIndividual work ordersread:jobs / write:jobs
Job seriesRecurring job templatesread:job_series / write:job_series
InvoicesBills sent to customersread:invoices / write:invoices
QuotesEstimates sent before work startsread:quotes / write:quotes
Catalog itemsPricebook entries (services, products, bundles)read:catalog_items / write:catalog_items
Catalog categoriesFolders for catalog itemsread:catalog_categories / write:catalog_categories
PlansRecurring billing plan templatesread:plans / write:plans
Tax ratesTax rates applied to invoices and quotesread: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:

VerbWhat it does
list or searchReturns multiple records with pagination.
getFetches one full record by ID.
createCreates a new record.
updatePartially updates an existing record — only the fields you send are changed.
remove verbRemoves 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 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:

ToolVerbWhy
jobs.cancelcancelJobs have a status machine; the terminal state is cancelled.
invoices.voidvoidInvoices use void as their soft-delete. One-way.
job_series.endendSeries have no archive column; end caps the schedule and cancels future instances. One-way.
plans.cancelcancelPlans have a status machine; cancel is terminal with no resume.
tags.deletedeleteHard delete — the tags table has no soft-delete column. Removes the tag and all its assignments permanently.
catalog_categories.deletedeleteHard 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_archived flag 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 the category_id on 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"
}

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:

KindMeaningWhat to check
invalid_inputA 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_foundThe 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.
conflictThe 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_scopeYour token does not hold a required scope.Add the missing scope to your API key or OAuth token request. See scopes.
internalAn 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 returns invalid_input immediately.
  • Phone numbers on contacts are normalized to E.164 format — a standardized international format that starts with a + and the country code (for example, +15125550199 for a US number). The raw input is also preserved.
  • Tax rates are stored as a decimal internally (for example, 0.0825 for 8.25%), but the create and update tools accept a human-readable rate_percentage value (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.

What comes next

  • To authenticate and get a token, see API keys or OAuth.
  • For the full list of scopes, see Scopes.
  • To browse the tools for a specific resource, see the API reference under Customers, Jobs, Invoices, and the other resource pages.
  • To use these tools from an AI agent, see MCP.