Work registration
Setting a person's hours and completion on a line, as totals.
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
PUT /work-registrationsThere 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
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.
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.
{
"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.
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
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:
invoiceandline, wherelineis theidof the invoice line. - On an occurrence:
series,occurrenceandline.occurrenceis the first day of the occurrence's billing period,YYYY-MM-DD, asGET /recurring-series/{ref}/occurrencesstates it.lineis theidof the recurring line, the same for every occurrence, or theline_idof 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
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 withPATCH /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
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
| 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.