# Work registration

> Absolute-set hours and completion into the recognition engine.

Source: https://invoia.io/developers/api/work-registration

Registering work is how you tell Invoia's recognition engine "here is how
far along a given line of a given invoice is." Hours + completion feed
the same earned / billed / deferred / WIP figures the UI shows.

## The endpoint [#the-endpoint]

```
PUT /api/v1/work-registrations
```

There is exactly one work-registration endpoint. It is a single-item PUT
(no batch), and it is **absolute** — you send the totals for a
`(line, user)` pair, not deltas.

## Absolute-set semantics [#absolute-set-semantics]

Send the **total hours** the user has logged against the line, and their
**completion rate** (0–1), and Invoia computes the internal delta for you.

```bash
curl -X PUT https://invoia.io/api/v1/work-registrations \
  -H "Authorization: Bearer $INVOIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "series": "ext:clickup:acme-retainer",
    "occurrence": "2026-07-01",
    "line": "5f8e…",
    "user": "email:jane@acme.example",
    "hours": 12,
    "completion_rate": 0.6
  }'
```

Meaning: "Jane has 12 hours total on this line; her part is 60% done."

Repeat calls are idempotent by construction — you never compute deltas,
which means a retried sync is safe with no separate idempotency key.
Both `hours` and `completion_rate` are optional; you can send one, the
other, or both.

## Naming the occurrence (Option B) [#naming-the-occurrence-option-b]

You always name the exact occurrence:

* **Series + period** — `series: <ref>, occurrence: <YYYY-MM-DD>`. The
  `YYYY-MM-DD` is the first day of the billing period (e.g. `2026-07-01`
  for the July invoice). Virtual and materialized states are hidden from
  you — Invoia materializes on demand.
* **One-time invoice** — `invoice: <ref>`. No occurrence needed.

Exactly one of `{series, occurrence}` or `{invoice}` must be sent.

## Entry-month bucketing [#entry-month-bucketing]

Every registration lands in the **month it was made** (the "entry
month"), not the month you sent as the occurrence. This mirrors the
UI's behaviour and prevents accidental back-dating of ledger figures.

A negative delta — say Jane's total drops from 12 hours to 10 —
draws down newest-first across earlier month buckets, so you never
break a closed accounting period from a nightly catch-up sync.

## Naming the user [#naming-the-user]

The `user` field accepts the same three refs as everywhere else:

* `ext:your-id` — after you tag the user with `PATCH /users/{ref}`.
* `email:someone@example.com` — the user's login email.
* Raw UUID.

Users **cannot be created via the API** — invitations stay in the UI.
Read the roster with `GET /users` to see what's available.

## Typed 409 when no occurrence exists [#typed-409-when-no-occurrence-exists]

If you register work against a series past its horizon — a series that has
ended, or one whose end date was set by cancelling it — the API returns `409`
with a typed code:

```json
{
  "error": {
    "type": "conflict",
    "code": "OCCURRENCE_NOT_FOUND",
    "message": "This series has no current occurrence — extend or resume.",
    "request_id": "req_…"
  }
}
```

Codes you can branch on:

* `OCCURRENCE_NOT_FOUND` — the period is past the current horizon (the
  series has ended, or was cancelled as of an earlier date); extend the
  series, register against a new one, or wait for the cadence to reach it.
* `OCCURRENCE_SKIPPED` — the occurrence was explicitly skipped by an
  admin.
