# PaySuiteTT time tracking API

Log time, run timers, add clients, projects and tasks, correct entries and pull client reports in PaySuiteTT with an API key, the way Clockify API keys work. Reference: https://paysuitett.com/developers/api · OpenAPI 3.1: https://paysuitett.com/developers/api/openapi.json

## Authentication

Send your key in `Authorization: Bearer <key>` (or `X-Api-Key: <key>`). Keys look like `pstt_live_` followed by 40 characters. Make one in PaySuiteTT under **Settings → API keys**; it's shown once. The PaySuiteTT team switches the API on for each business; ask support@illusionxmedia.com.

- **Personal key** (any member): adds clients, projects and tasks; runs and describes its holder's timer; adds, corrects and deletes its holder's own unapproved time; reports on all Staff time, read only, hours only. For time operations it acts as Staff, whatever the holder's role. Billing endpoints use the holder's actual Owner/Admin or Accountant role and require the invoices service.
- **Master key** (business owner only): also reads, corrects and deletes anyone's time (approved time too, never time in a pay run), and its reports include amounts. It stops working if its holder stops being the owner.

Never paste a key into a chat or commit it; give tools the key as an environment variable such as `PAYSUITETT_API_KEY`.

## Conventions

- Base URL: `https://paysuitett.com/api/v1`. Bodies and responses are JSON (`Content-Type: application/json`).
- Times are ISO 8601 with an offset (Trinidad is `-04:00`, no daylight saving). Dates are `YYYY-MM-DD` business days in Trinidad.
- IDs are UUIDs. Send `client_ref` (a UUID you choose) when starting a timer or adding time; the same `client_ref` again returns the entry already made, so retries are safe.
- Time description generation is limited to 20 requests per business per hour.

## Where time goes

Starting a timer, changing it, adding time and correcting an entry all file the time under a task, given one of these ways:

| Name | Type | Description |
| --- | --- | --- |
| `task_id` | string (uuid) | File the time under this task. Takes precedence over the fields below. |
| `project_id` | string (uuid) | The project, by ID. With `task` (a task name) or, for a project with one task, alone. |
| `project` | string | The project, by its exact name (ignoring case). A name nobody has yet is added on the spot only when `client` is sent too, so a typo doesn't make a project. |
| `client` | string | The client, by name. Only used with `project` to add a new project (and the client, if new). |
| `task` | string | A task name in the project: found, or added if it doesn't exist. Not needed when the project has one task. |

## The time entry object

| Name | Type | Description |
| --- | --- | --- |
| `id` | string (uuid) | The entry's ID. |
| `user_id` | string (uuid) | Whose time it is. |
| `status` | string | Timer state. One of: `running`, `paused`, `stopped`. |
| `started_at` | string (date-time) | Start, ISO 8601. |
| `ended_at` | string (date-time) | End, ISO 8601; null while running. |
| `seconds` | integer | Active seconds (paused time not counted). |
| `hours` | number | Active hours, two decimals. |
| `description` | string | What the work was. Empty string when none. |
| `billable` | boolean | Marked billable. Billable time counts only once a manager approves it. |
| `approved` | boolean | Approved by a manager or accountant. |
| `task_id` | string (uuid) | The task the time is filed under; null when none. |
| `task` | string | The task's name; null when none. |
| `project_id` | string (uuid) | The project; null when none. |
| `project` | string | The project's name; null when none. |
| `client` | string | The project's client; null when none. |
| `source` | string | Where the entry came from: api, mobile_web (browser timer), desktop, kiosk, manual… |

## Errors

Errors return a JSON body with a code and a message that says what to fix:

```json
{
  "error": {
    "code": "INVALID_REQUEST",
    "message": "Send start and end (ISO 8601, e.g. 2026-10-05T09:00:00-04:00)."
  }
}
```

| Status | Code | When |
| --- | --- | --- |
| 400 | `INVALID_REQUEST` | The body or a parameter is missing or wrong. The message says what to fix. |
| 401 | `UNAUTHORIZED` | No key, a malformed key, or a revoked key. |
| 403 | `API_OFF` | API keys aren't switched on for the business. |
| 403 | `FORBIDDEN` | The key may not do this: someone else's time or a master-key-only endpoint with a personal key, approved time, time in a pay run, a holder who left the business, or a master key whose holder is no longer the owner. |
| 409 | `NOTHING_TO_BILL` | No next service period or no billable invoice lines remain. |
| 409 | `CONFLICT` | Only draft invoices can be changed. |
| 404 | `NOT_FOUND` | No such entry, project or task that the key's holder can see. |

## Endpoints

## Account

### Who the key belongs to

`GET https://paysuitett.com/api/v1/me`

The key's holder, their business, the key itself and, in plain words, what it can do. Includes billing: whether this key has billing access (role and invoices service). A good first call to check a key works.

**Access:** Any key.

**Example request**

```bash
curl "https://paysuitett.com/api/v1/me" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY"
```

**Response** (200)

```json
{
  "user_id": "1f2e3d4c-5b6a-4789-8a7b-6c5d4e3f2a10",
  "name": "Kyle Mahadeo",
  "business_id": "33b7145b-a41c-4d2d-a48d-7fc8121d36c2",
  "billing": false,
  "key": {
    "id": "5d6e7f80-91a2-4b3c-8d4e-5f6a7b8c9d0e",
    "name": "Claude on my laptop",
    "kind": "personal",
    "prefix": "pstt_live_zG1U"
  },
  "can": [
    "add clients, projects and tasks",
    "start, stop and describe your timer",
    "add, correct and delete your own unapproved time",
    "reports on all Staff time a manager has approved (read only, hours only)"
  ]
}
```

### People reports cover

`GET https://paysuitett.com/api/v1/people`

The Staff whose time reports cover, for the `person` report filter. A master key gets everyone in the business, with their role.

**Access:** Any key. Personal keys get Staff only, without roles.

**Example request**

```bash
curl "https://paysuitett.com/api/v1/people" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY"
```

**Response** (200)

```json
{
  "people": [
    {
      "user_id": "1f2e3d4c-5b6a-4789-8a7b-6c5d4e3f2a10",
      "name": "Kyle Mahadeo",
      "active": true
    }
  ]
}
```

- With a master key each person also has `role` (owner_admin, accountant, project_time_manager, staff…).

## Clients and projects

### Read a project

`GET https://paysuitett.com/api/v1/projects/{id}`

Returns a visible project, including its customer-facing invoiceName (null when unset).

**Access:** Any key, for projects the holder can see.

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | no | Project ID. |

**Example request**

```bash
curl "https://paysuitett.com/api/v1/projects/a1b2c3d4-e5f6-4789-90ab-cdef12345678" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY"
```

**Response** (200)

```json
{
  "project": {
    "id": "a1b2c3d4-e5f6-4789-90ab-cdef12345678",
    "name": "Development",
    "client": "Agency",
    "active": true,
    "invoiceName": "Development services"
  }
}
```

### List clients

`GET https://paysuitett.com/api/v1/clients`

The business's client directory, each client with the projects the key's holder can track time on. `id` is the directory entry's, for PATCH /clients/{id}; null for a client named only on a project.

**Access:** Any key.

**Example request**

```bash
curl "https://paysuitett.com/api/v1/clients" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY"
```

**Response** (200)

```json
{
  "clients": [
    {
      "id": "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b",
      "name": "21PG Recruiting",
      "projects": [
        {
          "id": "a1b2c3d4-e5f6-4789-90ab-cdef12345678",
          "name": "Recruiting Metrics Dashboard"
        }
      ]
    }
  ]
}
```

### Add a client

`POST https://paysuitett.com/api/v1/clients`

Adds a client to the directory, or returns the existing one with that name (ignoring case).

**Access:** Any key.

**JSON body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes | The client's name, up to 200 characters. |

**Example request**

```bash
curl -X POST "https://paysuitett.com/api/v1/clients" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Qosmos"}'
```

**Response** (201)

```json
{
  "client": {
    "name": "Qosmos"
  },
  "created": true
}
```

- Returns 200 with `created: false` when the client already exists.

### Change a client

`PATCH https://paysuitett.com/api/v1/clients/{id}`

Changes a client in the directory. Send only what changes. Projects name their client, so a new name is carried onto the client's projects too.

**Access:** Master key only.

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | The client's ID, from GET /clients. |

**JSON body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | no | The new name, up to 200 characters. Refused if another client has it. |
| `email` | string | no | Email address; empty to clear. |
| `phone` | string | no | Phone number; empty to clear. |
| `address` | string | no | Address; empty to clear. |

**Example request**

```bash
curl -X PATCH "https://paysuitett.com/api/v1/clients/4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"21PG Recruiting Ltd"}'
```

**Response** (200)

```json
{
  "client": {
    "id": "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b",
    "name": "21PG Recruiting Ltd",
    "email": null,
    "phone": null,
    "address": null
  }
}
```

### List projects

`GET https://paysuitett.com/api/v1/projects`

Active projects the key's holder can track time on; archived ones too with `archived=true`.

**Access:** Any key.

**Query parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `client` | string | no | Only this client's projects (exact name, ignoring case). |
| `archived` | boolean | no | true to include archived projects (`active: false`). |

**Example request**

```bash
curl "https://paysuitett.com/api/v1/projects?client=21PG%20Recruiting" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY"
```

**Response** (200)

```json
{
  "projects": [
    {
      "id": "a1b2c3d4-e5f6-4789-90ab-cdef12345678",
      "name": "Recruiting Metrics Dashboard",
      "client": "21PG Recruiting",
      "active": true,
      "invoiceName": null
    }
  ]
}
```

### Add a project

`POST https://paysuitett.com/api/v1/projects`

