# Work registration

> Setting a person's hours and completion on a line, as totals.

Source: https://www.tabba.io/developers/api/work-registration

Registering work tells Tabba how far one person has come on one line of an
invoice. Hours and completion feed the earned, billed, deferred and
work-in-progress figures the workspace shows and the month-end accrual posts
from.

## The endpoint [#the-endpoint]

```
PUT /work-registrations
```

There is one work-registration endpoint. It sets one person's figures on one
line per request, and it is **absolute**: you state where the person stands,
not what changed.

## Totals, not increments [#totals-not-increments]

Send the **total hours** the person has on the line, and the **completion
rate** they stand at, as a fraction from `0` to `1`. Tabba reads what is
registered and registers the signed difference.

```bash
curl -X PUT https://www.tabba.io/api/v1/work-registrations \
  -H "Authorization: Bearer $TABBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "series": "ext:crm:acme-retainer",
    "occurrence": "2026-07-01",
    "line": "5f8e2c1a-3b4d-4e6f-8a9b-0c1d2e3f4a5b",
    "user": "email:maja@your-company.example",
    "hours": 12,
    "completion_rate": 0.6
  }'
```

This says that Maja has 12 hours in total on the line, and that her part is
60 % done.

```json
{
  "object": "work_registration",
  "invoice_id": "…",
  "invoice_line_id": "…",
  "user_id": "…",
  "accrual_month": "2026-07-01",
  "hours": 12,
  "hours_delta": 3,
  "completion_rate": 0.6,
  "external_id": null
}
```

`hours` is her total on the line after the request, and `hours_delta` is what
the request registered: `3` here, because 9 hours were registered before.
Sending the same figures again registers nothing and answers
`hours_delta: 0`, so a retried sync is safe with no idempotency key.

`hours` and `completion_rate` are each optional, and a request states at
least one. A figure left out, or sent as `null`, stays as it is.

> [!WARNING]
> **One request at a time per person and line**
>
> Two requests that set one person's hours on one line at the same moment both
> measure against the same total, and the difference can be registered twice.
> Send them one after the other.

## Naming the line [#naming-the-line]

A line is named on an invoice, or on an occurrence of a recurring series.
Send exactly one of the two forms:

* **On an invoice**: `invoice` and `line`, where `line` is the `id` of the
  invoice line.
* **On an occurrence**: `series`, `occurrence` and `line`. `occurrence` is the
  first day of the occurrence's billing period, `YYYY-MM-DD`, as
  `GET /recurring-series/{ref}/occurrences` states it. `line` is the `id` of
  the recurring line, the same for every occurrence, or the `line_id` of a
  one-time line as its occurrence states it.

`invoice` and `series` take a reference: the `id`, or `ext:` and your own
`external_id`. `line` is always an id.

An occurrence that is not an invoice yet is made one by the registration, as
when an admin registers work on it in the workspace. The answer's
`invoice_id` is that invoice.

## Naming the person [#naming-the-person]

`user` takes the same references as everywhere else:

* `email:` and the person's address.
* `ext:` and your own id, after you have tagged the person with
  `PATCH /users/{ref}`.
* The person's `id`.

People are not created through the API: they are invited in the workspace.
`GET /users` lists the people who can work in the key's company.

The person must hold a place on the line, as one of its `assignees`. A
registration for someone who does not is refused with `NOT_ASSIGNED` (`409`).
The registration is credited to the person it names.

## The entry month [#the-entry-month]

Every registration lands in the month the request arrives in, in the
Europe/Copenhagen time zone, never in a month you state. The answer's
`accrual_month` is that month's first day. There is one registration per
line, person and month, and it is the one the workspace reads.

A lower total is a reduction. It is drawn down across the person's months,
newest first.

## Refusals [#refusals]

| Code                      | Status | What happened                                                                    |
| ------------------------- | ------ | -------------------------------------------------------------------------------- |
| `NOT_ASSIGNED`            | `409`  | The person holds no place on the line                                            |
| `HOURS_BILLED_LOCKED`     | `409`  | The hours are of an hourly line that its invoice has billed                      |
| `LINE_NOT_FOUND`          | `404`  | The invoice or the occurrence has no such line                                   |
| `INVOICE_NOT_FOUND`       | `404`  | `invoice` names no invoice of the company                                        |
| `SERIES_NOT_FOUND`        | `404`  | `series` names no recurring series of the company                                |
| `OCCURRENCE_OUT_OF_RANGE` | `404`  | `occurrence` is not the first day of a billing period of the series              |
| `OCCURRENCE_SKIPPED`      | `409`  | An admin skipped the occurrence                                                  |
| `NOT_FOUND`               | `404`  | `user` names no person who can work in the company                               |
| `DUPLICATE_EXTERNAL_ID`   | `409`  | Another month's registration of the person on the line carries the `external_id` |
| `VALIDATION_FAILED`       | `422`  | See `field_errors`                                                               |

One request raises a person's total by at most 1,000 hours, a guard against a
slipped decimal. A larger rise is a `VALIDATION_FAILED` with
`HOURS_CHANGE_TOO_LARGE` under `hours`.
