Skip to content

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

curl -X GET "https://fsmnavigator.com/api/v1/jobs?id=123" \
  -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": 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:

  1. Final comment. Missing or empty returns 400 final_comment_required; too long returns 400 comment_too_long.
  2. Required forms. If a form the job needs is not submitted yet, you get 409 job_forms_incomplete. The missing list 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.
  3. Required custom fields. If a field marked required to complete the job is empty, you get 422 custom_fields_required. The missing list names each field (field_key, field_name). Fill them in with PUT and custom_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.

curl "https://fsmnavigator.com/api/v1/jobs?sub=hold-reasons" \
  -H "X-API-Key: YOUR_API_KEY"
{
  "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:

  1. The customer's own response time (default_sla_hours on the customer record), reached through the location the job is at.
  2. Your company's default response time, from Settings → Company Settings.
  3. 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.

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