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, IR35 positions and status determinations, 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.
sds_missing422Self-billing a company placement whose client size is unstated, or whose client is not small and has given no status determination.
inside_ir35422Self-billing gross a company placement the client has determined inside the off-payroll rules.
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 }

POST /api/v1/import/propose

Scope placements:write. Same body again, and this one reads header rows only: nothing is parsed, kept or written. It answers what each column would be read as and where that reading came from.

{ "files": [
    { "file": "workers.csv", "label": "Workers",
      "columns": [
        { "source": "First Name",   "target": "first_name", "via": "SYNONYM", "suggestion": null },
        { "source": "Cand Ref",     "target": "email",      "via": "SAVED",   "suggestion": null },
        { "source": "email",        "target": "email",      "via": "EXACT",   "suggestion": null },
        { "source": "Payroll Code", "target": null,         "via": "UNMAPPED","suggestion": null } ],
      "missingRequired": [ "last_name" ],
      "fields": [ { "name": "first_name", "label": "First name", "hint": "", "required": true } ] } ] }

via is SAVED for a mapping this agency stated, SYNONYM for one of ours, EXACT for a heading that already matched, and UNMAPPED for a column nobody has claimed. An unmapped column is ignored on import rather than guessed at. missingRequired names the fields that still have to come from somewhere before the file can import at all.

Column mappings

What this agency's own headings mean, remembered. Saved mappings are applied before the built-in synonyms and beat them, because the agency stating what their own column means outranks a guess about what it probably means.

PUT /api/v1/import/mappings
{ "file": "workers.csv",
  "columns": { "Cand Ref": "email",
               "Payroll Code": "" } }

A blank target forgets the mapping, so unlearning is the same gesture as learning. A target the file does not carry is refused with invalid_request: a mapping that pointed nowhere would sit there doing nothing, and you would find out when the column came back empty. GET lists what is held, DELETE /api/v1/import/mappings/{id} removes one.

[ { "id": "6b21…", "file": "workers.csv", "source": "Cand Ref", "target": "email" } ]

GET /api/v1/import/fields lists every field each file carries with its label and whether it is required, which is what a mapping screen offers.

