BookKeptevidence chain
Reference

API reference

Every published endpoint with the body it takes and the body it returns. JSON in, JSON out, over HTTPS. Dates are ISO-8601 (2026-09-07), timestamps are RFC-3339 in UTC, money is in pence as a whole number, and identifiers are UUIDs.

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.

If you have no developer, you do not need this page. A connection takes the export your current system already produces, on a schedule, with no code at all.

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.

AreaCovers
placementsPlacements, workers, clients, contacts, umbrella companies, the importer
timesheetsWeeks and entries, shifts, AWR absences, the approval chain
billingBoth invoice series, agreements, accounting exports, the quarterly return
complianceKIDs, right to work, umbrella evidence, the change radar, evidence packs
jobsPostings, 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" }
CodeStatusMeans
invalid_request400The body could not be read, or a field failed validation.
unauthenticated401No key, an unknown key, or a revoked one.
forbidden403A valid key without the grant this path needs.
not_found422Named a record that does not exist, or belongs to another agency.
umbrella_required422An umbrella engagement with no umbrella company named.
umbrella_not_permitted422A PSC or PAYE engagement carrying an umbrella link.
self_billing_agreement_missing422No live self-billing agreement for that supplier.
agency_vat_number_missing422Sending a client invoice before the agency VAT number is recorded.
timesheet_state422The week is not in a state that allows this.
conflict422The record already exists, or the write conflicts with one held.
idempotency_mismatch422An Idempotency-Key reused with a different body.
rate_limited429Too many requests on this key. Retry-After says how long.
rule_violation422A 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 '{ … }'
Reusing a key with a different body is refused with idempotency_mismatch. Answering it with the first record would tell you something exists that you never sent. Keys are kept for 26 hours, which is longer than any scheduler's retry window.

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.

StatusMeans
400The body could not be read, or a field failed validation. The message names the field.
401No key, an unknown key, or a revoked one. Says nothing about which.
403A valid key without the grant. The message names the scope it needed.
422A 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.

422 with no umbrella. An UMBRELLA placement must name a registered umbrellaId, and a PSC or PAYE placement must not carry one. That link is the liability evidence, so it is not optional and cannot be added later by a side door.

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" }
The engagement type and the umbrella are not amendable. Moving a worker between PSC, umbrella and PAYE is a different engagement with its own Key Information Document, and editing it in place would leave issued paperwork describing something that is no longer true. Open a new placement instead.

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" }
The approval link is not in the response. It goes to the approver and nobody else. Returning it here would let agency staff approve their own worker's hours, which is the four-eyes control this endpoint exists to enforce.

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…" }
422 without a live agreement. HMRC allows self-billing only where an agreement exists first, and agreements expire. An invoice raised outside one is not a valid VAT invoice, so this refuses rather than producing a document that fails an inspection quietly.

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" }
The payload names the record; it does not carry it. A copy of the record in somebody's queue is a second truth going stale, and a delivery crossing the internet is not how personal data should leave the building. Call back with your key for the detail, which also means the same scope rules still apply to what you can then read.

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:

PathWhy
/api/v1/platformThe operator plane. That is us running the platform, not you using it.
/api/v1/privacy, /api/v1/retentionSubject 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-keysKey management, so a leaked key cannot mint its own replacement.
/api/v1/connectionsData sources, because a key that could create one could feed the book from anywhere.
/api/v1/webhooksWebhook 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.

Start here

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.

Have it set up for you