Adds a project for the key's holder, as the tracker app does: its client joins the directory, it's shared with the holder's teams, and it gets a first task (`task`, or "General"). A project with that name the holder can already see is returned instead of a duplicate.

**Access:** Any key, whatever the holder's own page permissions.

**JSON body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes | The project's name. |
| `client` | string | no | The client's name; added to the directory if new. |
| `task` | string | no | The first task's name. Defaults to "General". |
| `description` | string | no | A note about the project. |

**Example request**

```bash
curl -X POST "https://paysuitett.com/api/v1/projects" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Website refresh","client":"Qosmos","task":"Design"}'
```

**Response** (201)

```json
{
  "project": {
    "id": "a1b2c3d4-e5f6-4789-90ab-cdef12345678",
    "name": "Website refresh",
    "client": "Qosmos"
  },
  "created": true
}
```

- Returns 200 with `created: false` when the project already exists.

### Change or archive a project

`PATCH https://paysuitett.com/api/v1/projects/{id}`

Changes a project's name, client, description, budget or billing rate, or archives it (`active: false`) and brings it back. Send only what changes. A new client name joins the directory.

**Access:** Billing access for invoiceName alone. Other fields require a master key. In businesses that allow it, a personal key may assign the client (`client` only) on projects its holder can see.

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | The project's ID. |

**JSON body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | no | The new name. |
| `client` | string | no | The client's name; empty to remove the client. |
| `description` | string | no | A note about the project; empty to clear. |
| `invoiceName` | string | no | Customer-facing name, trimmed, max 120 characters; null clears it. Billing access required (master or personal owner/accountant, invoices service). |
| `budget_hours` | number | no | Budgeted hours; null to clear. |
| `hourly_rate` | number | no | Billing rate per hour in the project's currency, e.g. 10.5; null to clear. |
| `currency` | string | no | Three-letter currency code, e.g. TTD or USD. |
| `active` | boolean | no | false archives the project (no new time on it, its hours stay); true brings it back. |

**Example request**

```bash
curl -X PATCH "https://paysuitett.com/api/v1/projects/a1b2c3d4-e5f6-4789-90ab-cdef12345678" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Recruiting Metrics Dashboard v2"}'
```

**Response** (200)

```json
{
  "project": {
    "id": "a1b2c3d4-e5f6-4789-90ab-cdef12345678",
    "name": "Recruiting Metrics Dashboard v2",
    "client": "21PG Recruiting",
    "active": true,
    "invoiceName": null,
    "description": null,
    "budget_hours": null,
    "hourly_rate": 10,
    "currency": "USD"
  }
}
```

### Delete a project

`DELETE https://paysuitett.com/api/v1/projects/{id}`

Deletes a project nobody tracked time on, with its tasks. A project with time can't be deleted: archive it instead (PATCH with `active: false`), so its hours stay with it.

**Access:** Master key. In businesses that allow it, a personal key may delete projects its holder can see.

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | The project's ID. |

**Example request**

```bash
curl -X DELETE "https://paysuitett.com/api/v1/projects/a1b2c3d4-e5f6-4789-90ab-cdef12345678" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY"
```

**Response** (200)

```json
{
  "deleted": {
    "id": "a1b2c3d4-e5f6-4789-90ab-cdef12345678",
    "name": "Website refresh",
    "client": "Qosmos"
  }
}
```

### List a project's tasks

`GET https://paysuitett.com/api/v1/projects/{id}/tasks`

The project's active tasks.

**Access:** Any key, for projects its holder can see.

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | The project's ID. |

**Example request**

```bash
curl "https://paysuitett.com/api/v1/projects/a1b2c3d4-e5f6-4789-90ab-cdef12345678/tasks" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY"
```

**Response** (200)

```json
{
  "tasks": [
    {
      "id": "c0a8012e-0d4b-4e3f-9b1a-7f6e5d4c3b2a",
      "name": "General",
      "project_id": "a1b2c3d4-e5f6-4789-90ab-cdef12345678"
    }
  ]
}
```

### Find or add a task

`POST https://paysuitett.com/api/v1/projects/{id}/tasks`

Finds the task by name in the project, or adds it.

**Access:** Any key, for projects its holder can see.

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | The project's ID. |

**JSON body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes | The task's name. |

**Example request**

```bash
curl -X POST "https://paysuitett.com/api/v1/projects/a1b2c3d4-e5f6-4789-90ab-cdef12345678/tasks" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Sourcing"}'
```

**Response** (201)

```json
{
  "task": {
    "id": "c0a8012e-0d4b-4e3f-9b1a-7f6e5d4c3b2a",
    "name": "Sourcing",
    "project_id": "a1b2c3d4-e5f6-4789-90ab-cdef12345678"
  }
}
```

## Teams

### List teams

`GET https://paysuitett.com/api/v1/teams`

The business's teams: each with its members (user IDs) and the projects its members can track time on.

**Access:** Master key only.

**Example request**

```bash
curl "https://paysuitett.com/api/v1/teams" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY"
```

**Response** (200)

```json
{
  "teams": [
    {
      "id": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
      "name": "Developers",
      "archived": false,
      "members": [
        "1f2e3d4c-5b6a-4789-8a7b-6c5d4e3f2a10"
      ],
      "projects": [
        "a1b2c3d4-e5f6-4789-90ab-cdef12345678"
      ]
    }
  ]
}
```

### Add a person to a team

`POST https://paysuitett.com/api/v1/teams/{id}/members`

Adds a person to the team, so they can track time on the team's projects.

**Access:** Master key only.

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | The team's ID, from GET /teams. |

**JSON body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `user_id` | string (uuid) | yes | The person's user_id, from GET /people. |

**Example request**

```bash
curl -X POST "https://paysuitett.com/api/v1/teams/6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d/members" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"user_id":"1f2e3d4c-5b6a-4789-8a7b-6c5d4e3f2a10"}'
```

**Response** (200)

```json
{
  "team_id": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
  "user_id": "1f2e3d4c-5b6a-4789-8a7b-6c5d4e3f2a10",
  "member": true
}
```

### Take a person out of a team

`DELETE https://paysuitett.com/api/v1/teams/{id}/members`

Takes a person out of the team. Their time stays.

**Access:** Master key only.

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | The team's ID. |

**Query parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `user_id` | string (uuid) | yes | The person's user_id. |

**Example request**

```bash
curl -X DELETE "https://paysuitett.com/api/v1/teams/6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d/members?user_id=1f2e3d4c-5b6a-4789-8a7b-6c5d4e3f2a10" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY"
```

**Response** (200)

```json
{
  "team_id": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
  "user_id": "1f2e3d4c-5b6a-4789-8a7b-6c5d4e3f2a10",
  "member": false
}
```

### Give a team a project

`POST https://paysuitett.com/api/v1/projects/{id}/teams`

Lets the team's members track time on the project.

**Access:** Master key only.

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | The project's ID. |

**JSON body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `team_id` | string (uuid) | yes | The team's ID, from GET /teams. |

**Example request**

```bash
curl -X POST "https://paysuitett.com/api/v1/projects/a1b2c3d4-e5f6-4789-90ab-cdef12345678/teams" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"team_id":"6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d"}'
```

**Response** (200)

```json
{
  "project_id": "a1b2c3d4-e5f6-4789-90ab-cdef12345678",
  "team_id": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
  "access": true
}
```

### Take a project from a team

`DELETE https://paysuitett.com/api/v1/projects/{id}/teams`

Stops the team's members tracking new time on the project through the team. Time already tracked stays.

**Access:** Master key only.

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | The project's ID. |

**Query parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `team_id` | string (uuid) | yes | The team's ID. |

**Example request**

```bash
curl -X DELETE "https://paysuitett.com/api/v1/projects/a1b2c3d4-e5f6-4789-90ab-cdef12345678/teams?team_id=6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY"
```

**Response** (200)

```json
{
  "project_id": "a1b2c3d4-e5f6-4789-90ab-cdef12345678",
  "team_id": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
  "access": false
}
```

## Timer

### Running timers

`GET https://paysuitett.com/api/v1/timer`

The key holder's running or paused timers (usually one).

**Access:** Any key, its holder's own timer; a master key anyone's with `user_id`.

**Query parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `user_id` | string (uuid) | no | Master key only: this person's timers. |

**Example request**

```bash
curl "https://paysuitett.com/api/v1/timer" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY"
```

**Response** (200)

```json
{
  "running": [
    {
      "id": "7b8f0c2e-3c4d-4a51-9a0e-2f1b6c9d8e71",
      "user_id": "1f2e3d4c-5b6a-4789-8a7b-6c5d4e3f2a10",
      "status": "running",
      "started_at": "2026-10-05T09:00:00-04:00",
      "ended_at": null,
      "seconds": 1260,
      "hours": 0.35,
      "description": "LinkedIn search",
      "billable": true,
      "approved": false,
      "task_id": "c0a8012e-0d4b-4e3f-9b1a-7f6e5d4c3b2a",
      "task": "Sourcing",
      "project_id": "a1b2c3d4-e5f6-4789-90ab-cdef12345678",
      "project": "Recruiting Metrics Dashboard",
      "client": "21PG Recruiting",
      "source": "api"
    }
  ]
}
```

### Start a timer

`POST https://paysuitett.com/api/v1/timer/start`

Starts a timer for the key's holder. Say where the time goes with one of the ways below.

**Access:** Any key; always its holder's own timer.

