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, IR35 positions and status determinations, 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. |
| sds_missing | 422 | Self-billing a company placement whose client size is unstated, or whose client is not small and has given no status determination. |
| inside_ir35 | 422 | Self-billing gross a company placement the client has determined inside the off-payroll rules. |
| 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 }
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.
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. 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…" }
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.
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." ] }
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:
| 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.