Jobs API
Enterprise feature
The REST API is available on Enterprise plans. Compare plans to find the right fit for your team.
The Jobs API lets you create, retrieve, update, and manage field service jobs programmatically. Use it to sync jobs with external systems, automate workflows, or build custom dashboards.
Endpoints summary
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /api/v1/jobs | jobs:read | List jobs |
GET | /api/v1/jobs?id={id} | jobs:read | Get a single job |
POST | /api/v1/jobs | jobs:write | Create a job |
PUT | /api/v1/jobs?id={id} | jobs:write | Update a job |
PATCH | /api/v1/jobs?id={id} | jobs:write | Update job status |
Job object
Every job returned by the API contains these fields:
| Field | Type | Description |
|---|---|---|
job_id | integer | Unique job identifier |
version | integer | Optimistic-concurrency token — echo as expected_version on update (see Update a job) |
job_title | string | Short description of the work |
job_description | string | Detailed notes for the technician |
job_status | string | Current status (see Valid statuses) |
job_priority | string | Low, Medium, High, or Urgent |
due_at | string | The job's SLA deadline (ISO 8601). Every job has one — see SLA deadlines |
assigned_technician_id | integer | ID of the assigned technician, or null |
assigned_technician_name | string | Full name of the assigned technician, or null |
customer_location_id | integer | ID of the service location |
customer_location_name | string | Name of the service location |
customer_id | integer | ID of the customer |
customer_name | string | Name of the customer |
scheduled_start | string | Scheduled start time (ISO 8601), or null |
scheduled_end | string | Scheduled end time (ISO 8601), or null |
actual_start | string | Actual start time, or null |
actual_end | string | Actual end time, or null |
created_at | string | When the job was created (ISO 8601) |
updated_at | string | When the job was last updated (ISO 8601) |
assets | array | Linked assets (Enterprise only). See Assets on jobs |
hold_reason_key | string | Single-job read only. Why an On-Hold job is paused (see Putting a job on hold), or null when the job is not on hold |
hold_reason_label | string | Single-job read only. Your company's name for that reason, or null |
hold_reason_note | string | Single-job read only. The note left with the hold, or null |
List jobs
GET /api/v1/jobs
Returns a paginated list of jobs. Use query parameters to filter results.
Query parameters
| Parameter | Type | Description |
|---|---|---|
status | string | Filter by status (see Valid statuses) |
priority | string | Filter by priority: Low, Medium, High, Urgent |
assigned_technician_id | integer | Filter by assigned technician |
customer_location_id | integer | Filter by customer location |
created_after | string | Jobs created on or after this date (ISO 8601 format) |
created_before | string | Jobs created on or before this date (ISO 8601 format) |
page | integer | Page number (default: 1) |
per_page | integer | Results per page (default: 50, max: 100) |
Example request
curl -X GET "https://fsmnavigator.com/api/v1/jobs?status=Pending&priority=High" \
-H "X-API-Key: YOUR_API_KEY"
Example response
{
"data": [
{
"job_id": 123,
"job_title": "AC unit repair",
"job_description": "Customer reports AC not cooling in main office",
"job_status": "Pending",
"job_priority": "High",
"assigned_technician_id": null,
"assigned_technician_name": null,
"customer_location_id": 87,
"customer_location_name": "Main Office",
"customer_id": 201,
"customer_name": "Acme Corporation",
"scheduled_start": "2026-02-25T04:00:00-05:00",
"scheduled_end": "2026-02-25T06:00:00-05:00",
"actual_start": null,
"actual_end": null,
"created_at": "2026-02-24T09:30:00-05:00",
"updated_at": "2026-02-24T09:30:00-05:00"
}
],
"pagination": {
"page": 1,
"per_page": 50,
"total": 1,
"total_pages": 1
},
"request_id": "a1b2c3d4e5f6"
}
Get a single job
GET /api/v1/jobs?id={id}
Retrieves full details for one job.
Example request
Example response
{
"data": {
"job_id": 123,
"job_title": "AC unit repair",
"job_description": "Customer reports AC not cooling in main office",
"job_status": "Pending",
"job_priority": "High",
"assigned_technician_id": 45,
"assigned_technician_name": "John Smith",
"customer_location_id": 87,
"customer_location_name": "Main Office",
"customer_id": 201,
"customer_name": "Acme Corporation",
"scheduled_start": "2026-02-25T04:00:00-05:00",
"scheduled_end": "2026-02-25T06:00:00-05:00",
"actual_start": null,
"actual_end": null,
"created_at": "2026-02-24T09:30:00-05:00",
"updated_at": "2026-02-24T09:30:00-05:00"
},
"request_id": "a1b2c3d4e5f6"
}
Create a job
POST /api/v1/jobs
Creates a new job and returns the created record. Returns 201 Created on success.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
job_title | string | Yes | Short description of the work |
job_description | string | Yes | Detailed notes for the technician — minimum 30 characters. A shorter or missing value returns 400 validation_failed. |
job_priority | string | No | Low, Medium (default), High, or Urgent |
due_at | string | No | The SLA deadline (ISO 8601). Leave it out — or send null — and we set one for you. That is not true on update; see Explicit nulls |
customer_location_id | integer | Yes | ID of the customer location |
assigned_technician_id | integer | No | Assign to a specific technician |
scheduled_start | string | No | Scheduled start time (ISO 8601) |
scheduled_end | string | No | Scheduled end time (ISO 8601) |
asset_ids | array of integers | No | IDs of assets to link (Enterprise only) |
asset_relationship_type | string | No | Relationship type: serviced (Serviced, the default), installed (Installed), replaced (Replaced), inspected (Inspected), maintenance (Maintenance), or meter_threshold (Meter Threshold) |
custom_fields | object | No | Values for your job custom fields, keyed by each field's key, for example {"gate_code": "4417"}. A field marked required when a job is created must be included. See Custom fields |
Example request
curl -X POST "https://fsmnavigator.com/api/v1/jobs" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"job_title": "AC unit repair",
"job_description": "Customer reports AC not cooling in main office",
"job_priority": "High",
"customer_location_id": 87,
"assigned_technician_id": 45,
"scheduled_start": "2026-02-25T04:00:00-05:00",
"scheduled_end": "2026-02-25T06:00:00-05:00",
"asset_ids": [42, 67]
}'
Example response
{
"success": true,
"message": "Job created successfully",
"data": {
"job_id": 123,
"job_title": "AC unit repair",
"job_status": "Pending",
"created_at": "2026-02-24T09:30:00-05:00"
},
"request_id": "a1b2c3d4e5f6"
}
Update a job
PUT /api/v1/jobs?id={id}
Updates one or more fields on an existing job.
Example request
curl -X PUT "https://fsmnavigator.com/api/v1/jobs?id=123" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"job_priority": "Urgent",
"job_description": "Customer reports no cooling — escalated to urgent",
"asset_ids": [42, 67, 103]
}'
Syncing asset links
Passing asset_ids on a PUT replaces all current asset links. Pass "asset_ids": [] to remove every link.
You can change the deadline, but you cannot remove it
Every job must have an SLA deadline, so due_at cannot be set to null or "" on an update. Doing so returns 400 Bad Request with sla_required and nothing is written — the job keeps the deadline it had.
To move a deadline, send the new one: {"due_at": "2026-07-26T14:00:00-05:00"}. To leave it exactly as it is, leave due_at out of the request body entirely — omitting a field never changes it.
This changed on 13 September 2026. A PUT clearing due_at previously returned 200 OK and quietly replaced the deadline with a freshly calculated one, so the job ended up with a date nobody asked for. If your integration sends "due_at": null to mean "leave it alone", stop sending the field instead.
Creating a job with "due_at": null still succeeds — see Explicit nulls for why the two endpoints differ.
job_description now has to clear the same 30-character minimum on update
If you send job_description on a PUT, it must be at least 30 characters — the same minimum Create a job has always enforced. A shorter value, an empty string or an explicit null returns 400 Bad Request with validation_failed and nothing is written — the job keeps the description it had.
Leaving job_description out of the request body entirely still changes nothing, exactly as before. Only a value you actually send is checked.
This changed on 20 September 2026. A PUT carrying a short description previously returned 200 OK and stored it, so a job created with a valid description could be edited down to a few characters — or emptied — and the documented minimum only ever applied to the first write. If your integration sends a short job_description on update, lengthen it or stop sending the field.
PUT /api/v1/jobs?id={id} · minimum 30 characters · error key validation_failed · HTTP 400.
Custom fields on update
Send custom_fields with only the keys you want to change; a key you leave out keeps its value. Sending an empty value clears the field, except a field that is required when a job is created, which cannot be cleared. See Custom fields.
Removing the technician
Sending "assigned_technician_id": null takes the technician off the job and moves it back to Pending, whatever status it was in — the same as unassigning on the web. A job that was On-Hold also gets its SLA clock running again.
Optimistic concurrency (optional)
Every job read returns a version integer. To guard against lost updates when multiple integrations edit the same job, echo that value back as expected_version in the PUT body. If another request changed the job since you read it, the update is rejected with 409 conflict and no field is written — re-read the job and retry with the current version. Omitting expected_version skips the check (last-write-wins remains the default).
Example response
{
"success": true,
"message": "Job updated successfully",
"data": {
"job_id": 123,
"job_title": "AC unit repair",
"job_status": "Pending"
},
"request_id": "a1b2c3d4e5f6"
}
Update job status
PATCH /api/v1/jobs?id={id}
Changes only the status of a job. The API enforces valid status transitions, and a status change made through the API has the same effects as one made in the app: the customer gets the same emails (cancellation, rescheduling, completion, and the on-the-way and arrival emails where your plan includes them), your team gets the same alerts, completion records the job's measured duration and SLA result, and the change is written to the audit log. The API key acts as your company, so these records name no individual user.
Example request
curl -X PATCH "https://fsmnavigator.com/api/v1/jobs?id=123" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"job_status": "Assigned"}'
Completing a job
Setting job_status to Completed needs a final_comment: a short note on the work done, which appears on the invoice. It can be up to 500 characters (characters such as & and < count as more than one). The job is only completed when all three checks pass, in this order:
- Final comment. Missing or empty returns
400 final_comment_required; too long returns400 comment_too_long. - Required forms. If a form the job needs is not submitted yet, you get
409 job_forms_incomplete. Themissinglist names each form (response_id,form_name) and the keys of its unanswered items (missing_items). Submit the forms in the app, then retry. The API cannot skip this check. - Required custom fields. If a field marked required to complete the job is empty, you get
422 custom_fields_required. Themissinglist names each field (field_key,field_name). Fill them in withPUTandcustom_fields, then retry.
A refused completion changes nothing: the job keeps its status.
curl -X PATCH "https://fsmnavigator.com/api/v1/jobs?id=123" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"job_status": "Completed", "final_comment": "Replaced the capacitor and tested the unit."}'
{
"success": false,
"error": "custom_fields_required",
"message": "Fill in the required fields before completing this job.",
"missing": [
{"field_key": "gate_code", "field_name": "Gate code"}
]
}
Putting a job on hold
Setting job_status to On-Hold needs a reason, the same one your team picks in the app. Send hold_reason_key and, if you like, a hold_reason_note of up to 500 characters.
The built-in reasons are:
| Key | Label |
|---|---|
waiting_on_parts | Waiting on parts |
customer_not_available | Customer not available |
waiting_on_approval | Waiting on approval |
weather | Weather |
access_issue | Access issue |
customer_requested_changes | Customer requested changes |
other | Other (always needs a note) |
Your company can rename or hide reasons and add its own in Company Settings; a hidden reason is refused.
Listing your company's hold reasons
GET /api/v1/jobs?sub=hold-reasons returns the reasons your company offers right now, in the order your team sees them in the app: the built-in reasons (under your company's names), your own reasons, then other. Hidden reasons are left out. It needs the jobs:read scope.
{
"data": [
{ "hold_reason_key": "waiting_on_parts", "hold_reason_label": "Waiting on parts", "requires_note": false },
{ "hold_reason_key": "custom_3f9a1c2b7d", "hold_reason_label": "Permit pending", "requires_note": false },
{ "hold_reason_key": "other", "hold_reason_label": "Other", "requires_note": true }
],
"request_id": "8f2c41d09a7e4b6c9d3e5f1a2b7c6d40e9f1"
}
| Field | Type | Description |
|---|---|---|
hold_reason_key | string | The value to send as hold_reason_key when you set a job to On-Hold |
hold_reason_label | string | Your company's name for the reason |
requires_note | boolean | true when the reason needs a hold_reason_note (only other) |
Any other sub value, or sub together with id, returns 400 invalid_sub.
| Problem | Response |
|---|---|
No hold_reason_key | 422 hold_reason_required |
| A key that is not on your company's list, or is hidden | 422 invalid_hold_reason |
other without a note | 422 hold_note_required |
| A note over 500 characters | 422 hold_note_too_long |
A refused hold changes nothing. Once the job is on hold, GET /api/v1/jobs?id={id} returns the reason in hold_reason_key, hold_reason_label and hold_reason_note. Sending On-Hold for a job that is already on hold changes nothing, including its reason.
curl -X PATCH "https://fsmnavigator.com/api/v1/jobs?id=123" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"job_status": "On-Hold", "hold_reason_key": "waiting_on_parts", "hold_reason_note": "Compressor on back order"}'
Example response
{
"success": true,
"message": "Job status updated to Assigned",
"data": {
"job_id": 123,
"job_status": "Assigned",
"previous_status": "Pending"
},
"request_id": "a1b2c3d4e5f6"
}
Custom fields
Job custom fields are the extra fields your company adds to jobs, such as a gate code or a PO number. Every job you read returns them in custom_fields. To write them, send a custom_fields object on create or update, keyed by each field's key.
If any value is refused (a key that is not one of your fields, a value of the wrong type, or a required field left out when creating), the whole request is refused with 400 custom_field_invalid and nothing is written. The errors object names each field key and what is wrong with it:
{
"success": false,
"error": "custom_field_invalid",
"message": "Some custom fields need attention.",
"errors": {
"po_number": "PO number is required."
}
}
SLA deadlines
Every job carries an SLA deadline in due_at — the date and time the work is measured against. It is what puts a job in the right place on the dispatch board, what drives the reminders your team gets as the date approaches, and what decides whether the job counts as on time when it is finished. A job with no deadline would sit outside all three, so the API does not let one exist.
On create, due_at is optional. Send one and it is stored exactly as you sent it. Leave it out and we work one out for you, in this order:
- The customer's own response time (
default_sla_hourson the customer record), reached through the location the job is at. - Your company's default response time, from Settings → Company Settings.
- 24 hours.
Those hours count on every day your company is open, all day, and skip the days it is closed (the days marked closed in your business hours — Saturday and Sunday if you have never changed them — and any closed dates on your booking page). If every day is marked closed, every day counts. A due_at you send is stored as sent.
On update, you can move the deadline but not remove it. See the note under Update a job.
Explicit nulls
Create and update answer an explicit "due_at": null differently, and the difference is deliberate — they are being asked different questions.
| Request | Result |
|---|---|
POST with "due_at": null | 201 Created, and the deadline is worked out from the list above |
POST with "due_at": "" | 201 Created, same as above |
POST with due_at left out | 201 Created, same as above |
PUT with "due_at": null | 400 Bad Request, sla_required, nothing is written |
PUT with "due_at": "" | 400 Bad Request, sla_required, nothing is written |
PUT with due_at left out | 200 OK, the existing deadline is untouched |
On create there is no deadline yet, so an empty value can only mean pick one for me — there is nothing there to remove. On update there already is one, so the same empty value reads as take it away, and a job with no deadline is the one thing the API will not leave behind.
This catches out integrations that build a request body by filling every field they know about and setting the rest to null. That style creates jobs without complaint and then starts returning 400 the first time it updates one. The fix is to leave due_at out of the update body rather than sending it empty — an omitted field is never changed.
A due_at that is present but is not a valid ISO 8601 datetime is a different error on both endpoints: 400 Bad Request with invalid_date_format.
Valid statuses
Jobs can have one of nine statuses:
| Status | Description |
|---|---|
Pending | Job created, awaiting assignment |
Assigned | Technician assigned, not yet travelling |
In-Transit | Technician on the way to the site |
Arrived | Technician on site, work not yet started |
In-Progress | Technician actively working on the job |
On-Hold | Job temporarily paused |
Completed | Work finished by the technician |
Declined | Job turned down and handed back for reassignment |
Cancelled | Job cancelled before completion |
Send the exact spelling
Status values are matched exactly, hyphens included — In-Progress, In-Transit and On-Hold, not In Progress, In Transit or On Hold. Anything outside the nine values above returns 409 Conflict with invalid_status.
Status transitions
| From | Allowed transitions |
|---|---|
| Pending | Assigned, In-Transit, Declined, Cancelled, On-Hold |
| Assigned | Pending, In-Transit, Declined, Cancelled, On-Hold |
| In-Transit | Arrived, Pending, Declined, Cancelled, On-Hold |
| Arrived | In-Progress, In-Transit, Declined, Cancelled, On-Hold |
| In-Progress | Completed, Arrived, Declined, Cancelled, On-Hold |
| On-Hold | Pending, Assigned, In-Transit, Arrived, In-Progress, Cancelled |
| Completed | Pending, Assigned (reopen only — see below) |
| Declined | Pending, Assigned (reopen only — see below) |
| Cancelled | Pending (reopen only — see below) |
Reopening finished work
Completed, Declined and Cancelled are end states in the ordinary workflow, but an API key carries owner-level permissions, so it may reopen such a job: a completed or declined job back to Pending or Assigned, a cancelled job back to Pending. Nothing else moves out of those states — to cancel finished work, reopen it first and then cancel, so both steps are recorded separately. Reopening does not email the customer. A reopened cancelled job gets a new due_at, counted from the moment it is reopened with the job's usual SLA hours, so it does not come back already overdue.
Invalid transitions return an error
If you attempt a transition the workflow does not allow (e.g., Pending → Completed), the API returns 409 Conflict with invalid_transition and a message listing the statuses you can move to from here. Sending a status the job does not use at all returns 409 Conflict with invalid_status.
Setting a job to Assigned while no technician is on it returns 400 Bad Request with assignee_required — assign someone first, then set the status.
Moving a job to Pending while its technician already holds another pending job returns 409 Conflict with already_has_pending_job — a technician holds one pending job at a time. Start or move the other job first.
Editing a job that is already Completed, Declined or Cancelled with PUT returns 409 Conflict with invalid_state. Move it back to Pending or Assigned first.
Assets on jobs
Enterprise feature
Asset linking requires the Asset Management module on Enterprise plans.
When creating or updating a job, you can link it to one or more assets. Linked assets appear in the detail response.
Link assets on creation
Include asset_ids in the POST body:
curl -X POST "https://fsmnavigator.com/api/v1/jobs" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"job_title": "Replace compressor on Unit #7",
"customer_location_id": 87,
"asset_ids": [42, 67],
"asset_relationship_type": "serviced"
}'
Relationship types
| Type | Description |
|---|---|
serviced | General service or repair (default) |
installed | New asset installation |
replaced | Asset being replaced |
inspected | Routine inspection |
maintenance | Scheduled maintenance |
meter_threshold | Triggered by meter reading threshold |
Assets in job response
When retrieving a single job, linked assets appear in the assets array:
{
"data": {
"job_id": 123,
"job_title": "Replace compressor on Unit #7",
"assets": [
{
"asset_id": 42,
"asset_tag": "HVAC-007",
"asset_name": "Rooftop AC Unit #7",
"asset_status": "In Maintenance",
"relationship_type": "serviced",
"relationship_notes": null,
"linked_at": "2026-02-25T09:30:00-05:00"
}
]
}
}
Update linked assets
Use PUT to sync the asset list. This replaces all current links:
curl -X PUT "https://fsmnavigator.com/api/v1/jobs?id=123" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"asset_ids": [42, 67, 103]}'
Pass an empty array to remove all links:
curl -X PUT "https://fsmnavigator.com/api/v1/jobs?id=123" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"asset_ids": []}'
A PUT that changes the linked assets counts as a job update, even when no other field changes. The job's version goes up by one, and your job.updated webhook subscribers are notified. If the list you send matches the job's current links, nothing is written and version stays the same. You can include expected_version here too: if the job changed since you read it, you get 409 conflict and nothing is written.
Asset validation
Each asset ID is verified against your company's asset inventory. Invalid or deleted asset IDs are silently skipped. If one of the assets is deleted, or linked to the job by someone else, while your update is being saved, the request returns 409 conflict and nothing is written. Read the job again and retry.
Error responses
See Error codes for the full reference. Common job-specific errors:
| HTTP code | Error | Description |
|---|---|---|
400 | validation_failed | Missing required fields or invalid values |
400 | assignee_required | You set the job to Assigned without a technician on it |
400 | sla_required | You tried to clear due_at on an update. Send a new deadline, or leave the field out |
400 | custom_field_invalid | A custom_fields value was refused; errors names each field key. See Custom fields |
400 | final_comment_required | You set the job to Completed without a final_comment |
400 | comment_too_long | The final_comment is over 500 characters |
404 | not_found | Job ID does not exist |
409 | invalid_state | The job is already completed, declined or cancelled and cannot be edited |
409 | invalid_status | The status you sent is not one jobs use |
409 | invalid_transition | The status change is not allowed from the job's current status |
409 | conflict | Someone else changed the job's status while you were working on it |
409 | job_forms_incomplete | The job has required forms that are not submitted yet; missing lists them |
422 | custom_fields_required | Required custom fields are empty, so the job cannot be completed; missing lists them |
400 | invalid_sub | The sub query parameter is not hold-reasons, or it was sent with id |
422 | hold_reason_required | You set the job to On-Hold without a hold_reason_key |
422 | invalid_hold_reason | The hold_reason_key is not one of your company's hold reasons |
422 | hold_note_required | The reason is other and there is no hold_reason_note |
422 | hold_note_too_long | The hold_reason_note is over 500 characters |
Related guides
- Endpoints overview — all available endpoints
- Authentication — API key setup
- Customers API — manage customer records
- Rate limits — request throttling
- Jobs overview — managing jobs from the web dashboard
- Assets API — manage asset records