All documentation
Integrations & API

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

KeySeesUse it
Publishable / anon keyNothing on its ownAlongside a user's access token, in a browser
A user's access tokenExactly what that user sees in the appServer-side scripts acting as one person
Secret / service-role keyEverything, bypassing RLS entirelyServer-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.

Exchange an email and password for an access token
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

Every active project, newest first
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"
Response
[
  {
    "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

TableHoldsKeyed by
companiesYour firm, branding, plan—
company_membersStaff and their company rolecompany_id, user_id
projectsJobs: name, type, status, address, dates, completioncompany_id
project_membersWho is on a job, and as whatproject_id, user_id
project_stagesProgramme stages, status, completionproject_id
milestonesDated milestones and whether they are doneproject_id
tasksWork items: status, priority, assignee, due dateproject_id, stage_id
task_checklist_itemsChecklists inside a tasktask_id
snag_itemsSnags: title, room, status, assigneeproject_id
scope_itemsScope of works: included, excluded, provisional sumsproject_id
payment_stagesThe payment schedule and what has been paidproject_id, milestone_id
variationsChanges to the job, priced and decidedproject_id
selectionsDecisions the client has to make, and by whenproject_id, stage_id
selection_optionsThe choices offered against a selectionselection_id, project_id
defectsReported after completion. Any member may insertproject_id
warrantiesWhat is guaranteed, by whom, until whenproject_id
subcontractorsWho you use, and their CIS statuscompany_id
subcontractor_paymentsPayments with the deduction, snapshottedcompany_id, subcontractor_id
purchasesMaterials, plant and everything else a job costcompany_id, project_id
cis_returnsOne row per tax month once it is marked filedcompany_id
quotesPriced work, frozen once sent. Carries your costs and margincompany_id, lead_id, project_id
quote_linesThe build-up: cost, margin and VAT per linequote_id, company_id
invoicesInvoices and credit notes, numbered and frozen once issuedcompany_id, project_id
invoice_linesLines, each with its own VAT rateinvoice_id, company_id
leadsEnquiries: who asked, from where, and what happenedcompany_id
site_visitsVisits, against a lead OR a project — never bothcompany_id, lead_id
site_visit_photosPre-sale photos. Staff only, never the clientvisit_id, company_id
construction_phase_plansPlan versions. Frozen once issuedproject_id
risk_assessmentsRAMS, hazards as JSON, before/after scoresproject_id, company_id
toolbox_talksTalks given, with the date and the noteproject_id
safety_signoffsWho signed which document version, and whenproject_id, user_id
incidentsInjuries, near misses, RIDDOR. Company admins onlyproject_id
messagesThe project thread, by categoryproject_id
documentsDocument library and versionsproject_id, folder_id
photosProgress photos and their tagsproject_id
daily_logsThe site diaryproject_id
building_control_inspectionsInspections and resultsproject_id
compliance_certificatesCertificates, issuers, expiryproject_id
profilesPeople — 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=100

Ask for the columns you want rather than taking everything. It is faster, and it will not break when a column is added.

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

Create a task
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"
  }'
Update one
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.

Contract sum, paid, due and outstanding for one project
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.

FunctionWho can call itDoes
submit_variationThe project's builderSends a draft to the client and notifies them
decide_variationThe project's clientApproves or rejects. Records the channel as in_app
record_variation_decisionThe project's builderRecords 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.

FunctionWho can call itDoes
request_selectionThe project's builderSends it to the client. Refuses a selection with no options
choose_selectionThe project's clientRecords the choice, and the moment it arrived
project_selections_summaryBuilder or clientCounts, 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.

FunctionDoes
issue_invoiceTakes the next number, snapshots the parties, marks any payment stages invoiced, tells the client
void_invoiceMarks an issued invoice void with a reason. Refused once money has been received against it
create_credit_noteDrafts a CN- document copying the original's lines, for you to trim
record_invoice_paymentAdds to what has been received. The status follows the arithmetic
project_billableWhat 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.

FunctionDoes
certify_practical_completionDates the event, starts the defects period, moves the project to completed, notifies. Refuses a future date and a second call
revoke_practical_completionUndoes it — refused once retention has been released against it
certify_final_completionRecords the end. Allowed with defects open, deliberately
project_retentionHeld, released and outstanding, plus what is open. Gated on can_see_money, so the client sees it and a subcontractor does not
release_retentionRecords a release. Refused before practical completion
defects_period_end / company_aftercareThe 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.

FunctionDoes
cis_tax_month_endThe 5th that ends the tax month a date falls in. 6th-to-5th, not calendar
cis_deductionLabour and a status to a deduction. Null for unverified, because there is no answer
cis_return_due / cis_payment_due / cis_statement_dueThe 19th, the 22nd, and 14 days after the month end
company_cis_returnOne tax month per subcontractor, as HMRC's form asks for it
company_cis_monthsEvery month with something in it, plus deadlines and what is unfiled
file_cis_return / unfile_cis_returnMarks a month filed and freezes it, or releases it. Admin only
project_cost_summary / project_cost_positionQuoted 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.

FunctionDoes
send_quoteTakes the next number, snapshots the customer, defaults the validity, moves the enquiry to quoted
accept_quoteStarts 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_quoteRecords the no, with what they said
revise_quoteCopies the lines into a new draft version and supersedes the old one
expire_quotesNightly sweep for quotations past their date
company_quote_statsWin 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.

FunctionDoes
convert_lead_to_projectCreates 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_statsThe funnel: conversion, medians, what is unanswered. Staff only
company_lead_sourcesEnquiries, 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.

FunctionDoes
issue_construction_phase_planStamps the plan with the server's time and freezes it, then tells everyone on site
revise_construction_phase_planOpens the next version as a draft and supersedes the current one, keeping both sets of signatures
project_f10_testThe 30-day/20-worker limb, plus an upper bound on person days. Reports, does not decide
project_safety_summaryOne job: plan status, how late it was issued, sign-offs outstanding
company_safety_alertsAcross 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 variations row 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

StatusUsually means
401Missing or expired token — refresh and retry once
403RLS refused it. The row exists but this user may not touch it
404No such table or function — or RLS hiding it so completely it does not appear
409A constraint: a duplicate member, or a plan limit trigger
PGRST200Ambiguous 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_at and pull only what changed since your last run.
  • Ask for the columns you need, not select=*.
  • Back off on 5xx rather than hammering.

Try it on a real job

The free plan runs one project end to end — the fastest way to find out whether any of this fits how you work.

API access — BuildFlow documentation · BuildFlow