**JSON body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `task_id` | string (uuid) | no | File the time under this task. Takes precedence over the fields below. |
| `project_id` | string (uuid) | no | The project, by ID. With `task` (a task name) or, for a project with one task, alone. |
| `project` | string | no | The project, by its exact name (ignoring case). A name nobody has yet is added on the spot only when `client` is sent too, so a typo doesn't make a project. |
| `client` | string | no | The client, by name. Only used with `project` to add a new project (and the client, if new). |
| `task` | string | no | A task name in the project: found, or added if it doesn't exist. Not needed when the project has one task. |
| `description` | string | no | What the work is, up to 2,000 characters. |
| `billable` | boolean | no | Billable to the client. Defaults to true. |
| `start` | string (date-time) | no | When it started, if not now. ISO 8601. |
| `client_ref` | string (uuid) | no | A UUID you choose. Sending the same one again returns the timer already started instead of starting another, so retries are safe. |

**Example request**

```bash
curl -X POST "https://paysuitett.com/api/v1/timer/start" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"description":"LinkedIn search","billable":true,"start":"2026-10-05T09:00:00-04:00"}'
```

**Response** (201)

```json
{
  "entry": {
    "id": "7b8f0c2e-3c4d-4a51-9a0e-2f1b6c9d8e71",
    "user_id": "1f2e3d4c-5b6a-4789-8a7b-6c5d4e3f2a10",
    "status": "running",
    "started_at": "2026-10-05T09:00:00-04:00",
    "ended_at": null,
    "seconds": 0,
    "hours": 0,
    "description": "LinkedIn search",
    "billable": true,
    "approved": false,
    "task_id": "c0a8012e-0d4b-4e3f-9b1a-7f6e5d4c3b2a",
    "task": "Sourcing",
    "project_id": "a1b2c3d4-e5f6-4789-90ab-cdef12345678",
    "project": "Recruiting Metrics Dashboard",
    "client": "21PG Recruiting",
    "source": "api"
  }
}
```

- A business allows one running timer per person unless it has changed that; starting another while one runs is refused with 400.

### Change the running timer

`PATCH https://paysuitett.com/api/v1/timer`

Changes the running timer's description, billable flag or project without stopping it.

**Access:** Any key; always its holder's own timer.

**JSON body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `entry_id` | string (uuid) | no | Which running timer. Optional with one timer running; required when several are running (people allowed simultaneous timers). |
| `description` | string | no | What the work is, up to 2,000 characters. |
| `billable` | boolean | no | Billable to the client. Defaults to true. |
| `task_id` | string (uuid) | no | File the time under this task. Takes precedence over the fields below. |
| `project_id` | string (uuid) | no | The project, by ID. With `task` (a task name) or, for a project with one task, alone. |
| `project` | string | no | The project, by its exact name (ignoring case). A name nobody has yet is added on the spot only when `client` is sent too, so a typo doesn't make a project. |
| `client` | string | no | The client, by name. Only used with `project` to add a new project (and the client, if new). |
| `task` | string | no | A task name in the project: found, or added if it doesn't exist. Not needed when the project has one task. |

**Example request**

```bash
curl -X PATCH "https://paysuitett.com/api/v1/timer" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"description":"LinkedIn search","billable":true}'
```

**Response** (200)

```json
{
  "entry": {
    "id": "7b8f0c2e-3c4d-4a51-9a0e-2f1b6c9d8e71",
    "user_id": "1f2e3d4c-5b6a-4789-8a7b-6c5d4e3f2a10",
    "status": "running",
    "started_at": "2026-10-05T09:00:00-04:00",
    "ended_at": null,
    "seconds": 9000,
    "hours": 2.5,
    "description": "Homepage layout",
    "billable": true,
    "approved": false,
    "task_id": "c0a8012e-0d4b-4e3f-9b1a-7f6e5d4c3b2a",
    "task": "Sourcing",
    "project_id": "a1b2c3d4-e5f6-4789-90ab-cdef12345678",
    "project": "Recruiting Metrics Dashboard",
    "client": "21PG Recruiting",
    "source": "api"
  }
}
```

### Stop the timer

`POST https://paysuitett.com/api/v1/timer/stop`

Stops the running timer. The body is optional.

**Access:** Any key, its holder's own timer; a master key anyone's with `user_id`.

**JSON body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `user_id` | string (uuid) | no | Master key only: stop this person's timer. |
| `entry_id` | string (uuid) | no | Which running timer. Optional with one timer running; required when several are running (people allowed simultaneous timers). |
| `end` | string (date-time) | no | When it ended, if not now. ISO 8601. |
| `description` | string | no | Replace the description as it stops. |

**Example request**

```bash
curl -X POST "https://paysuitett.com/api/v1/timer/stop" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY"
```

**Response** (200)

```json
{
  "entry": {
    "id": "7b8f0c2e-3c4d-4a51-9a0e-2f1b6c9d8e71",
    "user_id": "1f2e3d4c-5b6a-4789-8a7b-6c5d4e3f2a10",
    "status": "stopped",
    "started_at": "2026-10-05T09:00:00-04:00",
    "ended_at": "2026-10-05T11:30:00-04:00",
    "seconds": 9000,
    "hours": 2.5,
    "description": "LinkedIn search",
    "billable": true,
    "approved": false,
    "task_id": "c0a8012e-0d4b-4e3f-9b1a-7f6e5d4c3b2a",
    "task": "Sourcing",
    "project_id": "a1b2c3d4-e5f6-4789-90ab-cdef12345678",
    "project": "Recruiting Metrics Dashboard",
    "client": "21PG Recruiting",
    "source": "api"
  }
}
```

## Time entries

### List time entries

`GET https://paysuitett.com/api/v1/time-entries`

Time entries between two dates, newest first. Dates are business days in Trinidad.

**Access:** A personal key lists its holder's own entries; a master key anyone's.

**Query parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `start` | string (date) | no | First day, YYYY-MM-DD. |
| `end` | string (date) | no | Last day, YYYY-MM-DD. |
| `project_id` | string (uuid) | no | Only this project. |
| `user_id` | string (uuid) | no | Master key only: one person's entries. |
| `status` | string | no | Only entries in this state. One of: `running`, `paused`, `stopped`. |
| `limit` | integer | no | Most entries to return, 1 to 5,000. Defaults to 500. |
| `offset` | integer | no | Entries to skip first, for the next page: send the number already read. Defaults to 0. |

**Example request**

```bash
curl "https://paysuitett.com/api/v1/time-entries?start=2026-10-01&end=2026-10-07&limit=500" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY"
```

**Response** (200)

```json
{
  "entries": [
    {
      "id": "7b8f0c2e-3c4d-4a51-9a0e-2f1b6c9d8e71",
      "user_id": "1f2e3d4c-5b6a-4789-8a7b-6c5d4e3f2a10",
      "status": "stopped",
      "started_at": "2026-10-05T09:00:00-04:00",
      "ended_at": "2026-10-05T11:30:00-04:00",
      "seconds": 9000,
      "hours": 2.5,
      "description": "LinkedIn search",
      "billable": true,
      "approved": false,
      "task_id": "c0a8012e-0d4b-4e3f-9b1a-7f6e5d4c3b2a",
      "task": "Sourcing",
      "project_id": "a1b2c3d4-e5f6-4789-90ab-cdef12345678",
      "project": "Recruiting Metrics Dashboard",
      "client": "21PG Recruiting",
      "source": "api"
    }
  ]
}
```

### Add finished time

`POST https://paysuitett.com/api/v1/time-entries`

Adds a finished block of time for the key's holder, as "Add time" does in the app. A master key adds it for someone else with `user_id`.

**Access:** Any key, its holder's own time; a master key anyone's.

**JSON body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `user_id` | string (uuid) | no | Master key only: whose time it is (from GET /people). The person must track time on projects; the time can't be in the future. |
| `start` | string (date-time) | yes | Start, ISO 8601 with the offset (Trinidad is -04:00). |
| `end` | string (date-time) | yes | End, after the start. |
| `task_id` | string (uuid) | no | File the time under this task. Takes precedence over the fields below. |
| `project_id` | string (uuid) | no | The project, by ID. With `task` (a task name) or, for a project with one task, alone. |
| `project` | string | no | The project, by its exact name (ignoring case). A name nobody has yet is added on the spot only when `client` is sent too, so a typo doesn't make a project. |
| `client` | string | no | The client, by name. Only used with `project` to add a new project (and the client, if new). |
| `task` | string | no | A task name in the project: found, or added if it doesn't exist. Not needed when the project has one task. |
| `description` | string | no | What the work is, up to 2,000 characters. |
| `billable` | boolean | no | Billable to the client. Defaults to true. |
| `client_ref` | string (uuid) | no | A UUID you choose. Sending the same one again returns the entry already added instead of adding it twice, so retries are safe. |

**Example request**

```bash
curl -X POST "https://paysuitett.com/api/v1/time-entries" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"start":"2026-10-05T09:00:00-04:00","end":"2026-10-05T11:30:00-04:00","description":"LinkedIn search","billable":true}'
```

**Response** (201)

```json
{
  "entry": {
    "id": "7b8f0c2e-3c4d-4a51-9a0e-2f1b6c9d8e71",
    "user_id": "1f2e3d4c-5b6a-4789-8a7b-6c5d4e3f2a10",
    "status": "stopped",
    "started_at": "2026-10-05T09:00:00-04:00",
    "ended_at": "2026-10-05T11:30:00-04:00",
    "seconds": 9000,
    "hours": 2.5,
    "description": "LinkedIn search",
    "billable": true,
    "approved": false,
    "task_id": "c0a8012e-0d4b-4e3f-9b1a-7f6e5d4c3b2a",
    "task": "Sourcing",
    "project_id": "a1b2c3d4-e5f6-4789-90ab-cdef12345678",
    "project": "Recruiting Metrics Dashboard",
    "client": "21PG Recruiting",
    "source": "api"
  }
}
```

### Get a time entry

