Authenticating
Every request carries an integration key as a bearer token. Keys are generated by a signed-in person under Integration keys, belong to exactly one agency, and are shown once.
curl https://bookkept.co.uk/api/v1/placements \ -H "Authorization: Bearer bk_live_xxxxxxxxxxxxxxxxxxxx"
There is no tenant parameter anywhere in this API. The key establishes the agency, and no valid key can ask for another agency's data.
Scopes
Grants are per area and per level, written area:level and given as a space-separated list. Write implies read for the same area only.
| Area | Covers |
|---|---|
| placements | Placements, workers, clients, contacts, umbrella companies, the importer |
| timesheets | Weeks and entries, shifts, AWR absences, the approval chain |
| billing | Both invoice series, agreements, accounting exports, the quarterly return |
| compliance | KIDs, right to work, umbrella evidence, the change radar, evidence packs |
| jobs | Postings, applications, the talent pool, proposals |
Versioning
The published surface is /api/v1. Within a version, fields are added and never removed or repurposed, so a client that ignores what it does not recognise keeps working. A change that would break a parser gets a new version rather than a release note.
Every response carries X-API-Version.
Errors
Every failure returns a code and a message. The code is part of this contract and does not change; the message is written for a person and may be reworded, so branch on the code.
{ "code": "self_billing_agreement_missing",
"message": "No active self-billing agreement for this worker: Notice 700/62
requires a written agreement before self-billed invoices are raised" }
| Code | Status | Means |
|---|---|---|
| invalid_request | 400 | The body could not be read, or a field failed validation. |
| unauthenticated | 401 | No key, an unknown key, or a revoked one. |
| forbidden | 403 | A valid key without the grant this path needs. |
| not_found | 422 | Named a record that does not exist, or belongs to another agency. |
| umbrella_required | 422 | An umbrella engagement with no umbrella company named. |
| umbrella_not_permitted | 422 | A PSC or PAYE engagement carrying an umbrella link. |
| self_billing_agreement_missing | 422 | No live self-billing agreement for that supplier. |
| agency_vat_number_missing | 422 | Sending a client invoice before the agency VAT number is recorded. |
| timesheet_state | 422 | The week is not in a state that allows this. |
| conflict | 422 | The record already exists, or the write conflicts with one held. |
| idempotency_mismatch | 422 | An Idempotency-Key reused with a different body. |
| rate_limited | 429 | Too many requests on this key. Retry-After says how long. |
| rule_violation | 422 | A rule with no code of its own yet. The message says which. |
Retries and idempotency
Send an Idempotency-Key header on any write. A repeat of that key returns exactly what the first attempt produced, including the id it created, and carries Idempotent-Replay: true. The second write never happens.
curl -X POST https://bookkept.co.uk/api/v1/placements \
-H "Authorization: Bearer bk_live_…" \
-H "Idempotency-Key: 8f1c2b90-2c37-4a1e-9b7b-6e2a1f0d3c55" \
-H "Content-Type: application/json" \
-d '{ … }'
Only successful writes are remembered. A retry after a 500 or a 422 genuinely tries again, because the first attempt changed nothing.
Rate limits
Per key, per minute: 600 reads and 120 writes. Over either and the answer is 429 with Retry-After in seconds. Keyed by the key rather than by address, so one agency's traffic never limits another's.
Reading incrementally
Every list takes updatedSince and limit, so keeping in step costs one request rather than a full pull.
GET /api/v1/placements?updatedSince=2026-08-14T09:00:00Z&limit=200 X-Returned-Count: 200 X-Next-Updated-Since: 2026-08-14T11:42:07.318Z
Resume from X-Next-Updated-Since. The comparison is inclusive, so the boundary row comes back again rather than being lost: dedupe by id. A strictly-greater cursor silently drops every row sharing your last-seen instant, and two rows written in the same millisecond is not rare.
Without either parameter a list behaves as it always did: everything, in the order a person would want it.
Fetching one record
Every collection has GET /{id}, and the response carries an ETag. Send it back as If-None-Match and an unchanged record answers 304 with no body.
GET /api/v1/placements/0f2b… If-None-Match: "1786789255318" 304 Not Modified
Your own identifiers
Every record takes an optional externalRef: your identifier for the same thing. It is unique per agency, so two of your records can never map to one of ours, and it comes back on every response.
POST /api/v1/placements
{ "…": "…", "externalRef": "CRM-PLACEMENT-77" }
GET /api/v1/placements/by-reference/CRM-PLACEMENT-77
Reconciliation therefore needs no map on your side and no name matching on ours, which is the usual way two systems quietly end up with two of the same person.
| Status | Means |
|---|---|
| 400 | The body could not be read, or a field failed validation. The message names the field. |
| 401 | No key, an unknown key, or a revoked one. Says nothing about which. |
| 403 | A valid key without the grant. The message names the scope it needed. |
| 422 | A rule this system enforces. The request was well formed; resending it unchanged will not help. |
The 422s are the interesting ones, and they are the product working rather than failures to route around: an umbrella placement with no umbrella named, a self-billed invoice raised before its agreement exists, a client invoice sent before the agency VAT number is recorded, a week rebuilt after it left draft.
The book
POST /api/v1/directory/workers
Scope placements:write. Returns 201.
{ "firstName": "Nadia", "lastName": "Okafor", "email": "nadia@example.com" }
{ "id": "0f2b…", "firstName": "Nadia", "lastName": "Okafor",
"email": "nadia@example.com" }
GET /api/v1/directory/workers
Scope placements:read. Returns an array of the above, by surname.
POST /api/v1/directory/clients
Scope placements:write.
{ "name": "Northgate Trust" }
POST /api/v1/directory/contacts
Scope placements:write. A contact belongs to a client and is who a timesheet can be sent to for approval.
{ "clientId": "8a41…", "name": "Ruth Kelsey", "email": "ruth@northgate.example" }
POST /api/v1/directory/umbrellas
Scope placements:write. Registering an umbrella is audited, because the register is the joint and several liability evidence.
{ "name": "Brolly Pay Ltd" }
PATCH /api/v1/directory/umbrellas/{id}
Scope placements:write. Send only what changes. Both fields are optional.
{ "riskStatus": "AMBER", "onPsl": true }
riskStatus is GREEN, AMBER or RED. The change is audited with what it was and what it became, because the question is not what you think of an umbrella today but whether you can show you kept assessing it.
POST /api/v1/placements
Scope placements:write. Returns 201.
{ "workerId": "0f2b…",
"clientId": "8a41…",
"roleTitle": "Band 5 Nurse",
"engagementType": "UMBRELLA",
"umbrellaId": "c7d0…",
"startDate": "2026-09-07",
"endDate": null,
"payRatePence": 2200,
"chargeRatePence": 3100,
"rateUnit": "HOURLY" }
engagementType is PSC, UMBRELLA or PAYE. rateUnit is HOURLY or DAILY.
GET /api/v1/placements
Scope placements:read. The whole book, newest first.
GET /api/v1/placements/{id}
GET /api/v1/placements/by-reference/{externalRef}
Scope placements:read. One placement, with an ETag.
PATCH /api/v1/placements/{id}
Scope placements:write. Correcting a live assignment, or ending it. Every field optional; send only what changed.
{ "roleTitle": "Band 6 Nurse",
"endDate": "2026-12-19",
"payRatePence": 2400,
"chargeRatePence": 3300,
"externalRef": "CRM-PLACEMENT-77" }
PATCH /api/v1/directory/workers/{id}
PATCH /api/v1/directory/clients/{id}
PATCH /api/v1/directory/contacts/{id}
Scope placements:write. Same shape: every field optional, only what changed.
{ "lastName": "Okafor-Bello", "email": "nadia@example.com" }
Each collection also has GET /{id} with an ETag.
POST /api/v1/import/validate
Scope placements:write. A dry run: writes nothing, reports everything. Each field is the raw content of a CSV file, and every field is optional.
{ "clients": "Name\nNorthgate Trust\n",
"contacts": null,
"umbrellas": null,
"workers": "First Name,Surname,Email\nNadia,Okafor,nadia@example.com\n",
"placements": null }
{ "creates": { "workers": 1, "clients": 1 },
"updates": { },
"errors": [ { "file": "placements.csv", "row": 84,
"column": "start_date", "message": "…" } ],
"notes": [ { "file": "workers.csv",
"message": "Read the \"surname\" column as last_name." } ],
"valid": true }
POST /api/v1/import/execute
Scope placements:write. Same body. Writes the whole set or none of it.
{ "imported": { "workers": 1, "clients": 1 }, "updated": { },
"auditEventId": 4192 }
Time
POST /api/v1/timesheets
Scope timesheets:write. Creates a draft week.
{ "placementId": "b31c…",
"weekStart": "2026-09-07",
"entries": [
{ "workDate": "2026-09-07", "minutes": 450, "breakMinutes": 30,
"dayUnits": null, "notes": null },
{ "workDate": "2026-09-08", "minutes": 450, "breakMinutes": 30 }
] }
On an hourly placement send minutes; on a daily one send dayUnits between 0.5 and 1.0. The service decides which it needs from the placement, because a half day is a real thing and rounding it to hours invents a number nobody agreed.
POST /api/v1/timesheets/{id}/submit
Scope timesheets:write. Sends the week to a named approver at the client.
{ "clientContactId": "d902…" }
{ "sentTo": "ruth@northgate.example", "emailed": "true" }
GET /api/v1/timesheets?placementId=…
Scope timesheets:read. Every week on a placement.
GET /api/v1/timesheets/{id}/entries
Scope timesheets:read. The days behind a week.
GET /api/v1/timesheets/awr?placementId=…
Scope timesheets:read. The AWR position: qualifying weeks counted, what broke or paused the clock, and how close week 12 is.
POST /api/v1/shifts
Scope timesheets:write. Puts a shift to a worker. Hourly placements only.
{ "placementId": "b31c…",
"startsAt": "2026-09-07T20:00:00Z",
"endsAt": "2026-09-08T08:00:00Z",
"unpaidBreakMinutes": 60,
"location": "Ward 4",
"note": null }
The response carries workDate and paidMinutes computed on the server. A shift counts on the day it starts, so the night shift above is a 7 September shift. That decides its week, which decides its timesheet, which decides its AWR week.
POST /api/v1/shifts/{id}/worked
POST /api/v1/shifts/{id}/cancel
Scope timesheets:write. No body.
POST /api/v1/shifts/build-week
Scope timesheets:write. Turns worked shifts into that week's draft timesheet.
{ "placementId": "b31c…", "weekStart": "2026-09-07" }
It rebuilds rather than appends, so calling it twice does not double the hours, and it refuses with 422 once the sheet has left draft.
POST /api/v1/awr-absences
Scope timesheets:write. Records why a week is missing, so the clock does the right thing.
{ "placementId": "b31c…",
"reason": "SICKNESS",
"startsOn": "2026-09-14",
"endsOn": "2026-09-28",
"note": null }
reason is one of SICKNESS, JURY_SERVICE, SHUTDOWN, INDUSTRIAL_ACTION (these pause the clock), PREGNANCY_MATERNITY, FAMILY_LEAVE (these accrue towards the twelve) or OTHER (an ordinary gap). The response carries the effect it had and an effectExplained sentence.
Compliance
POST /api/v1/compliance/rtw
Scope compliance:write. Records a right-to-work check.
{ "workerId": "0f2b…",
"method": "ONLINE_SHARE_CODE",
"shareCode": "W12ABC456",
"recheckDue": "2027-04-13",
"evidenceContent": null,
"facialImageRecorded": null,
"supplyChainStatement": null }
method is MANUAL_DOCUMENT, ONLINE_SHARE_CODE or IDSP_DIGITAL. evidenceContent, where given, is stored as a document with a retention date. The last two fields are optional evidence rather than requirements; see right-to-work records for what SI 2026/700 does and does not ask for.
GET /api/v1/compliance/rtw?workerId=…
Scope compliance:read. Every check on a worker, newest first.
POST /api/v1/compliance/kid/{placementId}
Scope compliance:write. No body. Generates and issues the Key Information Document from the placement, and returns the document.
POST /api/v1/compliance/assignment-confirmation/{placementId}
Scope compliance:write. The regulation 21 confirmation. Held facts come from the record; these are the ones only the operator knows.
{ "location": "Northgate Hospital, Ward 4",
"hours": "Nights, 20:00 to 08:00, four on four off",
"healthSafety": "Trust induction on first shift",
"requirements": "NMC registration, enhanced DBS",
"expenses": "None" }
It refuses blank on location and hours, because a confirmation that does not say where or when is not a confirmation.
POST /api/v1/compliance/umbrellas/{umbrellaId}/evidence
Scope compliance:write. Files an assessment document against an umbrella.
{ "kind": "ACCREDITATION",
"note": "FCSA renewal",
"expiresAt": "2027-06-30",
"content": "…file contents…" }
kind is ACCREDITATION, PAYSLIP_SAMPLE, RTI_EVIDENCE, INSURANCE or CONTRACT.
GET /api/v1/compliance/jsl
Scope compliance:read. Every umbrella with its evidence position and the live placements running through it.
{ "asOf": "2026-08-14",
"note": "…",
"umbrellas": [
{ "umbrella": { "id": "c7d0…", "name": "Brolly Pay Ltd",
"riskStatus": "GREEN", "onPsl": true },
"evidenceCount": 3, "expiredEvidenceCount": 1,
"activePlacements": 4, "redFlag": false,
"amberNote": "1 of 3 evidence items have expired; refresh them to show the assessment is ongoing" } ] }
Red means no current assessment at all: live placements with no evidence, or an umbrella the agency itself marked red. Stale evidence raises the amber note instead, because a red that fires on every expiring certificate stops being read.
GET /api/v1/compliance/changes
Scope compliance:read. The change radar: each dated statutory change paired with this agency's own exposure to it, with the people named.
[ { "title": "Right-to-work checks change",
"effectiveFrom": "2026-10-01",
"daysAway": 48,
"sourceUrl": "…",
"summary": "…",
"exposure": { "what": "workers to re-check",
"why": "…",
"subjects": [ { "id": "0f2b…", "name": "Amara Osei",
"detail": "No check on file" } ] } } ]
GET /api/v1/compliance/evidence-pack/{placementId}
Scope compliance:read. The whole chain for one placement in one call: the placement, worker and client, the umbrella and its evidence, the right-to-work record, the KID, the assignment confirmation, the weeks with who approved them, and both invoice series.
GET /api/v1/compliance/documents/{id}
Scope compliance:read. Serves a stored document as an attachment, never as a page, with nosniff. Uploaded evidence is bytes somebody else chose.
Money
POST /api/v1/billing/agreements
Scope billing:write. The self-billing agreement that must exist before a worker can be self-billed.
{ "workerId": "0f2b…", "vatRegistered": true,
"vatNumber": "GB123456789", "agreedAt": "2026-08-01" }
POST /api/v1/billing/umbrella-agreements
Scope billing:write. The same for an umbrella company.
{ "umbrellaId": "c7d0…", "vatRegistered": true,
"vatNumber": "GB987654321", "agreedAt": "2026-08-01" }
POST /api/v1/billing/invoices/generate
Scope billing:write. Raises the self-billed purchase invoice from an approved week at the pay rate.
{ "timesheetId": "e55a…" }
POST /api/v1/billing/client-invoices
Scope billing:write. Raises the agency's own sales invoice from the same week at the charge rate.
{ "timesheetId": "e55a…" }
POST /api/v1/billing/client-invoices/{id}/sent
POST /api/v1/billing/client-invoices/{id}/paid
POST /api/v1/billing/client-invoices/{id}/void
Scope billing:write. No body. Sending refuses with 422 until the agency's VAT registration number is recorded, because VAT Regs 1995 reg 14(1) requires it on the invoice.
PUT /api/v1/billing/clients/{clientId}/payment-terms
Scope billing:write.
{ "days": 30 }
GET|PUT /api/v1/billing/agency-vat
Scope billing:read and billing:write.
{ "vatNumber": "GB123456789" }
GET /api/v1/billing/invoices
GET /api/v1/billing/client-invoices
Scope billing:read. Both ledgers. Overdue is derived from the due date and the state rather than stored, so an invoice cannot be paid and overdue at once.
GET /api/v1/billing/invoices/xero.csv
GET /api/v1/billing/client-invoices/xero.csv
GET /api/v1/reports/intermediaries.csv
Scope billing:read. CSV rather than JSON. The quarterly return is built from placements rather than invoices, because building it from invoices under-reports every quarter.
Webhooks
Registered from the desk under Integrations rather than over the API, for the same reason keys are: a leaked key that could add a webhook could forward an agency's whole event stream somewhere else.
Every compliance-relevant act sends one, because the event list is the audit list rather than a second list maintained beside it. A KID issued, a week approved, a right-to-work check recorded, umbrella evidence filed, an invoice raised, an umbrella's risk status changed.
POST https://your-endpoint.example/bookkept
X-BookKept-Event: KID_ISSUED
X-BookKept-Delivery: 9c1f…
X-BookKept-Timestamp: 1786789255
X-BookKept-Signature: sha256=…
{ "event": "KID_ISSUED",
"recordType": "document",
"recordId": "4a71…",
"occurredAt": "2026-08-14T11:42:07.318Z" }
Verify the signature. It is an HMAC-SHA256 over timestamp + "." + body using the secret shown when the webhook was registered. Reject a timestamp more than a few minutes old so a captured delivery cannot be replayed later.
Non-2xx or a timeout is retried six times, backing off 1, 4, 9, 16 and 25 minutes. Treat X-BookKept-Delivery as the deduplication key: at-least-once is the guarantee, not exactly-once. Every attempt is visible in the desk with its status, so a webhook that quietly stopped delivering is visible rather than assumed to be working.
URLs must be https and must not point at a private or loopback address.
What no key can reach
Four paths are refused to every key whatever its scopes, with 403 and the reason:
| Path | Why |
|---|---|
| /api/v1/platform | The operator plane. That is us running the platform, not you using it. |
| /api/v1/privacy, /api/v1/retention | Subject access and erasure are decisions a person answers for. A script running unattended is how an agency erases a record it was required to keep. |
| /api/v1/api-keys | Key management, so a leaked key cannot mint its own replacement. |
| /api/v1/connections | Data sources, because a key that could create one could feed the book from anywhere. |
| /api/v1/webhooks | Webhook registration, because a key that could add one could forward the whole event stream elsewhere. |
Anything not published above is refused too. A new endpoint has to be classified deliberately, so it can never quietly widen a key issued months ago.
The usual first integration
A key scoped placements:write timesheets:write compliance:read, a POST per new assignment, weeks pushed as they are approved, and the compliance position read back onto the dashboard your team already opens.