Job series
Define recurring jobs that generate instances on a schedule.
Use this page to create and manage recurring job schedules through the Zoop API. It covers all four job_series tools, a complete working example, and a reference for errors.
A job series is a recurring-job template. You define the schedule once and Zoop generates the individual job instances automatically. Think of the series as the blueprint and the generated jobs as the copies.
- Series template — the schedule, title, line items, and team assignments. Managed with
job_series.*tools. - Job instances — the individual jobs Zoop generates from the template. Managed with
jobs.*tools.
When you update a series, Zoop regenerates all future instances from the updated template. When you end a series, it stops generating new instances and cancels the pending ones.
For the end-user view of how recurring jobs work in the product, see recurring jobs.
Scopes
Scopes control what your API token is allowed to do. You request them when you create an API key in Zoop under Settings > API keys.
| Scope | Grants |
|---|---|
read:job_series | job_series.list, job_series.get |
write:job_series | job_series.create, job_series.update, job_series.end |
write:job_series does not include read:job_series. Grant both if your integration needs to read and write.
Who can create and update a series
job_series.create and job_series.update require a user-bound token — an API key tied to a real Zoop team member (zoop_uk_ prefix). These tokens carry an identity, which is required to author a series.
Tenant-key tokens (zoop_tk_ prefix) have no user identity and will receive an invalid_input error on those two calls.
job_series.end has no such restriction — either token type can end a series.
If you need to write series from an automated pipeline (no human in the loop), create a dedicated service-account team member in Zoop and issue a zoop_uk_ key for that account. See API keys.
Tools
job_series.list
Lists recurring job series for the tenant, most recent first. Returns page-based results (default page size: 20).
Scope: read:job_series
Filter by customer. Returns only series linked to this customer.
1-based page number. Defaults to 1.
Returns: { data, count, page, limit }
Each row in data is the series template record. To fetch the generated job instances, use jobs.list with the seriesId filter.
job_series.get
Fetches a single series by ID, including its template line items and assignments.
Scope: read:job_series
The series ID.
Returns the full series record with job_series_line_items and job_series_assignments embedded. Returns not_found if the series does not exist within your tenant.
job_series.create
Creates a recurring job series. After saving, Zoop immediately tries to generate the first batch of job instances so they are ready to use.
Scope: write:job_series — requires a user-bound token (zoop_uk_).
Series title, max 200 characters.
The recurrence rule string (RFC 5545 iCalendar format) that defines how often jobs repeat. FREQ must be one of DAILY, WEEKLY, MONTHLY, or YEARLY. Sub-daily frequencies (BYHOUR, BYMINUTE, BYSECOND) are not allowed. If you include COUNT, it must be between 1 and 366. If you include UNTIL, it must be within 5 years of today.
Example: FREQ=WEEKLY;BYDAY=MO — generates a job every Monday.
You can use a tool like rrule.js playground to build and test rules before passing them here.
IANA timezone name for the schedule — for example, America/Los_Angeles or America/New_York. This controls what "Monday" means: a job scheduled on Monday Pacific time fires at midnight Pacific, not UTC. Invalid timezone strings are rejected.
See the IANA tz database for valid names.
The date the series starts, in YYYY-MM-DD format (for example, 2026-07-07). This anchors the recurrence rule — it is the first possible date a job instance can be generated on. Cannot be changed after the series is created.
Link the series to a customer. Must belong to your tenant.
Free-text description, max 5000 characters.
A fixed price for each generated job, overriding any line-item total. Non-negative.
The date the series ends. No instances are generated after this date. Omit for an open-ended series.
Cap the total number of instances the series generates. Must be between 1 and 366. Omit for no cap.
Template line items copied onto each generated job. Defaults to an empty array.
Line item description, max 500 characters.
Unit price. Non-negative.
Quantity. Must be positive.
Link the line item to a catalog item. Must belong to your tenant.
Display order. Defaults to the line item's position in the array.
Team member IDs to assign to each generated job. Every ID must be a member of your tenant. Defaults to an empty array.
Returns the created series record. Zoop generates the first window of job instances synchronously — they are usually available via jobs.list immediately after a successful call. If that first generation step fails (for example, a transient database error), the series is still created and a background job will generate the instances shortly after.
job_series.update
Updates a recurring job series. This is an edit-all operation: changes apply to the whole series template, not just future occurrences.
Scope: write:job_series — requires a user-bound token (zoop_uk_).
After a successful update, Zoop cancels all future unscheduled and scheduled job instances and regenerates them from the updated template. Jobs with status done or cancelled are left intact — they are the historical record and cannot be regenerated.
dtstart cannot be changed after a series is created. Passing it to update returns an invalid_input error. To use a different start date, end the existing series and create a new one.
The series ID.
Updated title, max 200 characters.
New recurrence rule. Same constraints as job_series.create.
New IANA timezone name.
Change or clear the customer link.
Updated description, max 5000 characters.
Updated fixed price, or null to clear it.
New end date, or null to remove it.
New occurrence cap, or null to remove it.
If provided, replaces the template line items wholesale — the existing list is deleted and the new list is inserted. Omit to leave line items unchanged.
If provided, replaces the assignment list wholesale. Omit to leave assignments unchanged.
Returns the updated series record on success. Returns not_found if the series does not exist within your tenant.
After a successful update, Zoop wipes future unscheduled and scheduled instances synchronously, then attempts to regenerate the window. If regeneration fails (transient error), the series template is still updated and a background cron will regenerate the instances.
job_series.end
Permanently stops a recurring job series. There is no archive or soft-delete — once ended, a series cannot be resumed.
Scope: write:job_series
Ending a series is one-way. If you only want to pause generation temporarily, use job_series.update to set ends_on to a future date instead — you can move it again later.
Ending a series does two things in sequence:
- Sets
ends_onto today, so no further instances are generated. - Cancels every future
unscheduledandscheduledinstance that has ascheduled_startfrom now onward.
Jobs with status done or cancelled are untouched — completed work is preserved.
The series ID.
Returns { ended: true, id } on success. Returns not_found if the series does not exist within your tenant.
Example: create a weekly recurring job
The example below creates a series that generates a job every Monday starting 2026-07-07, in the Pacific timezone, assigned to one team member. Swap in your real API key, customer ID, and team member ID before running it.
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": "job_series.create",
"arguments": {
"title": "Weekly lawn maintenance",
"rrule": "FREQ=WEEKLY;BYDAY=MO",
"timezone": "America/Los_Angeles",
"dtstart": "2026-07-07",
"customer_id": "aaaaaaaa-0000-0000-0000-000000000001",
"line_items": [
{
"description": "Mow and edge",
"unit_price": 80.00,
"quantity": 1
}
],
"assigned_user_ids": ["bbbbbbbb-0000-0000-0000-000000000002"]
}
}
}'
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: 'job_series.create',
arguments: {
title: 'Weekly lawn maintenance',
rrule: 'FREQ=WEEKLY;BYDAY=MO',
timezone: 'America/Los_Angeles',
dtstart: '2026-07-07',
customer_id: 'aaaaaaaa-0000-0000-0000-000000000001',
line_items: [
{ description: 'Mow and edge', unit_price: 80.00, quantity: 1 },
],
assigned_user_ids: ['bbbbbbbb-0000-0000-0000-000000000002'],
},
},
}),
})
const envelope = await response.json()
const text = envelope.result?.content?.[0]?.text
const series = JSON.parse(text)
console.log('Created series:', series.id)
A successful response looks like:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [
{
"type": "text",
"text": "{"id":"cccccccc-0000-0000-0000-000000000003","tenant_id":"...","title":"Weekly lawn maintenance","rrule":"FREQ=WEEKLY;BYDAY=MO","timezone":"America/Los_Angeles","dtstart":"2026-07-07","ends_on":null,"max_occurrences":null,"flat_price":null,"customer_id":"aaaaaaaa-0000-0000-0000-000000000001","created_at":"2026-06-13T00:00:00Z"}"
}
]
}
}
To list the generated job instances after creating the series:
const instancesResponse = 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: 2,
method: 'tools/call',
params: {
name: 'jobs.list',
arguments: {
seriesId: 'cccccccc-0000-0000-0000-000000000003',
},
},
}),
})
Key concepts
These are the common points of confusion when first working with this API.
Series vs. instances
The job_series tools manage the template — the schedule definition, title, line items, and assignments. The generated jobs are regular jobs rows accessed through the jobs tools. Use jobs.list with the seriesId filter to retrieve all instances for a series.
dtstart is immutable
Once a series is created, dtstart cannot be changed. If you need a different start date, end the existing series and create a new one.
Update semantics — edit-all, not "this and future"
job_series.update applies every change to the whole series template, then regenerates all future instances. This differs from the Zoop product UI, which lets users choose "this and future." The API only supports edit-all.
When you update line_items or assigned_user_ids, the new value replaces the entire current list. Omit those fields to leave them unchanged.
Ending a series vs. pausing it
job_series.end is one-way. It caps ends_on at today and cancels all pending instances.
If you only want to pause generation temporarily, use job_series.update to set ends_on to a future date instead — jobs still generate up to that date, and you can move the date again later.
Error reference
| Error kind | Meaning |
|---|---|
not_found | The series ID does not exist in this tenant. |
invalid_input | A required field is missing; the rrule is malformed; the timezone is not a recognized IANA name; dtstart was passed to update; the token is a tenant key (zoop_tk_) on create or update; or a referenced customer_id, catalog_item_id, or user ID does not belong to this tenant. |
insufficient_scope | The token does not have read:job_series or write:job_series as required. |
conflict | A conflicting series already exists (unique constraint). |
internal | An unexpected server error — details are not returned to the client. |
See errors for the full error reference and HTTP status codes.
Paginating through all series
job_series.list returns up to 20 series per call. To fetch more, pass page (starting at 1) to step through results. The response tells you how many total series exist (count), which page you are on (page), and the page size (limit, always 20).
let page = 1
let allSeries: unknown[] = []
while (true) {
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: page,
method: 'tools/call',
params: {
name: 'job_series.list',
arguments: { page },
},
}),
})
const envelope = await response.json()
const text = envelope.result?.content?.[0]?.text
const result = JSON.parse(text)
allSeries = allSeries.concat(result.data)
const totalPages = Math.ceil(result.count / result.limit)
if (page >= totalPages) break
page++
}
Related
- Jobs — fetch and manage the job instances generated by a series
- Recurring jobs — end-user guide for recurring jobs in the product
- Scopes — full scope catalog
- API keys — how to get and use API keys
- MCP server — transport, conventions, and the full tool catalog
- Errors — error kinds and HTTP status codes