`GET https://paysuitett.com/api/v1/time-entries/{id}`

One time entry.

**Access:** A personal key its holder's own entries; a master key anyone's.

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | The entry's ID. |

**Example request**

```bash
curl "https://paysuitett.com/api/v1/time-entries/7b8f0c2e-3c4d-4a51-9a0e-2f1b6c9d8e71" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY"
```

**Response** (200)

```json
{
  "entry": {
    "id": "7b8f0c2e-3c4d-4a51-9a0e-2f1b6c9d8e71",
    "user_id": "1f2e3d4c-5b6a-4789-8a7b-6c5d4e3f2a10",
    "status": "stopped",
    "started_at": "2026-10-05T09:00:00-04:00",
    "ended_at": "2026-10-05T11:30:00-04:00",
    "seconds": 9000,
    "hours": 2.5,
    "description": "LinkedIn search",
    "billable": true,
    "approved": false,
    "task_id": "c0a8012e-0d4b-4e3f-9b1a-7f6e5d4c3b2a",
    "task": "Sourcing",
    "project_id": "a1b2c3d4-e5f6-4789-90ab-cdef12345678",
    "project": "Recruiting Metrics Dashboard",
    "client": "21PG Recruiting",
    "source": "api"
  }
}
```

### Correct a time entry

`PATCH https://paysuitett.com/api/v1/time-entries/{id}`

Changes an entry's description, billable flag, times or project. A corrected entry counts as one block from start to end. Send only what changes.

**Access:** A personal key its holder's own unapproved entries. A master key anyone's, approved time too. Time in a pay run is never changed.

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | The entry's ID. |

**JSON body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `description` | string | no | What the work is, up to 2,000 characters. |
| `billable` | boolean | no | Billable to the client. Defaults to true. |
| `start` | string (date-time) | no | New start, ISO 8601. |
| `end` | string (date-time) | no | New end, ISO 8601. |
| `task_id` | string (uuid) | no | File the time under this task. Takes precedence over the fields below. |
| `project_id` | string (uuid) | no | The project, by ID. With `task` (a task name) or, for a project with one task, alone. |
| `project` | string | no | The project, by its exact name (ignoring case). A name nobody has yet is added on the spot only when `client` is sent too, so a typo doesn't make a project. |
| `client` | string | no | The client, by name. Only used with `project` to add a new project (and the client, if new). |
| `task` | string | no | A task name in the project: found, or added if it doesn't exist. Not needed when the project has one task. |

**Example request**

```bash
curl -X PATCH "https://paysuitett.com/api/v1/time-entries/7b8f0c2e-3c4d-4a51-9a0e-2f1b6c9d8e71" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"description":"LinkedIn search","billable":true}'
```

**Response** (200)

```json
{
  "entry": {
    "id": "7b8f0c2e-3c4d-4a51-9a0e-2f1b6c9d8e71",
    "user_id": "1f2e3d4c-5b6a-4789-8a7b-6c5d4e3f2a10",
    "status": "stopped",
    "started_at": "2026-10-05T09:00:00-04:00",
    "ended_at": "2026-10-05T11:30:00-04:00",
    "seconds": 9000,
    "hours": 2.5,
    "description": "LinkedIn search",
    "billable": true,
    "approved": false,
    "task_id": "c0a8012e-0d4b-4e3f-9b1a-7f6e5d4c3b2a",
    "task": "Sourcing",
    "project_id": "a1b2c3d4-e5f6-4789-90ab-cdef12345678",
    "project": "Recruiting Metrics Dashboard",
    "client": "21PG Recruiting",
    "source": "api"
  }
}
```

- A master key correcting approved time rebuilds its payroll hours, so payroll sees the change.

### Delete a time entry

`DELETE https://paysuitett.com/api/v1/time-entries/{id}`

Removes a wrong entry for good. Only unapproved time outside any pay run and without screenshots (screenshots are kept as evidence). Recorded in the business's audit log.

**Access:** A personal key its holder's own entries; a master key anyone's.

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | The entry's ID. |

**Example request**

```bash
curl -X DELETE "https://paysuitett.com/api/v1/time-entries/7b8f0c2e-3c4d-4a51-9a0e-2f1b6c9d8e71" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY"
```

**Response** (200)

```json
{
  "deleted": {
    "id": "7b8f0c2e-3c4d-4a51-9a0e-2f1b6c9d8e71",
    "started_at": "2026-10-05T09:00:00-04:00",
    "ended_at": "2026-10-05T11:30:00-04:00",
    "description": "LinkedIn search"
  }
}
```

### Approve a time entry

`POST https://paysuitett.com/api/v1/time-entries/{id}/approve`

Approves a stopped entry, as Approve time does: it counts toward billable hours in reports and goes to payroll. An approver can't approve their own time unless they are the owner.

**Access:** Master key only.

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | The entry's ID. |

**Example request**

```bash
curl -X POST "https://paysuitett.com/api/v1/time-entries/7b8f0c2e-3c4d-4a51-9a0e-2f1b6c9d8e71/approve" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY"
```

**Response** (200)

```json
{
  "entry": {
    "id": "7b8f0c2e-3c4d-4a51-9a0e-2f1b6c9d8e71",
    "user_id": "1f2e3d4c-5b6a-4789-8a7b-6c5d4e3f2a10",
    "status": "stopped",
    "started_at": "2026-10-05T09:00:00-04:00",
    "ended_at": "2026-10-05T11:30:00-04:00",
    "seconds": 9000,
    "hours": 2.5,
    "description": "LinkedIn search",
    "billable": true,
    "approved": true,
    "task_id": "c0a8012e-0d4b-4e3f-9b1a-7f6e5d4c3b2a",
    "task": "Sourcing",
    "project_id": "a1b2c3d4-e5f6-4789-90ab-cdef12345678",
    "project": "Recruiting Metrics Dashboard",
    "client": "21PG Recruiting",
    "source": "api"
  }
}
```

- A running timer can't be approved; stop it first.

### Take an approval back

`POST https://paysuitett.com/api/v1/time-entries/{id}/unapprove`

Returns an approved entry to Needs approval and takes it out of payroll until it's approved again. Refused once its pay run is approved, finalized or paid; a draft pay run is marked to recompute.

**Access:** Master key only.

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | The entry's ID. |

**Example request**

```bash
curl -X POST "https://paysuitett.com/api/v1/time-entries/7b8f0c2e-3c4d-4a51-9a0e-2f1b6c9d8e71/unapprove" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY"
```

**Response** (200)

```json
{
  "entry": {
    "id": "7b8f0c2e-3c4d-4a51-9a0e-2f1b6c9d8e71",
    "user_id": "1f2e3d4c-5b6a-4789-8a7b-6c5d4e3f2a10",
    "status": "stopped",
    "started_at": "2026-10-05T09:00:00-04:00",
    "ended_at": "2026-10-05T11:30:00-04:00",
    "seconds": 9000,
    "hours": 2.5,
    "description": "LinkedIn search",
    "billable": true,
    "approved": false,
    "task_id": "c0a8012e-0d4b-4e3f-9b1a-7f6e5d4c3b2a",
    "task": "Sourcing",
    "project_id": "a1b2c3d4-e5f6-4789-90ab-cdef12345678",
    "project": "Recruiting Metrics Dashboard",
    "client": "21PG Recruiting",
    "source": "api"
  }
}
```

## Kiosk shifts

### List kiosk shifts

`GET https://paysuitett.com/api/v1/kiosk-shifts`

Clock-ins and clock-outs from the business's kiosks between two work dates, oldest first, with each worker's name, worked hours (breaks taken off), status and approval.

**Access:** Master key only.

**Query parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `start` | string (date) | yes | First work date, YYYY-MM-DD. |
| `end` | string (date) | yes | Last work date, YYYY-MM-DD. |
| `employee_id` | string (uuid) | no | Only this worker's shifts. |
| `status` | string | no | Only shifts in this state. One of: `open`, `on_break`, `closed`, `missed_clock_out`. |
| `approved` | boolean | no | true or false: only approved, or only unapproved, shifts. |
| `limit` | integer | no | Most shifts to return, 1 to 5,000. Defaults to 2,000. |

**Example request**

```bash
curl "https://paysuitett.com/api/v1/kiosk-shifts?start=2026-10-05&end=2026-10-11" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY"
```

**Response** (200)

```json
{
  "shifts": [
    {
      "id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
      "employee_id": "2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091",
      "employee": "Ravi Persad",
      "work_date": "2026-10-06",
      "clock_in_at": "2026-10-06T11:58:00+00:00",
      "clock_out_at": "2026-10-06T21:04:00+00:00",
      "break_minutes": 30,
      "worked_hours": 8.6,
      "status": "closed",
      "approved": false,
      "corrected": false,
      "note": null
    }
  ]
}
```

- `missed_clock_out` shifts need a clock-out: correct them with PATCH /kiosk-shifts/{id}.

### Approve kiosk shifts

`POST https://paysuitett.com/api/v1/kiosk-shifts/approve`

Approves closed kiosk shifts for payroll, as the Kiosk shifts page does. Shifts already approved, still open or missing a clock-out are skipped, and so is the approver's own shift.

**Access:** Master key only.

**JSON body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `shift_ids` | array | yes | 1 to 500 shift IDs. |

**Example request**

```bash
curl -X POST "https://paysuitett.com/api/v1/kiosk-shifts/approve" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"shift_ids":["9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"]}'
```

**Response** (200)

```json
{
  "approved": 1
}
```

- `approved` is how many shifts were approved by this call.

### Correct a kiosk shift

`PATCH https://paysuitett.com/api/v1/kiosk-shifts/{id}`

