API access
Reading and writing BuildFlow data over HTTP: keys, authentication, the tables you will use, filtering, and the traps worth knowing about.
Updated 2 September 2026
BuildFlow's data lives in Postgres on Supabase, which exposes every table over HTTP through PostgREST with your Row Level Security rules applied. There is no separate BuildFlow API to learn — the database *is* the API, and the app is just one client of it.
This is a power tool
There is no scoped API key yet, so you are authenticating either as a user or as the whole project. Read the key section carefully before you put anything in production.
Base URL
https://<your-project-ref>.supabase.co/rest/v1/Your project reference is the subdomain of the Supabase URL in your deployment's environment. Every table is a path under it: /rest/v1/projects, /rest/v1/tasks, and so on.
Keys and who you are
| Key | Sees | Use it |
|---|---|---|
| Publishable / anon key | Nothing on its own | Alongside a user's access token, in a browser |
| A user's access token | Exactly what that user sees in the app | Server-side scripts acting as one person |
| Secret / service-role key | Everything, bypassing RLS entirely | Server-side only, never in a browser or a mobile app |
Both key names have an older and a newer form. sb_publishable_… and sb_secret_… are the current ones; the older anon and service_role JWTs work identically.
The secret key bypasses every access rule you have
It reads and writes every company's data, ignores RLS, and is not attributable to a person in the audit trail. Keep it on a server, keep it out of version control, rotate it if it is ever pasted anywhere, and prefer a user token wherever a user token would do.
Authenticating as a user
The safer pattern for most integrations: sign in as a dedicated "integration" user you have added to your company with the least role that works, then use the token you get back. Everything that user does is subject to RLS and shows up in the audit trail under their name.
curl -s -X POST \
"https://<ref>.supabase.co/auth/v1/token?grant_type=password" \
-H "apikey: $BUILDFLOW_PUBLISHABLE_KEY" \
-H "Content-Type: application/json" \
-d '{"email":"integration@yourcompany.co.uk","password":"..."}'
# -> { "access_token": "...", "refresh_token": "...", "expires_in": 3600, ... }Access tokens expire in an hour. Long-running jobs should keep the refresh token and exchange it (grant_type=refresh_token) rather than signing in repeatedly.
Your first request
curl -s \
"https://<ref>.supabase.co/rest/v1/projects?status=eq.active&order=created_at.desc" \
-H "apikey: $BUILDFLOW_PUBLISHABLE_KEY" \
-H "Authorization: Bearer $ACCESS_TOKEN"[
{
"id": "55555555-5555-5555-5555-555555555555",
"company_id": "11111111-1111-1111-1111-111111111111",
"name": "Whitfield Residence — Rear Extension",
"reference": null,
"project_type": "extension",
"status": "active",
"address_line1": "14 Orchard Lane",
"city": "Guildford",
"postcode": "GU2 7QT",
"start_date": "2026-07-25",
"target_end_date": "2026-10-23",
"completion_pct": 51,
"created_at": "2026-07-20T09:14:02.117Z"
}
]The tables you will actually use
| Table | Holds | Keyed by |
|---|---|---|
companies | Your firm, branding, plan | — |
company_members | Staff and their company role | company_id, user_id |
projects | Jobs: name, type, status, address, dates, completion | company_id |
project_members | Who is on a job, and as what | project_id, user_id |
project_stages | Programme stages, status, completion | project_id |
milestones | Dated milestones and whether they are done | project_id |
tasks | Work items: status, priority, assignee, due date | project_id, stage_id |
task_checklist_items | Checklists inside a task | task_id |
snag_items | Snags: title, room, status, assignee | project_id |
scope_items | Scope of works: included, excluded, provisional sums | project_id |
payment_stages | The payment schedule and what has been paid | project_id, milestone_id |
variations | Changes to the job, priced and decided | project_id |
selections | Decisions the client has to make, and by when | project_id, stage_id |
selection_options | The choices offered against a selection | selection_id, project_id |
defects | Reported after completion. Any member may insert | project_id |
warranties | What is guaranteed, by whom, until when | project_id |
subcontractors | Who you use, and their CIS status | company_id |
subcontractor_payments | Payments with the deduction, snapshotted | company_id, subcontractor_id |
purchases | Materials, plant and everything else a job cost | company_id, project_id |
cis_returns | One row per tax month once it is marked filed | company_id |
quotes | Priced work, frozen once sent. Carries your costs and margin | company_id, lead_id, project_id |
quote_lines | The build-up: cost, margin and VAT per line | quote_id, company_id |
invoices | Invoices and credit notes, numbered and frozen once issued | company_id, project_id |
invoice_lines | Lines, each with its own VAT rate | invoice_id, company_id |
leads | Enquiries: who asked, from where, and what happened | company_id |
site_visits | Visits, against a lead OR a project — never both | company_id, lead_id |
site_visit_photos | Pre-sale photos. Staff only, never the client | visit_id, company_id |
construction_phase_plans | Plan versions. Frozen once issued | project_id |
risk_assessments | RAMS, hazards as JSON, before/after scores | project_id, company_id |
toolbox_talks | Talks given, with the date and the note | project_id |
safety_signoffs | Who signed which document version, and when | project_id, user_id |
incidents | Injuries, near misses, RIDDOR. Company admins only | project_id |
messages | The project thread, by category | project_id |
documents | Document library and versions | project_id, folder_id |
photos | Progress photos and their tags | project_id |
daily_logs | The site diary | project_id |
building_control_inspections | Inspections and results | project_id |
compliance_certificates | Certificates, issuers, expiry | project_id |
profiles | People — names, contact details | — |
Useful enum values: project_status is planning, active, on_hold, snagging, completed, archived. task_status is todo, in_progress, blocked, awaiting_review, completed. snag_status is open, assigned, in_progress, resolved, waiting_approval, completed. scope_kind is included, excluded, provisional_sum. payment_stage_status is pending, due, invoiced, paid, and payment_trigger is on_milestone, on_date, manual. variation_status is draft, awaiting_approval, approved, rejected, withdrawn, and decision_channel is in_app, email, written, verbal. selection_status is draft, awaiting_client, chosen, ordered, cancelled. invoice_status is draft, issued, part_paid, paid, void, and invoice_kind is invoice, credit_note. cpp_status is draft, issued, superseded, and incident_kind is near_miss, injury, dangerous_occurrence, ill_health, damage. lead_status is new, contacted, visit_booked, visited, quoted, won, lost; lead_source is referral, repeat_client, website, search, directory, social, signboard, trade_contact, other; lead_lost_reason is price, timing, went_elsewhere, no_response, we_declined, not_our_work, postponed, other; and site_visit_status is booked, done, cancelled, no_access. quote_kind is estimate, quotation; quote_status is draft, sent, accepted, declined, expired, superseded; and quote_cost_kind is labour, materials, subcontractor, plant, other. cis_status is gross, net, higher, unverified; subcontractor_kind is sole_trader, partnership, company; and purchase_kind is materials, plant, labour, subcontractor, other. defect_status is reported, acknowledged, scheduled, fixed, rejected, withdrawn; defect_liability is workmanship, materials, subcontractor, manufacturer, client_damage, wear_and_tear, not_a_defect, undecided; and warranty_kind is workmanship, manufacturer, installer, insurance_backed, structural.
Filtering, ordering and paging
# Tasks due in the next fortnight, not finished
?project_id=eq.<id>&status=neq.completed&due_date=lte.2026-09-16&order=due_date.asc
# Open snags in one room
?project_id=eq.<id>&status=in.(open,assigned,in_progress)&room=ilike.*kitchen*
# Just the columns you need
?select=id,name,status,completion_pct
# Page through, and ask for the total
# (with the header Prefer: count=exact the Content-Range reply carries it)
?limit=50&offset=100Ask for the columns you want rather than taking everything. It is faster, and it will not break when a column is added.
Embedding related rows
PostgREST can pull related rows in one request through the foreign keys:
?select=id,title,status,stage:project_stages(name),assignee:profiles(full_name)Ambiguous embeds are the trap on this API
Where a table has two or more foreign keys to the same table, PostgREST cannot choose and returns an error rather than guessing. snag_items points at profiles three times — assigned_to, reported_by and confirmed_by. Name the constraint: assignee:profiles!snag_items_assigned_to_fkey(full_name). The same applies to project_members (user_id and added_by) and tasks (assigned_to, completed_by, created_by).
Writing
curl -s -X POST "https://<ref>.supabase.co/rest/v1/tasks" \
-H "apikey: $BUILDFLOW_PUBLISHABLE_KEY" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Prefer: return=representation" \
-d '{
"project_id": "55555555-5555-5555-5555-555555555555",
"title": "Order kitchen units",
"status": "todo",
"priority": "medium",
"due_date": "2026-09-03"
}'curl -s -X PATCH "https://<ref>.supabase.co/rest/v1/tasks?id=eq.<task-id>" \
-H "apikey: $BUILDFLOW_PUBLISHABLE_KEY" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"status": "completed"}'A PATCH or DELETE with no filter changes every row you can reach
PostgREST does not require one. Always include ?id=eq.…, and test against a project you do not mind breaking.
Creating a whole project
Do not build a project by inserting rows one at a time — you will get the stages, tasks, milestones and inspections out of step. Use the same database function the app uses, which does it in one transaction:
curl -s -X POST \
"https://<ref>.supabase.co/rest/v1/rpc/create_project_from_template" \
-H "apikey: $BUILDFLOW_PUBLISHABLE_KEY" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"p_company_id": "<company-id>",
"p_template_id": "<template-id>",
"p_name": "Whitfield Residence — Rear Extension",
"p_start_date": "2026-07-25"
}'Read the current signature from supabase/migrations/0004_templates_seed.sql and 0011_project_from_body.sql before relying on the argument names — these functions are internal and can change between releases.
Reading the money
scope_items and payment_stages read like any other table, but the totals are worth taking from the database rather than adding up yourself — the app and the handover pack both use this function, and a figure you compute separately will disagree with theirs sooner or later.
curl -s -X POST \
"https://<ref>.supabase.co/rest/v1/rpc/project_money" \
-H "apikey: $BUILDFLOW_PUBLISHABLE_KEY" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"p_project_id": "<project-id>"}'company_money does the same across a company's live jobs, for company owners and admins only. Both return nothing rather than an error when you are not entitled to the answer, which is how every summary function in BuildFlow behaves.
Amounts are net of VAT
Everything in scope_items and payment_stages excludes VAT; the rate lives on the project as vat_rate, a percentage. A payment's paid_amount is what actually arrived, which is not always its amount.
Variations
variations inserts and updates behave like any other table — reference is assigned for you if you leave it out — but the decision columns are not yours to write. A row cannot be inserted already approved, and a decided one cannot be edited afterwards. Both refusals come back as VARIATION_DECISION_NOT_YOURS and VARIATION_ALREADY_DECIDED.
| Function | Who can call it | Does |
|---|---|---|
submit_variation | The project's builder | Sends a draft to the client and notifies them |
decide_variation | The project's client | Approves or rejects. Records the channel as in_app |
record_variation_decision | The project's builder | Records an answer given elsewhere. Rejects the channel in_app, and requires a note |
There is no API route to an in-app approval you did not get
This is the one place in BuildFlow where the database refuses something purely because of who is asking, and it is deliberate: an approval recorded as given in BuildFlow is supposed to mean the client pressed the button. If your integration is approving variations on a client's behalf, use record_variation_decision and say how they actually answered.
Selections
selection_options.project_id is set by a trigger from the parent selection — send it or don't, it will be overwritten. The read policy trusts that column, so it is not the caller's to claim. As with variations, the choice columns are not writable directly: SELECTION_CHOICE_NOT_YOURS comes back if you try.
| Function | Who can call it | Does |
|---|---|---|
request_selection | The project's builder | Sends it to the client. Refuses a selection with no options |
choose_selection | The project's client | Records the choice, and the moment it arrived |
project_selections_summary | Builder or client | Counts, the next one due, and total days late across settled decisions |
Invoices
A draft behaves like any other row. An ISSUED invoice does not: its number, dates, totals, lines and snapshotted addresses are all frozen, and an attempt to change any of them comes back as INVOICE_ALREADY_ISSUED. You cannot set a number or a status by hand either — INVOICE_ISSUE_VIA_FUNCTION — because the number comes from the company's series under a row lock.
| Function | Does |
|---|---|
issue_invoice | Takes the next number, snapshots the parties, marks any payment stages invoiced, tells the client |
void_invoice | Marks an issued invoice void with a reason. Refused once money has been received against it |
create_credit_note | Drafts a CN- document copying the original's lines, for you to trim |
record_invoice_payment | Adds to what has been received. The status follows the arithmetic |
project_billable | What is owed and not yet on an issued invoice |
Totals are stored, not computed on read
subtotal, vat_total and total are written by a trigger while the invoice is a draft and frozen afterwards. Do not recompute them from the lines in your own code and expect a match on an old invoice — a rounding change or a rate change must never alter what an issued document claims, which is the whole point of storing them.
Completion and defects
defects is the one table in the back half of BuildFlow a client can write to: any project member may insert, because the person living in the building is the one who finds out the shower leaks. Only builders and employees may update — a subcontractor cannot set liability on their own work, and a client cannot set it at all.
A refused UPDATE is not an error
Postgres does not raise when RLS filters an UPDATE — it matches zero rows and reports success. A client tapping something they cannot change gets UPDATE 0 and no message. Use returning (or PostgREST's .select()) and treat an empty result as the refusal it is, or your interface will confirm a change that never happened.
within_period is computed on insert from the project's practical completion date and defects period, then left alone. A defect reported in month eleven does not stop being a period defect when month thirteen arrives, and recomputing it on read would change the answer to the only question that matters about it. A defect reported after the period is accepted, not refused — the period governs who pays, not whether liability exists.
A defect with status = 'rejected' and no rejected_reason violates defect_rejection_needs_reason. Status changes stamp acknowledged_at, fixed_at and fixed_by, and moving off fixed clears the last two — something fixed in March and found again in June was not fixed.
| Function | Does |
|---|---|
certify_practical_completion | Dates the event, starts the defects period, moves the project to completed, notifies. Refuses a future date and a second call |
revoke_practical_completion | Undoes it — refused once retention has been released against it |
certify_final_completion | Records the end. Allowed with defects open, deliberately |
project_retention | Held, released and outstanding, plus what is open. Gated on can_see_money, so the client sees it and a subcontractor does not |
release_retention | Records a release. Refused before practical completion |
defects_period_end / company_aftercare | The date, and every job still inside its period |
Retention is held against payments received, not the contract sum: a £40,000 job at 5% with £30,000 paid holds £1,500, not £2,000.
Subcontractors and CIS
Staff only, like quotes and enquiries. A subcontractor's token reads nothing from any of these tables, which matters here because they would otherwise see what you pay everybody else.
Never write a deduction
Send labour_amount and materials_amount; a trigger writes deduction, cis_rate_used, tax_month_end and net_paid. The deduction comes off the labour only — not the invoice, not the materials, not VAT — and computing it yourself is the mistake this schema exists to prevent. cis_status_used and verification_number_used are snapshotted from the subcontractor on insert and never re-read: what was deducted must still say what was deducted after their status changes.
Inserting a payment for a subcontractor whose cis_status is unverified is refused (SUBCONTRACTOR_UNVERIFIED). There is no correct deduction until HMRC has been asked, and a payment recorded without that decision is an undisclosed liability. A payment whose subcontractor belongs to another company is refused too (SUBCONTRACTOR_WRONG_COMPANY) — the foreign key alone would not stop it.
Once a tax month has a cis_returns row with filed_at set, every insert, update and delete against its payments comes back as CIS_MONTH_FILED. Unfile it first if a correction is genuinely needed.
| Function | Does |
|---|---|
cis_tax_month_end | The 5th that ends the tax month a date falls in. 6th-to-5th, not calendar |
cis_deduction | Labour and a status to a deduction. Null for unverified, because there is no answer |
cis_return_due / cis_payment_due / cis_statement_due | The 19th, the 22nd, and 14 days after the month end |
company_cis_return | One tax month per subcontractor, as HMRC's form asks for it |
company_cis_months | Every month with something in it, plus deadlines and what is unfiled |
file_cis_return / unfile_cis_return | Marks a month filed and freezes it, or releases it. Admin only |
project_cost_summary / project_cost_position | Quoted against spent, by kind and in total |
project_cost_position returns labour_unrecorded: quoted labour with nothing spent against it. BuildFlow only sees money leaving the bank, so a builder's own employed team never appears — report that gap rather than presenting the margin as complete.
Quotes
Staff only, like enquiries, and for a sharper reason: a quote line carries unit_cost and margin_pct, so the table is the builder's own cost book. Clients have no policy at all on quotes — Postgres cannot filter columns in a policy, and exposing the row would mean trusting every query never to select a margin. There is no client-facing read path to build against.
Never write a price
There is no price column. A line stores quantity, unit_cost and margin_pct, and the price is derived: price_from_cost(cost, margin) divides — cost / (1 - margin/100) — rather than multiplying, because multiplying is markup and gives a smaller margin than the number says. The quote's stored subtotal, vat_total, total and cost_total are written by a trigger while it is a draft and frozen afterwards. Recomputing them yourself and expecting a match on an old quote will fail, which is the point of storing them.
A draft behaves like any other row. Past draft, the number, kind, version, totals, validity, wording and status are all frozen — QUOTE_ALREADY_SENT. Status is in that list on purpose: accept_quote is what writes the contract sum and the scope of works, so a plain update quotes set status = 'accepted' would leave a quote claiming a contract that does not exist. You cannot insert or hand-send a non-draft either (QUOTE_SEND_VIA_FUNCTION), because the number comes from the company series under a row lock.
| Function | Does |
|---|---|
send_quote | Takes the next number, snapshots the customer, defaults the validity, moves the enquiry to quoted |
accept_quote | Starts the job if needed, sets the contract sum and VAT rate, writes the scope of works from the lines, supersedes rival quotes. Admin only |
decline_quote | Records the no, with what they said |
revise_quote | Copies the lines into a new draft version and supersedes the old one |
expire_quotes | Nightly sweep for quotations past their date |
company_quote_stats | Win rate, value and the margin actually priced. Staff only |
price_from_cost, margin_of and markup_for_margin are all callable if you are building against this — use them rather than reimplementing the arithmetic, since the division is exactly the part that gets written the wrong way round.
Enquiries and site visits
These are the one part of BuildFlow gated on a company role rather than a project one. leads, site_visits and site_visit_photos require owner, admin or employee — is_company_staff, not is_company_member, so a subcontractor's token reads nothing from any of them and gets no rows from either reporting function. The pipeline is the company's commercial position, not a project record.
Two writes will be refused that look like they should work. A lead with status = 'lost' and no lost_reason violates lead_lost_needs_reason, and a reason left on a lead that is no longer lost violates lead_reason_only_when_lost — the trigger clears it for you on a status change, so this only bites when you set both columns by hand. A received_at in the future comes back as LEAD_RECEIVED_IN_FUTURE.
A site_visits row needs exactly one of lead_id or project_id (VISIT_NEEDS_ONE_OWNER), and whichever it is must belong to the same company as the visit (VISIT_WRONG_COMPANY). The foreign key alone would not stop you hanging a visit off another company's lead, so a trigger does.
The funnel dates are not yours to write
first_contacted_at, quoted_at and decided_at are stamped by a trigger when status changes, and inserting a lead already marked won or lost stamps decided_at at the same time. Booking a visit against a lead moves that lead to visit_booked by itself, and completing one moves it to visited. Set the status and let the rest follow — writing these columns directly is how a response-time report becomes fiction.
| Function | Does |
|---|---|
convert_lead_to_project | Creates the project through the same path as the app, links it to the lead and marks it won. Admin only, once per lead |
company_lead_stats | The funnel: conversion, medians, what is unanswered. Staff only |
company_lead_sources | Enquiries, wins and value per source. Staff only |
Photos go in the private site-visits bucket with the key <company_id>/<visit_id>/<uuid>-<name>. The storage policy checks the first path segment, so a key that does not start with your company id is rejected on upload.
Safety
A construction phase plan follows the same freeze-on-issue rule as an invoice, for the same reason: the date is the evidence. A draft is editable; an issued version is not. You cannot insert a row that is already issued, and you cannot set status or issued_at by hand on the way in or the way out. Both refusals come back as PLAN_ISSUE_VIA_FUNCTION, and editing an issued version gives PLAN_ALREADY_ISSUED.
| Function | Does |
|---|---|
issue_construction_phase_plan | Stamps the plan with the server's time and freezes it, then tells everyone on site |
revise_construction_phase_plan | Opens the next version as a draft and supersedes the current one, keeping both sets of signatures |
project_f10_test | The 30-day/20-worker limb, plus an upper bound on person days. Reports, does not decide |
project_safety_summary | One job: plan status, how late it was issued, sign-offs outstanding |
company_safety_alerts | Across live jobs: how many have started with no plan. Company admins only |
The incident log is not readable by most tokens
incidents is restricted to your own company's administrators — not clients, not employees, not subcontractors. The records name people and describe injuries, which makes them special category data under UK GDPR. The counts are restricted the same way: project_safety_summary returns zero for incidents_90d and riddor_open to anybody who is not the builder, rather than refusing the whole call. A user token that reads zero is not necessarily looking at a site with no incidents.
safety_signoffs.signed_name is the name the person typed, not a copy of their profile, and document_title is snapshotted at the moment of signing. Neither is derived on read — a later rename must not change what the record says somebody signed.
What you should not do through the API
- Write to `audit_logs`. It is append-only and a trigger will stop you. That is the point of it.
- Insert `notifications` directly. Use the app's own paths so delivery works.
- Bypass plan limits with the secret key. They are enforced on insert, and going round them puts the account in a state the interface cannot represent.
- Put the secret key in anything a browser loads. It is the whole database.
- Edit a project's `contract_sum` to absorb a variation. It is the original agreed sum and the record depends on it staying that way — raise a
variationsrow instead. - Set `companies.invoice_series_last` yourself. A series with a repeat in it is the one thing HMRC actually asks about.
- Try to write `construction_phase_plans.issued_at`. A plan you could date yourself is evidence of nothing, which is why the database will not let you — not even with the secret key.
Errors worth expecting
| Status | Usually means |
|---|---|
| 401 | Missing or expired token — refresh and retry once |
| 403 | RLS refused it. The row exists but this user may not touch it |
| 404 | No such table or function — or RLS hiding it so completely it does not appear |
| 409 | A constraint: a duplicate member, or a plan limit trigger |
PGRST200 | Ambiguous embed — name the foreign key constraint |
An empty array is not proof of nothing
RLS filters silently: a query you are not entitled to returns [], not an error. If a sync suddenly finds no rows, check the account's access before concluding the data has gone.
Rate limits and being a good citizen
There is no BuildFlow-specific rate limit; you are subject to your Supabase project's. In practice that is generous and the sensible constraint is your own: poll at the pace the data actually changes. A construction programme does not move every thirty seconds.
- Filter on
updated_atand pull only what changed since your last run. - Ask for the columns you need, not
select=*. - Back off on 5xx rather than hammering.