# Service jobs

A service job (a ticket in the API) is a piece of field work for a client: an installation, a repair, a router swap.
This guide follows one job from creation to completion.

<JobLifecycle />

## Before you begin

- The organization needs the **Ticketing System** add-on.
- The key needs these [abilities](/concepts/authentication/#abilities):

| Step | Ability |
| --- | --- |
| Read boards, job types, technicians and jobs | `tickets:read` |
| Create a job | `tickets:create` |
| Schedule or unschedule a visit | `tickets:schedule` |
| Start, update, move or cancel a job | `tickets:update` |
| Complete a job | `tickets:complete` |
| Complete a job that bills, or bill it later | `invoices:create` as well |

All times in responses are UTC with a `Z` suffix. A time you send without an offset is read in the organization's
timezone, so prefer sending an offset or `Z`.

## 1. Pick a board and a job type

Every job lives on a board. Each board has columns, and each column has a category: `new`, `scheduled`,
`in_progress`, `waiting`, `done` or `cancelled`. The API moves jobs between categories for you as they progress.

<span class="ep"><b>GET</b> /v1/ticket-boards</span>

A **job type** is a template. It sets the default priority, the SLA, a checklist and the billing mode. Job types can
belong to one board or to every board.

<span class="ep"><b>GET</b> /v1/ticket-job-types?board_id=&#123;board_id&#125;</span>

```json
{
  "data": [
    {
      "id": "0c4e6a8b-2d1f-4e3a-9b5c-7d9e1f3a5b7c",
      "name": "Installation",
      "code": "installation",
      "board_id": null,
      "default_priority": "high",
      "sla_hours": 48,
      "duration_minutes": 120,
      "billing_mode": "on_completion",
      "requires_client": true,
      "auto_assign": false,
      "active": true
    }
  ]
}
```

## 2. Create the job

<span class="ep"><b>POST</b> /v1/tickets</span>

```bash
curl -X POST https://api.invoicing.proradius.co/v1/tickets \
  -H "Authorization: Bearer $PRORADIUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Fiber installation",
    "description": "Ground floor, entrance from the side street",
    "board_id": "4c6e8a0b-1d3f-4a5c-9e7b-2f4a6c8e0b1d",
    "job_type_id": "0c4e6a8b-2d1f-4e3a-9b5c-7d9e1f3a5b7c",
    "client_external_ref": "crm-1001",
    "sla_hours": 24,
    "external_ref": "crm-job-48213"
  }'
```

| Field | Notes |
| --- | --- |
| `title`, `board_id` | Required. |
| `job_type_id` | Optional. With a job type, `priority` and `type` become optional; the job type fills the priority, SLA and checklist. A job type that requires a client answers `422` without one. |
| `priority`, `type` | Required without a job type. `priority`: `low`, `medium`, `high`, `critical`. `type`: `task`, `bug`, `support`, `feature`. |
| `client_id` or a lookup field | See [Identify the client](#identify-the-client). |
| `sla_hours` | See [SLA hours](#sla-hours). |
| `external_ref` | Your id for this job, unique per organization. Makes retries safe. |
| `due_date` | Optional date. |
| `technician_id`, `scheduled_at`, `scheduled_until` | Optional. Books the visit at creation; needs `tickets:schedule`. |

A new job answers `201` with the full job. The server sets `created_via` to `api` and `api_key_id` to your key.

### Safe retries with `external_ref`

Sending the same `external_ref` again returns the existing job with `200` and changes nothing. If the reference
belongs to a deleted job, or to a job on a board the key cannot open, the answer is `409`. Find a job by your
reference later with `GET /v1/tickets?external_ref=crm-job-48213`.

### Identify the client

Send **one** of these fields:

| Field | Matches |
| --- | --- |
| `client_id` | The client's PROradius id (uuid). |
| `client_external_ref` | The client's own `external_ref`, set through the [Clients API](/guides/clients/). |
| `client_phone` | The client's phone number. Spaces, dashes and the `+961`, `00961` or leading `0` prefixes are normalised on both sides. |
| `client_isp_username` | The username of the client's ISP account. |

The lookup is limited to your organization. If nothing matches, the create answers `422` with reason
`client_not_found`. If several clients match, it answers `422` with reason `client_ambiguous` and up to ten
candidate ids; retry with `client_id`. Sending two lookup fields answers `client_lookup_conflict`. See
[client lookup errors](/concepts/errors/#client-lookup-errors).

The same fields filter `GET /v1/tickets`. There, no match returns an empty list instead of an error.

### SLA hours

The SLA is the number of hours, counted from creation, within which the job should be done. The job's
`sla_due_at` is the deadline.

- Not sent: the job type's `sla_hours` applies (no SLA without a job type).
- An integer from 1 to 8760: this job's own SLA.
- `null`: no SLA for this job.

Time spent in a `waiting` column pauses the SLA and moves `sla_due_at` later by the paused time. Change the SLA of an
open job with `PUT /v1/tickets/{id}` and `{"sla_hours": 12}`; send `null` to remove it. A done or cancelled job
answers `422` with "Reopen the job to change its SLA.". Each job returns its current `sla_hours`, `sla_due_at` and
`sla_breached_at`.

## 3. Schedule a visit

Find who can take the job, then book a window.

<span class="ep"><b>GET</b> /v1/ticket-technicians?board_id=&#123;board_id&#125;</span>

Optionally ask for ranked suggestions for a window:

<span class="ep"><b>GET</b> /v1/tickets/&#123;id&#125;/technician-suggestions?scheduled_at=...&scheduled_until=...</span>

Then book:

<span class="ep"><b>POST</b> /v1/tickets/&#123;id&#125;/schedule</span>

```json
{
  "technician_id": "5d1e2f3a-4b5c-4d6e-8f70-1a2b3c4d5e6f",
  "scheduled_at": "2026-10-20T09:00:00+03:00",
  "scheduled_until": "2026-10-20T11:00:00+03:00"
}
```

The API answers `422` and books nothing when:

- the window is longer than 24 hours, or starts more than 5 minutes in the past,
- the technician already has an open job overlapping the window (touching windows such as 10:00 to 12:00 and
  12:00 to 13:00 are fine),
- the window falls outside the technician's working hours, on their time off, or they lack a skill the job type
  requires,
- the job is done, cancelled or archived.

Booking over an overlap or an availability conflict needs `"override": true`, an `override_reason`, and the
`tickets:dispatch` ability. `override` never allows a visit in the past.

A job in a `new` column moves to the board's first `scheduled` column, and the technician is added to the
assignees. `POST /v1/tickets/{id}/unschedule` clears the technician and window.

## 4. Start the job

<span class="ep"><b>POST</b> /v1/tickets/&#123;id&#125;/start</span>

Sets `started_at` the first time (repeating it changes nothing) and moves the job to the first `in_progress` column
when the board has one.

## 5. Complete the job

Read the completion preview first if you want the job type's default items with the client's prices:

<span class="ep"><b>GET</b> /v1/tickets/&#123;id&#125;/completion-preview</span>

Then complete:

<span class="ep"><b>POST</b> /v1/tickets/&#123;id&#125;/complete</span>

```bash
curl -X POST https://api.invoicing.proradius.co/v1/tickets/9a7c3e1f-2b4d-4e6f-8a1c-3d5e7f9b1c2d/complete \
  -H "Authorization: Bearer $PRORADIUS_API_KEY" \
  -H "Idempotency-Key: 6f1d2c3b-7a8e-4f90-a1b2-c3d4e5f60718" \
  -H "Content-Type: application/json" \
  -d '{
    "outcome": "done",
    "notes": "Installed ONT, speed test 48/20 Mbps",
    "items": [
      { "item_uuid": "3f1c2b4a-5d6e-4f70-8a9b-0c1d2e3f4a5b", "quantity": 1, "serial": "ZTEG12345678" }
    ],
    "collect_payment": { "amount": 50, "currency": "USD", "method": "cash" }
  }'
```

| Field | Notes |
| --- | --- |
| `outcome` | Required. `done`, `partial` or `revisit`. |
| `notes` | Up to 5000 characters. |
| `reason` | Required for `revisit`. Optional `reason_code`: `no_show`, `needs_parts`, `needs_time`, `customer_request`, `other`. |
| `items` | Up to 100 lines: `item_uuid`, `quantity`, optional `price`, `serial`, `mac`. |
| `checklist_state` | `[{"id": "...", "done": true}]` for the job type's checklist. |
| `collect_payment` | Optional payment on the new invoice: `amount`, optional `currency`, `method` (`cash`, `whish`, `omt`, `bob`, `bank_transfer`), `exchange_rate`. |
| `notify_client` | Default `true`. Sends the "job completed" message when customer messages are on. |
| `invoice_now` | For `partial` only. Default `true`; `false` keeps the items to bill later. |
| `create_follow_up` | Creates a linked follow-up job. Needs `tickets:create`. |

Everything happens in one transaction. If the invoice or payment fails, the answer is `422` with the reason and
nothing changes.

### Billing modes

The job type's `billing_mode` decides what completion does with the items:

| `billing_mode` | On completion |
| --- | --- |
| `none` | No invoice. Use it for free work such as repairs under contract. |
| `on_completion` | When items are sent, an invoice is created for the client and linked to the job. An optional payment is recorded on it. Needs `invoices:create`. |
| `manual` | The items are saved and no invoice is created. Bill later with `POST /v1/tickets/{id}/create-invoice`. |

`revisit` never bills. It sends the job back to the queue: the technician and window are cleared and the job returns
to the first `new` column.

### The answer

`complete`, `create-invoice` and `cancel` return the job plus a `completion` object:

```json
{
  "id": "9a7c3e1f-2b4d-4e6f-8a1c-3d5e7f9b1c2d",
  "ticket_number": "TKT-0123",
  "status": "Done",
  "status_category": "done",
  "resolved_at": "2026-10-20T08:40:00Z",
  "completion": {
    "replayed": false,
    "billing_mode": "on_completion",
    "invoice": {
      "id": "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d",
      "number": "INV-001204",
      "total": 50,
      "balance": 0,
      "currency": "USD",
      "status": "paid"
    },
    "payment_id": "a41d6f92-8c07-4b3e-95a2-0d7e6c1b8f43",
    "follow_up": null,
    "client_notified": true
  }
}
```

Completing a job that is already finished answers `200` with `completion.replayed: true` and the same invoice. Send
an `Idempotency-Key` as well to make network retries exact. See [Retries and idempotency](/concepts/idempotency/).

## Cancel instead

<span class="ep"><b>POST</b> /v1/tickets/&#123;id&#125;/cancel</span>

```json
{ "reason": "Client moved to another address" }
```

The job moves to the first `cancelled` column with `closed_at` and `cancellation_reason`. Nothing is invoiced.
Cancelling twice answers `200` with no change; a done job answers `422`.

## Follow along with webhooks

Instead of polling, subscribe an endpoint to the `ticket.*` topics:

| Topic | Sent when |
| --- | --- |
| `ticket.created` | A job is created, from any channel. |
| `ticket.scheduled` | A visit is booked, moved or cleared. |
| `ticket.status_changed` | The job moves to another column. |
| `ticket.assigned` | Assignees change. |
| `ticket.completed` | A completion with outcome `done` or `partial` succeeds. |
| `ticket.cancelled` | The job is cancelled. |
| `ticket.archived` | The job is archived. |

**Note:** Ticket payloads carry ids and the main fields only. Fetch the job with `GET /v1/tickets/{id}` when you need more.

See [Webhooks](/guides/webhooks/) for signatures and retries.