Changes a shift's clock-in, clock-out or break, with a note, and approves it; its payroll hours are rebuilt. Use it to fix a missed clock-out. Send only what changes.

**Access:** Master key only.

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | yes | The shift's ID. |

**JSON body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `clock_in` | string (date-time) | no | New clock-in, ISO 8601 with the offset. |
| `clock_out` | string (date-time) | no | New clock-out, after the clock-in and not in the future. Required when the shift has none. |
| `break_minutes` | integer | no | Total break minutes, shorter than the shift. |
| `note` | string | no | Why it changed; shown on the Kiosk shifts page. |

**Example request**

```bash
curl -X PATCH "https://paysuitett.com/api/v1/kiosk-shifts/9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"clock_in":"2026-10-06T08:00:00-04:00","clock_out":"2026-10-06T17:00:00-04:00","note":"Forgot to clock out; confirmed with the site lead."}'
```

**Response** (200)

```json
{
  "shift": {
    "id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
    "employee_id": "2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091",
    "employee": "Ravi Persad",
    "work_date": "2026-10-06",
    "clock_in_at": "2026-10-06T12:00:00+00:00",
    "clock_out_at": "2026-10-06T21:00:00+00:00",
    "break_minutes": 30,
    "worked_hours": 8.5,
    "status": "closed",
    "approved": true,
    "corrected": true,
    "note": "Forgot to clock out; confirmed with the site lead."
  }
}
```

- Nobody can correct or approve their own shift; an owner or accountant does it.

## Reports

### Time report

`GET https://paysuitett.com/api/v1/reports`

Every Staff member's time for a client, project or period, as Time Reports shows it: totals, a summary by group, and every entry. Owners', accountants' and managers' own time is left out, unless a master key sends `scope=everyone`. A personal key reports approved time only, so its per-client reports are final; a master key reports every status.

**Access:** Any key, read only. Hours only; a master key also gets amounts, at each project's billing rate.

**Query parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `billing` | string | no | Time-report billing filter; defaults to all. Unbilled/billed includes only billable time. One of: `all`, `unbilled`, `billed`. |
| `range` | string | no | A preset period. Weeks run Monday to Sunday. Or send start and end. One of: `today`, `this_week`, `last_week`, `this_month`, `last_month`, `this_year`. |
| `start` | string (date) | no | First day, YYYY-MM-DD (instead of range). |
| `end` | string (date) | no | Last day, YYYY-MM-DD (instead of range). |
| `client` | string, repeatable | no | A client's name. Repeat for several. |
| `project` | string (uuid), repeatable | no | A project's ID. Repeat for several. |
| `person` | string (uuid), repeatable | no | A person's user_id (from GET /people). Repeat for several. |
| `task` | string, repeatable | no | A task's ID. Repeat for several. |
| `description` | string | no | Entries whose description contains this text. |
| `billable` | string | no | Defaults to all. One of: `all`, `billable`, `non_billable`. |
| `status` | string | no | Defaults to all. One of: `all`, `approved`, `unapproved`. |
| `tab` | string | no | Which report, for CSV and PDF. Defaults to summary. One of: `summary`, `detailed`, `weekly`. |
| `group` | string | no | Summary grouping. Defaults to project. One of: `project`, `client`, `person`, `date`, `task`, `description`. |
| `subgroup` | string | no | Summary sub-grouping. Defaults to description. One of: `project`, `client`, `person`, `date`, `task`, `description`. |
| `format` | string | no | Defaults to json. One of: `json`, `csv`, `pdf`. |
| `scope` | string | no | Master key only: `everyone` includes owners', accountants' and managers' own time, as the owner's Time Reports page does. Defaults to staff. One of: `staff`, `everyone`. |

**Example request**

```bash
curl "https://paysuitett.com/api/v1/reports?range=last_week&client=21PG%20Recruiting" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY"
```

**Response** (200)

```json
{
  "range": {
    "start": "2026-09-28",
    "end": "2026-10-04"
  },
  "filters": {
    "clients": [
      "21PG Recruiting"
    ],
    "projects": [],
    "people": []
  },
  "totals": {
    "hours": 41.5,
    "billable_hours": 38,
    "awaiting_approval_billable_hours": 3.5,
    "entries": 27
  },
  "summary": [
    {
      "project": "Recruiting Metrics Dashboard",
      "hours": 22.25,
      "billable_hours": 20,
      "awaiting_approval_billable_hours": 2.25,
      "by_description": [
        {
          "description": "Sourcing",
          "hours": 9.5
        }
      ]
    }
  ],
  "entries": [
    {
      "id": "7b8f0c2e-3c4d-4a51-9a0e-2f1b6c9d8e71",
      "date": "2026-10-01",
      "start": "2026-10-01T13:00:00.000Z",
      "end": "2026-10-01T15:30:00.000Z",
      "person": "Kyle Mahadeo",
      "client": "21PG Recruiting",
      "project": "Recruiting Metrics Dashboard",
      "task": "Sourcing",
      "description": "LinkedIn search",
      "billable": true,
      "approved": true,
      "hours": 2.5
    }
  ]
}
```

With format=csv, a CSV file (text/csv); with format=pdf, the Time Reports PDF (application/pdf). Both download as attachments.

- Billable hours count approved time only; billable time not yet approved is `awaiting_approval_billable_hours`.
- With a master key, `totals`, `summary` and `entries` also carry `amount` (and `totals.currency`) for projects with a billing rate (set once on the project). When the amounts are in more than one currency, `totals` and that `summary` group leave out `amount` and give `amounts_by_currency` instead.

## Billing

### Find billing customers

`GET https://paysuitett.com/api/v1/customers`

Sales contacts of type customer or both, sorted by name, with whether a billing profile exists.

**Access:** Master key, or a personal key held by an owner/admin or accountant. The business must have the invoices service. Runs as the holder under their row-level security.

**Query parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `search` | string | no | Name contains this text, ignoring case. |

**Example request**

```bash
curl "https://paysuitett.com/api/v1/customers" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY"
```

**Response** (200)

```json
{
  "customers": [
    {
      "id": "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b",
      "name": "Customer",
      "email": "customer@example.com",
      "billingProfile": true
    }
  ]
}
```

### List billing profiles

`GET https://paysuitett.com/api/v1/billing-profiles`

Rates, time sources, cadence and unbilled hours for customers with billing profiles.

**Access:** Master key, or a personal key held by an owner/admin or accountant. The business must have the invoices service. Runs as the holder under their row-level security.

**Example request**

```bash
curl "https://paysuitett.com/api/v1/billing-profiles" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY"
```

**Response** (200)

```json
{
  "profiles": [
    {
      "customerId": "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b",
      "customerName": "Customer",
      "hourlyRate": 150,
      "clientNames": [
        "Agency"
      ],
      "projectIds": [],
      "groupBy": "project",
      "cadenceDays": 14,
      "currencyCode": "TTD",
      "exchangeRate": null,
      "invoiceNumberPrefix": null,
      "lastServiceEnd": "2026-09-30",
      "unbilledHours": 2,
      "earliestUnbilled": "2026-10-01"
    }
  ]
}
```

### Save a billing profile

`PUT https://paysuitett.com/api/v1/billing-profiles/{customerId}`

Upserts a customer's profile. Customer and project IDs must belong to this business. Unknown body fields are rejected.

**Access:** Master key, or a personal key held by an owner/admin or accountant. The business must have the invoices service. Runs as the holder under their row-level security.

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `customerId` | string (uuid) | no | Customer ID. |

**JSON body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `hourlyRate` | number | yes | Nonnegative hourly rate in the profile's currency, or null for project rates. |
| `clientNames` | array | yes | Time client names. |
| `projectIds` | array | yes | Project UUIDs in this business. |
| `groupBy` | string | yes | How to group invoice lines. One of: `project`, `project_task`, `single`. |
| `cadenceDays` | integer | yes | Days per period, 7–31. |
| `currency` | string | no | Currency this customer is billed in; kept when omitted, TTD for a new profile. |
| `exchangeRate` | number | no | Default TTD for one unit of the currency, used for drafts from time; null clears it. Required for a foreign currency when drafting. |
| `conversionFeePercent` | number | no | Default conversion fee percentage for this customer's foreign-currency drafts; null for none. |
| `conversionFeeTtd` | number | no | Default fixed conversion fee in TTD for this customer's foreign-currency drafts; null for none. |
| `invoiceNumberPrefix` | string | no | Up to 5 letters or digits. Posted invoices with a service period are numbered PREFIX-YYYYMMDD-MMDD (a letter is added for a second invoice for the same period); null returns to INV- numbers. |

**Example request**

```bash
curl -X PUT "https://paysuitett.com/api/v1/billing-profiles/a1b2c3d4-e5f6-4789-90ab-cdef12345678" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"hourlyRate":150,"clientNames":["Agency"],"projectIds":[],"groupBy":"project","cadenceDays":14,"currency":"USD","exchangeRate":6.78,"invoiceNumberPrefix":"ACME"}'
```

**Response** (200)

```json
{
  "profile": {
    "customerId": "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b",
    "customerName": "Customer",
    "hourlyRate": 150,
    "clientNames": [
      "Agency"
    ],
    "projectIds": [],
    "groupBy": "project",
    "cadenceDays": 14,
    "currencyCode": "TTD",
    "exchangeRate": null,
    "invoiceNumberPrefix": null,
    "lastServiceEnd": "2026-09-30",
    "unbilledHours": 2,
    "earliestUnbilled": "2026-10-01"
  }
}
```

### Delete a billing profile

`DELETE https://paysuitett.com/api/v1/billing-profiles/{customerId}`

Removes the profile without changing invoices or time entries.