The hash says what arrived; the mapping says how it was read. Both go in the audit chain. Once a saved mapping exists, the file on disk and the columns the importer saw are no longer the same statement, and an evidence chain that recorded only one of them could not answer the question three years later.

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. The copy kept (a scan, the saved online check, the provider's output) is filed first through /compliance/documents under RTW for the worker and named by evidenceDocumentId; a typed note goes in evidenceContent.

{ "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/documents

Scope compliance:write. Files an uploaded document in the vault: multipart/form-data with file, category (one of UMBRELLA_EVIDENCE, RTW, CONTRACT, TERMS, RATE_CARD, SDS, SELF_BILLING_AGREEMENT, KID, INSURANCE, CORRESPONDENCE, OTHER) and, optionally, ownerType (WORKER, CLIENT, UMBRELLA) with ownerId. PDF, PNG, JPEG, WEBP, HEIC, Word, Excel, plain text, CSV and .eml, up to 15 MB; anything else is refused with the reason. Returns the stored document with its id, contentType, sizeBytes and contentSha256. Retention follows the category.

GET /api/v1/compliance/documents

Scope compliance:read. Everything on file, newest first, each row naming what it belongs to; narrow with category, workerId, clientId or umbrellaId.

POST /api/v1/compliance/umbrellas/{umbrellaId}/evidence

Scope compliance:write. Files evidence against an umbrella: what it is, when it lapses, a note, and either the text of it or an uploaded document by documentId (file it first through /compliance/documents), or both.

{ "kind": "ACCREDITATION",
  "note": "FCSA renewal, reviewed by Priya",
  "expiresAt": "2027-06-30",
  "documentId": "…" }

kind is ACCREDITATION, PAYSLIP_SAMPLE, RTI_EVIDENCE, INSURANCE or CONTRACT. GET on the same path lists what is on file for the umbrella, newest first.

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/ir35

Scope compliance:read. Every live placement through a worker's own company with where it stands under the off-payroll rules (ITEPA 2003 Part 2 Chapter 10). The state is one of CLIENT_SIZE_UNKNOWN, CHAPTER_8, SDS_MISSING, OUTSIDE, INSIDE, DISPUTED, DISPUTE_OVERDUE; the label, detail and next step are the plain-English sentences the desk sees.

[ { "placementId": "0f2b…", "roleTitle": "Data engineer",
    "workerId": "…", "workerName": "Dylan Rees",
    "clientId": "…", "clientName": "Meridian Analytics Ltd", "clientSmall": false,
    "state": "OUTSIDE", "tone": "success",
    "label": "Outside IR35",
    "detail": "…", "nextStep": null,
    "sds": { "id": "…", "outcome": "OUTSIDE", "issuedOn": "2026-07-01",
             "issuedBy": "Priya Nair, Head of Data, Meridian Analytics Ltd",
             "hasReasons": true, "documentId": "…",
             "disputedOn": null, "disputeResponseDue": null,
             "disputeResolvedOn": null, "disputeOutcome": null } } ]

Client size is set with PATCH /api/v1/directory/clients/{id} and { "smallCompany": true } or false; unset is shown as unknown, never assumed.

GET /api/v1/compliance/ir35/{placementId}

GET /api/v1/compliance/ir35/{placementId}/history

Scope compliance:read. One placement's position, and every statement ever recorded against it, newest first, superseded ones included.

POST /api/v1/compliance/ir35/{placementId}/sds

Scope compliance:write. Records the client's status determination statement against the placement. Supersedes any earlier one (kept, never overwritten) and writes an audit event. wording is optional and is stored as a document on the placement under a six-year retention category.

{ "outcome": "OUTSIDE",
  "issuedOn": "2026-07-01",
  "issuedBy": "Priya Nair, Head of Data, Meridian Analytics Ltd",
  "reasons": "…",
  "wording": "…" }

POST /api/v1/compliance/ir35/{placementId}/disagree

POST /api/v1/compliance/ir35/{placementId}/disagree/resolve

Scope compliance:write. Raises a disagreement with the current statement ({ "on": "2026-08-17" }, today when omitted; the client's answer is due 45 days later under s.61T), and records the client's answer ({ "on": "…", "outcome": "UPHELD" | "CHANGED" }). Both return the position.

Self-billing (POST /api/v1/billing/invoices/generate) refuses with sds_missing or inside_ir35 when the record cannot justify paying the company gross.

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 IR35 position with every status determination recorded, 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 with its stored content type (a PDF as application/pdf, a KID as text/html), 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.

The scheduled drop

The one endpoint here that takes no key. It exists because most agencies have no developer, and telling them to write one is the same as telling them no. Whatever they already run posts the CSVs it already exports, and the setup on their side is a URL, a header and a schedule.

Create the connection in the desk under Connections. The secret is shown once.

POST /api/public/connections/drop
X-Connection-Secret: bkc_…
Content-Type: application/json

{ "workers": "first_name,last_name,email\nMaya,Ellison,maya@agency.example\n",
  "placements": "worker_email,client_name,role_title,engagement_type,start_date,pay_rate_pence,charge_rate_pence,rate_unit\nmaya@agency.example,Northgate Foods,Line Operative,UMBRELLA,2026-09-01,1450,2100,HOURLY\n" }

All five files are optional and every one is the raw file content: clients, contacts, umbrellas, workers, placements. An agency with no umbrella engagements sends no umbrellas file; one correcting a single file re-sends only that file. The secret may also go in an Authorization: Bearer header, because some schedulers cannot set an arbitrary one.

200 OK

{ "outcome": "APPLIED",
  "rowsSeen": 412,
  "rowsApplied": 412,
  "notes": [ "workers.csv: read the column Surname as last_name" ] }

outcome is APPLIED, NOTHING_TO_DO, REFUSED or FAILED. A refusal answers 422 rather than 200 with a sad body, so a scheduler can decide whether to wake somebody on the status code alone, and a run that changed nothing when it should have changed something ought to wake somebody.

422 Unprocessable Entity

{ "outcome": "REFUSED",
  "rowsSeen": 412,
  "rowsApplied": 0,
  "notes": [ "placements.csv row 88: an umbrella engagement must name its umbrella",
             "Nothing was written. Fix the rows above and the next run will apply the whole set." ] }
A drop applies the whole set or none of it. A person looking at a validation report can decide two bad rows out of six hundred are acceptable and fix them by hand. A scheduler decides nothing, so a partial apply would leave a book that is neither the old one nor the new one, at three in the morning, with nobody watching.

Every run is recorded whatever it did, including the ones that brought nothing, and each carries what the importer said. The failure that matters with an automated source is never loud: it is the connection that quietly stopped while the agency believed its position was current. A live connection silent for materially longer than its own usual gap raises one email to everyone who can sign in, once per silence rather than daily, with the expected gap derived from that connection's own run history rather than a schedule you have to declare.

Authorised providers

Where a provider allows it, a connection authorises instead of holding a secret. The redirect URI to register with them is one fixed address for every provider:

https://bookkept.co.uk/api/public/connections/callback

What a connector reads is rendered into the same five CSV shapes above and put through the same importer, so a provider connection cannot become a path around the rules a manual import obeys. Which providers are live, and why the rest are not.

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.

Start free Have it set up for you