Notes
Create and manage notes attached to other records.
Notes are free-text records you can attach to almost any record in Zoop — a customer, contact, location, job, invoice, quote, or estimate — or leave standalone. They support pinning, full-text search, date filtering, and archiving (hiding without deleting).
Notes use their own permission scopes (read:notes / write:notes), separate from the scopes for the records they attach to. See scopes for background on how scopes work.
Scopes
| Scope | Grants |
|---|---|
read:notes | notes.list, notes.get |
write:notes | notes.create, notes.update, notes.archive |
write:notes does not automatically include read:notes. If your integration needs to both read and write notes, request both scopes.
Write operations require a user identity
notes.create, notes.update, and notes.archive require a user actor — meaning the API key must be tied to a real team member so Zoop knows who authored the note. Tenant-level keys (the zoop_tk_ prefix) represent the account as a whole, not a person, so they cannot create or modify notes. If you try, you'll get an invalid_input error.
If you need to write notes from an automated pipeline with no human in the loop, create a dedicated service-account team member in Zoop and issue a user-bound API key (zoop_uk_) for that account. See API keys.
Tools
Each tool below is called by sending a JSON-RPC request to the Zoop MCP endpoint (POST /api/mcp). See the MCP server page for the full request envelope and transport details.
notes.list
Returns a list of notes for your tenant, sorted newest first. Results are paged — you get up to 25 notes per call (max 100), and a cursor you can use to fetch the next page.
Scope: read:notes
When true, returns only pinned notes.
Filter by the type of record the note is attached to. Accepted values: customer, customer_contact, customer_location, job, invoice, quote, lawn_estimate, standalone. Pass standalone to return only notes with no subject attached.
Filter by a specific subject record's ID. Combine with subject_type to scope to a single record. Cannot be used when subject_type is standalone.
Full-text search query, max 256 characters. Cannot be combined with subject_type: "standalone".
Return notes created at or after this timestamp. Example: 2026-01-01T00:00:00Z.
Return notes created before this timestamp (exclusive).
Filter by author user ID.
Opaque cursor returned as nextCursor in the previous response. Pass it to fetch the next page.
Number of rows to return. Default 25, max 100.
Returns: { rows, nextCursor, hasMore }
Each row includes the note's id, body, pinned, created_by, created_at, subject_type, subject_id, and any attached file URLs. Draft notes are excluded from results.
notes.get
Returns a single note by its ID.
Scope: read:notes
The note's ID.
Returns the full note record. Returns not_found if the note does not exist or has been archived.
notes.create
Creates a note. Optionally attaches it to another record.
Scope: write:notes — requires a user actor.
The note text, max 100,000 characters. Must be non-empty unless draft is true.
The type of record to attach the note to. Accepted values: customer, customer_contact, customer_location, job, invoice, quote, lawn_estimate. Omit to create a standalone note.
The ID of the record to attach the note to. subject_type and subject_id must be provided together or both omitted.
Pin the note. Defaults to false.
Mark the note as a draft. Drafts are hidden from default list results and are cleaned up automatically after 24 hours if not committed. Defaults to false.
Returns the created note record on success. Returns not_found if the subject_id does not exist.
notes.update
Partially updates a note. Only the fields you send are changed.
Scope: write:notes — requires a user actor.
Only the note's author or a tenant owner can update a note.
The note's ID.
Replacement text, min 1 character, max 100,000 characters.
Set or clear the pin.
Change or clear the attached subject type. Send both subject_type and subject_id together. Pass both as null to detach the note from its current subject.
Change or clear the attached subject ID. Must be provided with subject_type.
Commit a draft note by setting draft: false. You cannot re-draft a committed note.
At least one field is required. Returns not_found if the note does not exist or is already archived. Returns invalid_input if the caller is not the author and does not hold the owner role.
notes.archive
Archives a note. The note is hidden from notes.list and notes.get results, but its data is preserved in Zoop's database (it is not deleted).
Scope: write:notes — requires a user actor.
Only the note's author or a tenant owner can archive a note.
The note's ID.
Returns { archived: true, id } on success. Returns not_found if the note does not exist or is already archived.
Example: list pinned notes on a job
The request below fetches the first page of pinned notes attached to a specific job. Replace zoop_uk_<your-secret> with your API key and the subject_id value with the real job ID.
curl -X POST https://app.zoop.example/api/mcp \
-H "Authorization: Bearer zoop_uk_<your-secret>" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "notes.list",
"arguments": {
"subject_type": "job",
"subject_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"pinned": true,
"limit": 25
}
}
}'
A successful response looks like:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [
{
"type": "text",
"text": "{"rows":[{"id":"dddddddd-dddd-dddd-dddd-dddddddddddd","body":"Ran a new 20A circuit from the panel. Inspector visit scheduled for Friday.","pinned":true,"created_by":"uuuuuuuu-uuuu-uuuu-uuuu-uuuuuuuuuuuu","created_at":"2026-06-12T14:30:00Z","subject_type":"job","subject_id":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","attachments":[]}],"nextCursor":null,"hasMore":false}"
}
]
}
}
Error reference
| Error kind | Meaning |
|---|---|
not_found | The note ID does not exist in this tenant, the note is already archived, or the subject record (subject_id) was not found. |
invalid_input | A required field is missing, the input failed validation, the caller is a tenant actor, or the caller is not the author/owner. |
insufficient_scope | The token does not hold read:notes or write:notes as required. |
conflict | The write rate limit for this user was exceeded. Slow down your requests and retry. |
internal | An unexpected server error — details are not returned to the client. |
See errors for the full error reference and HTTP status codes.
Pagination
notes.list uses cursor-based pagination. Instead of page numbers, each response includes a nextCursor token. When hasMore is true, pass that token as the cursor parameter on your next call to get the following page.
let cursor: string | undefined
let allNotes: unknown[] = []
do {
const response = await fetch('https://app.zoop.example/api/mcp', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.ZOOP_API_KEY}`,
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: {
name: 'notes.list',
arguments: { subject_type: 'job', subject_id: JOB_ID, limit: 100, cursor },
},
}),
})
// Parse the MCP text content to extract the tool result
const envelope = await response.json()
const text = envelope.result?.content?.[0]?.text
const page = JSON.parse(text)
allNotes = allNotes.concat(page.rows)
cursor = page.nextCursor ?? undefined
} while (cursor)
If nextCursor is null but hasMore is still true, the result set was truncated — this can happen with full-text search queries. You cannot page past this point. Narrow your query with additional filters (for example, a tighter date range or a more specific search term) to get a complete result set.
Related
- Notes in Zoop — end-user guide for working with notes in the product
- Scopes — full scope catalog
- Authentication — how to authenticate requests
- MCP server — transport, conventions, and the full tool catalog
- Errors — error kinds and HTTP status codes