**Access:** Master key, or a personal key held by an owner/admin or accountant. The business must have the invoices service. Runs as the holder under their row-level security.

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `customerId` | string (uuid) | no | Customer ID. |

**Example request**

```bash
curl -X DELETE "https://paysuitett.com/api/v1/billing-profiles/a1b2c3d4-e5f6-4789-90ab-cdef12345678" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY"
```

**Response** (200)

```json
{
  "deleted": true
}
```

### Preview time to bill

`POST https://paysuitett.com/api/v1/billing/preview`

Approved and awaiting-approval billable time, excluding linked entries. Missing fields use the customer's profile. Missing dates use its next service period; 409 NOTHING_TO_BILL when no period is available. Amount is tracked hours × rate, rounded to two decimals, or zero for a missing rate or currency mismatch. Unknown fields are rejected.

**Access:** Master key, or a personal key held by an owner/admin or accountant. The business must have the invoices service. Runs as the holder under their row-level security.

**JSON body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `customerId` | string (uuid) | no | Customer whose billing profile supplies missing fields. |
| `clientNames` | array | no | Time client names; defaults to the profile's names. |
| `projectIds` | array | no | Time project UUIDs; defaults to the profile's projects. A client OR project match includes time. |
| `start` | string (date) | no | First service date; defaults to nextServicePeriod from the customer's profile. |
| `end` | string (date) | no | Last service date, inclusive; defaults to nextServicePeriod (no later than yesterday). |
| `groupBy` | string | no | Defaults to the profile's grouping, then project. One of: `project`, `project_task`, `single`. |
| `rateSource` | string | no | Defaults to customer when a rate is known, otherwise project. One of: `customer`, `project`. |
| `rate` | number | no | Nonnegative hourly rate in the invoice currency; defaults to the customer's profile rate. |
| `currency` | string | no | Invoice currency (three letters); defaults to the profile's, then TTD. Project rates in another currency are flagged currencyMismatch. |
| `invoiceId` | string (uuid) | no | Current invoice, whose existing links are excluded without counting as billed elsewhere. |

**Example request**

```bash
curl -X POST "https://paysuitett.com/api/v1/billing/preview" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"customerId":"4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b"}'
```

**Response** (200)

```json
{
  "period": {
    "start": "2026-10-01",
    "end": "2026-10-14",
    "text": "1–14 Oct 2026"
  },
  "rate": 150,
  "rateSource": "customer",
  "groupBy": "project",
  "lines": [
    {
      "key": "p:a1b2c3d4-e5f6-4789-90ab-cdef12345678",
      "title": "Development",
      "needsInvoiceName": false,
      "projectIds": [
        "a1b2c3d4-e5f6-4789-90ab-cdef12345678"
      ],
      "trackedHours": 2,
      "unapprovedHours": 0,
      "rate": 150,
      "currencyMismatch": false,
      "entryIds": [
        "7b8f0c2e-3c4d-4a51-9a0e-2f1b6c9d8e71"
      ],
      "taskTitles": [
        "Updated checkout"
      ],
      "plainDescription": "Development: Updated checkout",
      "amount": 300
    }
  ],
  "totals": {
    "trackedHours": 2,
    "amount": 300
  },
  "unapprovedHours": 0,
  "alreadyBilledHours": 0,
  "alreadyBilledOn": [],
  "currency": "TTD",
  "exchangeRate": null,
  "invoiceNumberPrefix": null
}
```

### Write time line descriptions

`POST https://paysuitett.com/api/v1/billing/descriptions`

Uses the same avoid-names list and optional AI writer as the app. Without a server Claude key, enabled is false and every line receives its plain fallback. Unknown fields are rejected.

**Access:** Master key, or a personal key held by an owner/admin or accountant. The business must have the invoices service. Runs as the holder under their row-level security.

**JSON body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `period` | string | yes | Service period text. |
| `clientNames` | array | no | Internal client names to avoid. |
| `lines` | array | yes | Up to 100 {key, title, hours, taskTitles} objects; up to 12 task titles each. |

**Example request**

```bash
curl -X POST "https://paysuitett.com/api/v1/billing/descriptions" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"period":"1–14 Oct 2026","lines":[{"key":"all","title":"Development","hours":2,"taskTitles":["Updated checkout"]}]}'
```

**Response** (200)

```json
{
  "descriptions": {
    "all": {
      "description": "Development: Updated checkout",
      "fallback": true
    }
  },
  "enabled": false
}
```

### List invoices

`GET https://paysuitett.com/api/v1/invoices`

Newest issue dates first. Invoice objects omit lines; money is in the invoice's currency (books holds the TTD amounts), with two decimal precision. paid excludes reversed payments; balanceDue subtracts posted credits and is zero for drafts/voids. URLs point to this installation's APP_URL host.

**Access:** Master key, or a personal key held by an owner/admin or accountant. The business must have the invoices service. Runs as the holder under their row-level security.

**Query parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | string | no | Filter status. One of: `draft`, `posted`, `void`. |
| `customerId` | string (uuid) | no | Filter customer. |
| `from` | string (date) | no | First issue date, inclusive. |
| `to` | string (date) | no | Last issue date, inclusive. |
| `limit` | integer | no | Default 50, maximum 200. |

**Example request**

```bash
curl "https://paysuitett.com/api/v1/invoices" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY"
```

**Response** (200)

```json
{
  "invoices": [
    {
      "id": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
      "number": "INV-DRAFT",
      "numberEnteredManually": false,
      "status": "draft",
      "currency": "TTD",
      "exchangeRate": null,
      "customer": {
        "id": "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b",
        "name": "Customer",
        "email": "customer@example.com"
      },
      "issueDate": "2026-10-15",
      "dueDate": "2026-11-14",
      "servicePeriod": {
        "start": "2026-10-01",
        "end": "2026-10-14",
        "text": "1–14 Oct 2026"
      },
      "qtyLabel": "hours",
      "notes": null,
      "subtotal": 300,
      "vatTotal": 37.5,
      "total": 337.5,
      "books": {
        "currency": "TTD",
        "subtotal": 300,
        "vatTotal": 37.5,
        "total": 337.5,
        "conversionFeePercent": null,
        "conversionFeeTtd": null,
        "totalTyped": false
      },
      "paid": 0,
      "balanceDue": 0,
      "urls": {
        "app": "https://paysuitett.com/admin/sales/invoices/6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
        "pdf": "https://paysuitett.com/admin/sales/invoices/6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d/pdf",
        "timeSchedule": "https://paysuitett.com/admin/sales/invoices/6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d/time-schedule",
        "workReport": "https://paysuitett.com/admin/sales/invoices/6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d/work-report"
      }
    }
  ]
}
```

### Read an invoice

`GET https://paysuitett.com/api/v1/invoices/{id}`

Includes ordered lines, linked tracked hours and entry counts. A draft's number is INV-DRAFT until it is posted, unless one was typed (numberEnteredManually). timeSchedule and workReport are null without time links; all URLs are absolute app links on APP_URL.

**Access:** Master key, or a personal key held by an owner/admin or accountant. The business must have the invoices service. Runs as the holder under their row-level security.

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | no | Invoice ID. |

**Example request**

```bash
curl "https://paysuitett.com/api/v1/invoices/a1b2c3d4-e5f6-4789-90ab-cdef12345678" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY"
```

**Response** (200)

```json
{
  "invoice": {
    "id": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
    "number": "INV-DRAFT",
    "numberEnteredManually": false,
    "status": "draft",
    "currency": "TTD",
    "exchangeRate": null,
    "customer": {
      "id": "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b",
      "name": "Customer",
      "email": "customer@example.com"
    },
    "issueDate": "2026-10-15",
    "dueDate": "2026-11-14",
    "servicePeriod": {
      "start": "2026-10-01",
      "end": "2026-10-14",
      "text": "1–14 Oct 2026"
    },
    "qtyLabel": "hours",
    "notes": null,
    "subtotal": 300,
    "vatTotal": 37.5,
    "total": 337.5,
    "books": {
      "currency": "TTD",
      "subtotal": 300,
      "vatTotal": 37.5,
      "total": 337.5,
      "conversionFeePercent": null,
      "conversionFeeTtd": null,
      "totalTyped": false
    },
    "paid": 0,
    "balanceDue": 0,
    "lines": [
      {
        "position": 0,
        "section": null,
        "description": "Development: Updated checkout",
        "qty": 2,
        "unitPrice": 150,
        "taxTreatment": "standard",
        "vatRate": 0.125,
        "net": 300,
        "vat": 37.5,
        "gross": 337.5,
        "timeSourceKey": "p:a1b2c3d4-e5f6-4789-90ab-cdef12345678",
        "trackedHours": 2,
        "timeEntryCount": 1
      }
    ],
    "urls": {
      "app": "https://paysuitett.com/admin/sales/invoices/6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
      "pdf": "https://paysuitett.com/admin/sales/invoices/6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d/pdf",
      "timeSchedule": "https://paysuitett.com/admin/sales/invoices/6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d/time-schedule",
      "workReport": "https://paysuitett.com/admin/sales/invoices/6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d/work-report"
    }
  }
}
```

### Create a draft invoice

`POST https://paysuitett.com/api/v1/invoices`

Computes amounts and VAT server-side, then saves via save_invoice_draft. Emailing, payments and voiding stay in the app. Unknown fields are rejected.

**Access:** Master key, or a personal key held by an owner/admin or accountant. The business must have the invoices service. Runs as the holder under their row-level security.

**JSON body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `customerId` | string (uuid) | yes | Customer in this business. |
| `issueDate` | string (date) | no | Defaults to today's business date on creation. |
| `dueDate` | string (date) | no | Defaults to issue date plus 30 days on creation; cannot precede issue date. |
| `servicePeriod` | object | no | {start, end}, YYYY-MM-DD, start ≤ end; null clears it. |
| `qtyLabel` | string | no | Quantity label; defaults to qty. One of: `qty`, `hours`. |
| `notes` | string | no | Invoice notes; empty string clears them. |
| `currency` | string | no | Currency the customer is billed in (TTD, USD, ...); defaults to TTD, or the draft's. Line prices are in it. |
| `exchangeRate` | number | no | TTD for one unit of a foreign currency, required with it (kept on PATCH when the currency stays). Each line's TTD amount, used in the books, is its amount × rate rounded to cents. |
| `invoiceNumber` | string | no | Drafts: a number to use instead of the next in sequence, up to 30 characters, unique in the business ignoring case (voided numbers included). It locks when the invoice is posted. Blank or null returns to the automatic number. Owner/Admin or Accountant only. |
| `conversionFeePercent` | number | no | Foreign invoices: percentage taken off the TTD value booked (0 to under 100); null for none. On PATCH, omit all three books fields to keep the draft's. |
| `conversionFeeTtd` | number | no | Foreign invoices: fixed TTD amount taken off the TTD value booked, after the percentage. |
| `booksTotal` | number | no | Foreign invoices: the TTD total to book, typed instead of worked out (fees are then ignored). The document amounts don't change; the TTD amount is spread over the lines in proportion. |
| `lines` | array | yes | Replaces all lines: {description, qty, unitPrice, section?, taxTreatment?, vatRatePercent?, timeSourceKey?, timeEntryIds?}. VAT defaults to the business's setup. taxTreatment: standard\|zero_rated\|exempt\|out_of_scope; vatRatePercent: 0–25 (12.5 means 12.5%). Nonnegative quantities (up to four decimals, so exact tracked hours bill to the cent) and prices in the invoice currency. Time entry IDs are UUIDs; save_invoice_draft enforces ownership, hour caps, links and order. |

**Example request**

```bash
curl -X POST "https://paysuitett.com/api/v1/invoices" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"customerId":"4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b","currency":"USD","exchangeRate":6.78,"lines":[{"description":"Development","qty":2,"unitPrice":150}]}'
```

**Response** (201)

```json
{
  "invoice": {
    "id": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
    "number": "INV-DRAFT",
    "numberEnteredManually": false,
    "status": "draft",
    "currency": "TTD",
    "exchangeRate": null,
    "customer": {
      "id": "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b",
      "name": "Customer",
      "email": "customer@example.com"
    },
    "issueDate": "2026-10-15",
    "dueDate": "2026-11-14",
    "servicePeriod": {
      "start": "2026-10-01",
      "end": "2026-10-14",
      "text": "1–14 Oct 2026"
    },
    "qtyLabel": "hours",
    "notes": null,
    "subtotal": 300,
    "vatTotal": 37.5,
    "total": 337.5,
    "books": {
      "currency": "TTD",
      "subtotal": 300,
      "vatTotal": 37.5,
      "total": 337.5,
      "conversionFeePercent": null,
      "conversionFeeTtd": null,
      "totalTyped": false
    },
    "paid": 0,
    "balanceDue": 0,
    "lines": [
      {
        "position": 0,
        "section": null,
        "description": "Development: Updated checkout",
        "qty": 2,
        "unitPrice": 150,
        "taxTreatment": "standard",
        "vatRate": 0.125,
        "net": 300,
        "vat": 37.5,
        "gross": 337.5,
        "timeSourceKey": "p:a1b2c3d4-e5f6-4789-90ab-cdef12345678",
        "trackedHours": 2,
        "timeEntryCount": 1
      }
    ],
    "urls": {
      "app": "https://paysuitett.com/admin/sales/invoices/6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
      "pdf": "https://paysuitett.com/admin/sales/invoices/6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d/pdf",
      "timeSchedule": "https://paysuitett.com/admin/sales/invoices/6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d/time-schedule",
      "workReport": "https://paysuitett.com/admin/sales/invoices/6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d/work-report"
    }
  }
}
```

### Update a draft invoice

`PATCH https://paysuitett.com/api/v1/invoices/{id}`

All fields are optional. Sending lines replaces every line. Omitting lines preserves and re-sends existing lines and their time links unchanged. Non-drafts return 409 CONFLICT. Unknown fields are rejected.

**Access:** Master key, or a personal key held by an owner/admin or accountant. The business must have the invoices service. Runs as the holder under their row-level security.

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | no | Invoice ID. |

**JSON body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `customerId` | string (uuid) | no | Customer in this business. |
| `issueDate` | string (date) | no | Defaults to today's business date on creation. |
| `dueDate` | string (date) | no | Defaults to issue date plus 30 days on creation; cannot precede issue date. |
| `servicePeriod` | object | no | {start, end}, YYYY-MM-DD, start ≤ end; null clears it. |
| `qtyLabel` | string | no | Quantity label; defaults to qty. One of: `qty`, `hours`. |
| `notes` | string | no | Invoice notes; empty string clears them. |
| `currency` | string | no | Currency the customer is billed in (TTD, USD, ...); defaults to TTD, or the draft's. Line prices are in it. |
| `exchangeRate` | number | no | TTD for one unit of a foreign currency, required with it (kept on PATCH when the currency stays). Each line's TTD amount, used in the books, is its amount × rate rounded to cents. |
| `invoiceNumber` | string | no | Drafts: a number to use instead of the next in sequence, up to 30 characters, unique in the business ignoring case (voided numbers included). It locks when the invoice is posted. Blank or null returns to the automatic number. Owner/Admin or Accountant only. |
| `conversionFeePercent` | number | no | Foreign invoices: percentage taken off the TTD value booked (0 to under 100); null for none. On PATCH, omit all three books fields to keep the draft's. |
| `conversionFeeTtd` | number | no | Foreign invoices: fixed TTD amount taken off the TTD value booked, after the percentage. |
| `booksTotal` | number | no | Foreign invoices: the TTD total to book, typed instead of worked out (fees are then ignored). The document amounts don't change; the TTD amount is spread over the lines in proportion. |
| `lines` | array | no | Replaces all lines: {description, qty, unitPrice, section?, taxTreatment?, vatRatePercent?, timeSourceKey?, timeEntryIds?}. VAT defaults to the business's setup. taxTreatment: standard\|zero_rated\|exempt\|out_of_scope; vatRatePercent: 0–25 (12.5 means 12.5%). Nonnegative quantities (up to four decimals, so exact tracked hours bill to the cent) and prices in the invoice currency. Time entry IDs are UUIDs; save_invoice_draft enforces ownership, hour caps, links and order. |

**Example request**

```bash
curl -X PATCH "https://paysuitett.com/api/v1/invoices/a1b2c3d4-e5f6-4789-90ab-cdef12345678" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"customerId":"4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b","currency":"USD","exchangeRate":6.78,"lines":[{"description":"Development","qty":2,"unitPrice":150}]}'
```

**Response** (200)

```json
{
  "invoice": {
    "id": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
    "number": "INV-DRAFT",
    "numberEnteredManually": false,
    "status": "draft",
    "currency": "TTD",
    "exchangeRate": null,
    "customer": {
      "id": "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b",
      "name": "Customer",
      "email": "customer@example.com"
    },
    "issueDate": "2026-10-15",
    "dueDate": "2026-11-14",
    "servicePeriod": {
      "start": "2026-10-01",
      "end": "2026-10-14",
      "text": "1–14 Oct 2026"
    },
    "qtyLabel": "hours",
    "notes": null,
    "subtotal": 300,
    "vatTotal": 37.5,
    "total": 337.5,
    "books": {
      "currency": "TTD",
      "subtotal": 300,
      "vatTotal": 37.5,
      "total": 337.5,
      "conversionFeePercent": null,
      "conversionFeeTtd": null,
      "totalTyped": false
    },
    "paid": 0,
    "balanceDue": 0,
    "lines": [
      {
        "position": 0,
        "section": null,
        "description": "Development: Updated checkout",
        "qty": 2,
        "unitPrice": 150,
        "taxTreatment": "standard",
        "vatRate": 0.125,
        "net": 300,
        "vat": 37.5,
        "gross": 337.5,
        "timeSourceKey": "p:a1b2c3d4-e5f6-4789-90ab-cdef12345678",
        "trackedHours": 2,
        "timeEntryCount": 1
      }
    ],
    "urls": {
      "app": "https://paysuitett.com/admin/sales/invoices/6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
      "pdf": "https://paysuitett.com/admin/sales/invoices/6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d/pdf",
      "timeSchedule": "https://paysuitett.com/admin/sales/invoices/6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d/time-schedule",
      "workReport": "https://paysuitett.com/admin/sales/invoices/6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d/work-report"
    }
  }
}
```

### Create a draft from tracked time

`POST https://paysuitett.com/api/v1/invoices/from-time`

Runs the preview with the customer's profile, then creates hours lines at exact tracked hours in the profile's currency and sets the service period. Skips missing rates and currency mismatches; 409 NOTHING_TO_BILL when no lines remain. Default billed hours are tracked hours; overrides cannot exceed tracked hours. Unknown fields are rejected.

**Access:** Master key, or a personal key held by an owner/admin or accountant. The business must have the invoices service. Runs as the holder under their row-level security.

**JSON body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `customerId` | string (uuid) | yes | Customer whose billing profile supplies missing fields. |
| `start` | string (date) | no | First service date; defaults to nextServicePeriod from the customer's profile. |
| `end` | string (date) | no | Last service date, inclusive; defaults to nextServicePeriod (no later than yesterday). |
| `groupBy` | string | no | Defaults to the profile's grouping, then project. One of: `project`, `project_task`, `single`. |
| `rateSource` | string | no | Defaults to customer when a rate is known, otherwise project. One of: `customer`, `project`. |
| `rate` | number | no | Nonnegative hourly rate in the invoice currency; defaults to the customer's profile rate. |
| `currency` | string | no | Invoice currency (three letters); defaults to the profile's, then TTD. Project rates in another currency are flagged currencyMismatch. |
| `section` | string | no | Section on every generated line. |
| `billedHours` | object | no | Map of preview line keys to billed hours (nonnegative, up to four decimals, no more than tracked hours). |
| `exchangeRate` | number | no | TTD for one unit of the invoice currency; defaults to the customer's profile rate. |
| `conversionFeePercent` | number | no | Foreign invoices: percentage taken off the TTD value booked (0 to under 100); null for none. On PATCH, omit all three books fields to keep the draft's. Defaults to the customer's profile fees. |
| `conversionFeeTtd` | number | no | Foreign invoices: fixed TTD amount taken off the TTD value booked, after the percentage. Defaults to the customer's profile fees. |
| `booksTotal` | number | no | Foreign invoices: the TTD total to book, typed instead of worked out (fees are then ignored). The document amounts don't change; the TTD amount is spread over the lines in proportion. Defaults to the customer's profile fees. |
| `invoiceNumber` | string | no | Drafts: a number to use instead of the next in sequence, up to 30 characters, unique in the business ignoring case (voided numbers included). It locks when the invoice is posted. Blank or null returns to the automatic number. Owner/Admin or Accountant only. |
| `taxTreatment` | string | no | VAT treatment for every line, such as zero_rated for exported services; defaults to the business's VAT setup. One of: `standard`, `zero_rated`, `exempt`, `out_of_scope`. |
| `descriptions` | object | no | Map of preview line keys to descriptions; otherwise plainDescription. |
| `issueDate` | string (date) | no | Defaults to today's business date on creation. |
| `dueDate` | string (date) | no | Defaults to issue date plus 30 days on creation; cannot precede issue date. |
| `notes` | string | no | Invoice notes; empty string clears them. |

**Example request**

```bash
curl -X POST "https://paysuitett.com/api/v1/invoices/from-time" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"customerId":"4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b"}'
```

**Response** (201)

```json
{
  "invoice": {
    "id": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
    "number": "INV-DRAFT",
    "numberEnteredManually": false,
    "status": "draft",
    "currency": "TTD",
    "exchangeRate": null,
    "customer": {
      "id": "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b",
      "name": "Customer",
      "email": "customer@example.com"
    },
    "issueDate": "2026-10-15",
    "dueDate": "2026-11-14",
    "servicePeriod": {
      "start": "2026-10-01",
      "end": "2026-10-14",
      "text": "1–14 Oct 2026"
    },
    "qtyLabel": "hours",
    "notes": null,
    "subtotal": 300,
    "vatTotal": 37.5,
    "total": 337.5,
    "books": {
      "currency": "TTD",
      "subtotal": 300,
      "vatTotal": 37.5,
      "total": 337.5,
      "conversionFeePercent": null,
      "conversionFeeTtd": null,
      "totalTyped": false
    },
    "paid": 0,
    "balanceDue": 0,
    "lines": [
      {
        "position": 0,
        "section": null,
        "description": "Development: Updated checkout",
        "qty": 2,
        "unitPrice": 150,
        "taxTreatment": "standard",
        "vatRate": 0.125,
        "net": 300,
        "vat": 37.5,
        "gross": 337.5,
        "timeSourceKey": "p:a1b2c3d4-e5f6-4789-90ab-cdef12345678",
        "trackedHours": 2,
        "timeEntryCount": 1
      }
    ],
    "urls": {
      "app": "https://paysuitett.com/admin/sales/invoices/6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
      "pdf": "https://paysuitett.com/admin/sales/invoices/6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d/pdf",
      "timeSchedule": "https://paysuitett.com/admin/sales/invoices/6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d/time-schedule",
      "workReport": "https://paysuitett.com/admin/sales/invoices/6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d/work-report"
    }
  },
  "preview": {
    "period": {
      "start": "2026-10-01",
      "end": "2026-10-14",
      "text": "1–14 Oct 2026"
    },
    "rate": 150,
    "rateSource": "customer",
    "groupBy": "project",
    "lines": [
      {
        "key": "p:a1b2c3d4-e5f6-4789-90ab-cdef12345678",
        "title": "Development",
        "needsInvoiceName": false,
        "projectIds": [
          "a1b2c3d4-e5f6-4789-90ab-cdef12345678"
        ],
        "trackedHours": 2,
        "unapprovedHours": 0,
        "rate": 150,
        "currencyMismatch": false,
        "entryIds": [
          "7b8f0c2e-3c4d-4a51-9a0e-2f1b6c9d8e71"
        ],
        "taskTitles": [
          "Updated checkout"
        ],
        "plainDescription": "Development: Updated checkout",
        "amount": 300
      }
    ],
    "totals": {
      "trackedHours": 2,
      "amount": 300
    },
    "unapprovedHours": 0,
    "alreadyBilledHours": 0,
    "alreadyBilledOn": [],
    "currency": "TTD",
    "exchangeRate": null,
    "invoiceNumberPrefix": null
  }
}
```

### Post a draft invoice

`POST https://paysuitett.com/api/v1/invoices/{id}/post`

Posts the draft to the books (a journal in TTD) and assigns its number: PREFIX-YYYYMMDD-MMDD when the customer's profile has a prefix and the invoice a service period, else INV-YYYY-NNNNN. Posting a posted invoice returns it unchanged; void returns 409 CONFLICT. Emailing stays in the app.

**Access:** Master key, or a personal key held by an owner/admin or accountant. The business must have the invoices service. Runs as the holder under their row-level security.

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | no | Invoice ID. |

**Example request**

```bash
curl -X POST "https://paysuitett.com/api/v1/invoices/a1b2c3d4-e5f6-4789-90ab-cdef12345678/post" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY"
```

**Response** (200)

```json
{
  "invoice": {
    "id": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
    "number": "INV-2026-00042",
    "numberEnteredManually": false,
    "status": "posted",
    "currency": "TTD",
    "exchangeRate": null,
    "customer": {
      "id": "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b",
      "name": "Customer",
      "email": "customer@example.com"
    },
    "issueDate": "2026-10-15",
    "dueDate": "2026-11-14",
    "servicePeriod": {
      "start": "2026-10-01",
      "end": "2026-10-14",
      "text": "1–14 Oct 2026"
    },
    "qtyLabel": "hours",
    "notes": null,
    "subtotal": 300,
    "vatTotal": 37.5,
    "total": 337.5,
    "books": {
      "currency": "TTD",
      "subtotal": 300,
      "vatTotal": 37.5,
      "total": 337.5,
      "conversionFeePercent": null,
      "conversionFeeTtd": null,
      "totalTyped": false
    },
    "paid": 0,
    "balanceDue": 337.5,
    "lines": [
      {
        "position": 0,
        "section": null,
        "description": "Development: Updated checkout",
        "qty": 2,
        "unitPrice": 150,
        "taxTreatment": "standard",
        "vatRate": 0.125,
        "net": 300,
        "vat": 37.5,
        "gross": 337.5,
        "timeSourceKey": "p:a1b2c3d4-e5f6-4789-90ab-cdef12345678",
        "trackedHours": 2,
        "timeEntryCount": 1
      }
    ],
    "urls": {
      "app": "https://paysuitett.com/admin/sales/invoices/6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
      "pdf": "https://paysuitett.com/admin/sales/invoices/6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d/pdf",
      "timeSchedule": "https://paysuitett.com/admin/sales/invoices/6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d/time-schedule",
      "workReport": "https://paysuitett.com/admin/sales/invoices/6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d/work-report"
    }
  }
}
```

### Download invoice PDF

`GET https://paysuitett.com/api/v1/invoices/{id}/pdf`

Returns application/pdf bytes using the app's renderer. with=work-report appends the work report, one PDF.

**Access:** Master key, or a personal key held by an owner/admin or accountant. The business must have the invoices service. Runs as the holder under their row-level security.

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | no | Invoice ID. |

**Query parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `with` | string | no | Append the work report. One of: `work-report`. |

**Example request**

```bash
curl "https://paysuitett.com/api/v1/invoices/a1b2c3d4-e5f6-4789-90ab-cdef12345678/pdf" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY"
```

**Response** (200)

Binary application/pdf bytes.

### Download time schedule PDF

`GET https://paysuitett.com/api/v1/invoices/{id}/time-schedule`

Hours per day per line. 404 without time links.

**Access:** Master key, or a personal key held by an owner/admin or accountant. The business must have the invoices service. Runs as the holder under their row-level security.

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | no | Invoice ID. |

**Example request**

```bash
curl "https://paysuitett.com/api/v1/invoices/a1b2c3d4-e5f6-4789-90ab-cdef12345678/time-schedule" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY"
```

**Response** (200)

Binary application/pdf bytes.

### Download work report PDF

`GET https://paysuitett.com/api/v1/invoices/{id}/work-report`

The Time Reports summary for exactly the time the invoice bills: hours per day and each project's time-entry descriptions, under invoice names. Amounts at the invoice's rates and currency, shown only when every line bills all its tracked time. 404 without time links.

**Access:** Master key, or a personal key held by an owner/admin or accountant. The business must have the invoices service. Runs as the holder under their row-level security.

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | no | Invoice ID. |

**Example request**

```bash
curl "https://paysuitett.com/api/v1/invoices/a1b2c3d4-e5f6-4789-90ab-cdef12345678/work-report" \
  -H "Authorization: Bearer $PAYSUITETT_API_KEY"
```

**Response** (200)

Binary application/pdf bytes.
