Everything in the model, available to your tools.
The same endpoints the Basegrid app and the SketchUp extension use. Base URL https://app.basegrid.com.au.
| Auth | How it works |
|---|---|
| Bearer token | Send Authorization: Bearer <token> where the token is either a bgc_ connection credential from /app/connections, or a SketchUp OAuth access token. Scoped to the token's organisation. |
| Session | Requires a signed-in browser session (the Supabase auth cookies set by /api/auth/login). Scoped to the caller's organisation by row-level security. |
| Session · owner or admin | Requires a signed-in session whose role in the organisation is owner or admin. Members receive 403. |
| Public | No credentials required. |
| Stripe signature | Called by Stripe only; verified with the webhook signing secret. |
Conventions
All endpoints live under /api. Request and response bodies are JSON unless stated. Every error response has the same shape — { "error": "…" } — with a conventional status: 400 validation, 401 not signed in, 403 not allowed, 404 not yours or not found, 409 conflict, 5xx server. Money is stored in integer cents and quantities as decimals. All timestamps are ISO 8601 in UTC. Tenant isolation is enforced by Postgres row-level security, so a request can never read or write another organisation's rows.
Connection API v1
Read-only endpoints for connected tools such as the SketchUp extension. Authenticate with a bearer token. Organisation owners and admins create and revoke bgc_ credentials at /app/connections; a credential is shown once and only its SHA-256 digest is stored, so a lost credential is replaced, not recovered. The SketchUp extension may instead present an OAuth 2.1 access token (Authorization Code + PKCE S256) issued to the registered public client; the token's client_id claim is verified before the user's first organisation membership sets tenant scope. Every 200 carries a strong ETag; send it back as If-None-Match to receive 304 Not Modified with no body. Revocation takes effect on the next request.
/api/v1/oauth/configPublicOAuth discovery for the SketchUp client
Public endpoints and the registered client ID a native client needs to start an Authorization Code + PKCE flow. enabled is false when OAuth is not configured on the server.
200Discovery document. Never cached.{ "enabled": true, "authorization_endpoint": "https://<project>.supabase.co/auth/v1/oauth/authorize", "token_endpoint": "https://<project>.supabase.co/auth/v1/oauth/token", "client_id": "…", "redirect_uri": "http://127.0.0.1:43821/oauth/callback", "scope": "openid email profile" }
/api/v1/material-libraryBearer tokenThe organisation's material library
Material types with their materials, dimensions and appearance, plus the organisation's tool configuration: takeoff_groups (every group, with status active or retired so saved models can still resolve retired ones) and tools (the global SketchUp tool catalogue, each with the ids of the groups it may offer in this organisation — configured at /app/tools; nothing is allowed by default and new groups are never linked automatically). A tool runs with one of its groups selected and every item it creates inherits that group. generated_roles is retained for older extensions and reports each role's tool groups. Every id in tools[].takeoff_group_ids and generated_roles[].takeoff_group_ids is present in takeoff_groups; arrays are [] rather than null. The ETag changes when a tool, group, assignment or material changes. Texture objects include one-hour signed_url values for privately stored images; solid colours have signed_url: null and expose color and render_spec instead. When a material has no construction texture, construction_texture falls back to its type's default_texture. ETags roll before signed URLs expire so long-running clients periodically receive fresh URLs.
| Field | Type | Notes |
|---|---|---|
| If-None-Match | string | The ETag from a previous 200. Returns 304 when unchanged. |
200The library, withETag.{ "api_version": "v1", "organisation": { "id": "uuid", "name": "Example Builders" }, "takeoff_groups": [ { "id": "uuid", "name": "Concrete slabs", "status": "active" } ], "tools": [ { "id": "concrete.slab_from_face", "name": "Concrete Slab", "status": "active", "takeoff_group_ids": ["uuid"] }, { "id": "structure.beam", "name": "Beam", "status": "active", "takeoff_group_ids": [] }, { "id": "timber.wall_frame", "name": "Timber Wall Framing", "status": "active", "takeoff_group_ids": [] } ], "generated_roles": [ { "id": "concrete.slab_from_face.slab_body", "tool_id": "concrete.slab_from_face", "takeoff_group_ids": ["uuid"] } ], "material_types": [ { "id": "uuid", "name": "LVL", "profile": "rectangular", "uom": "m", "density_kg_m3": 550, "notes": null, "status": "active", "updated_at": "2026-08-20T01:02:03.000Z", "default_texture": null, "materials": [ { "id": "uuid", "name": "240x45 LVL", "profile": "rectangular", "grade": "E13", "finish": null, "dimensions_mm": { "width": 45, "depth": 240, "thickness": null, "height": null, "diameter": null, "wall_thickness": null, "flange_width": null, "web_thickness": null, "flange_thickness": null, "girth": null, "folds": null }, "mass_per_uom": null, "status": "active", "updated_at": "2026-08-20T01:02:03.000Z", "construction_texture": { "texture_id": "uuid", "name": "Pine grain", "kind": "labelled", "signed_url": "https://…", "color": null, "render_spec": null, "aspect_ratio": 1, "natural_width_mm": null, "natural_height_mm": null, "fit": "fitted", "width_mm": 45, "height_mm": 45, "rotation": 0 }, "display_texture": null } ] } ] }304Unchanged; reuse the cached body.401Token missing, malformed, unknown, or revoked.500Library or texture signing could not be completed.
curl https://app.basegrid.com.au/api/v1/material-library \ -H "Authorization: Bearer bgc_…"
/api/v1/takeoff-groupsBearer tokenThe organisation's takeoff groups
The workspace-wide list of headings a takeoff is organised under, in the estimator's order. Use abbreviation for compact item codes and column headings.
| Field | Type | Notes |
|---|---|---|
| If-None-Match | string | The ETag from a previous 200. Returns 304 when unchanged. |
200The groups, withETag.{ "api_version": "v1", "organisation": { "id": "uuid", "name": "Example Builders" }, "takeoff_groups": [ { "id": "uuid", "name": "Wall Framing", "abbreviation": "WF", "description": "Studs, plates, noggins and lintels.", "sort_index": 3, "updated_at": "2026-08-27T04:05:06.000Z" } ] }304Unchanged; reuse the cached body.401Token missing, malformed, unknown, or revoked.500The groups could not be loaded.
curl https://app.basegrid.com.au/api/v1/takeoff-groups \ -H "Authorization: Bearer bgc_…"
Authentication
Session endpoints for the web app. A successful sign-in sets Supabase auth cookies on the response; browsers send them automatically to every other session-authenticated endpoint. Sign-up creates the user, their organisation and an owner membership in one step.
/api/auth/signupPublicCreate an account and organisation
| Field | Type | Notes |
|---|---|---|
| emailrequired | string | Lower-cased and trimmed. |
| passwordrequired | string | At least 8 characters. |
| fullNamerequired | string | Up to 120 characters. |
| orgName | string | Workspace name. Defaults to fullName. |
201Signed in immediately.{ "redirectTo": "/app" }202Email confirmation required before the session exists.{ "notice": "Check you@example.com — we've sent a link to confirm your account." }400Validation failed or the email is taken.{ "error": "Human-readable message." }
/api/auth/loginPublicSign in with email and password
| Field | Type | Notes |
|---|---|---|
| emailrequired | string | |
| passwordrequired | string | |
| next | string | Same-origin path to return to. Anything else falls back to /app. |
200Cookies set.{ "redirectTo": "/app" }401Those details didn't match an account.{ "error": "Human-readable message." }
curl https://app.basegrid.com.au/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"you@example.com","password":"…"}' -c cookies.txt/api/auth/logoutPublicSign out
200Session cleared.{ "redirectTo": "/login" }
Takeoff groups
An organisation-level library of headings a takeoff is organised under — Framing, External Cladding, Roofing. Names are unique per organisation (case-insensitive); abbreviations are optional, 1–8 uppercase letters or digits, and unique where set. Managed at /app/takeoff-groups.
/api/takeoff-groupsSessionList takeoff groups
200Ordered bysort_index, then name.{ "takeoffGroups": [ { "id": "uuid", "org_id": "uuid", "name": "Wall Framing", "abbreviation": "WF", "description": null, "sort_index": 3, "created_at": "2026-08-27T…", "updated_at": "2026-08-27T…" } ] }
/api/takeoff-groupsSessionCreate a takeoff group
New groups are appended to the end of the order.
| Field | Type | Notes |
|---|---|---|
| namerequired | string | 1–120 characters. |
| abbreviation | string | null | Up to 8 letters or digits; upper-cased on save. |
| description | string | null | Up to 2000 characters. |
201Created.{ "takeoffGroup": { … } }409Name or abbreviation already used.{ "error": "Human-readable message." }
curl https://app.basegrid.com.au/api/takeoff-groups -b cookies.txt \
-H "Content-Type: application/json" \
-d '{"name":"Wall Framing","abbreviation":"WF"}'/api/takeoff-groups/{id}SessionUpdate a takeoff group
Send only the fields to change. At least one is required.
| Field | Type | Notes |
|---|---|---|
| name | string | |
| abbreviation | string | null | Null clears it. |
| description | string | null | Null clears it. |
| sort_index | integer | Position in the list, from 0. |
| status | active | retired | Retired groups stay resolvable but cannot receive new tool assignments. |
200Updated.{ "takeoffGroup": { … } }404Not found in your organisation.{ "error": "Human-readable message." }409Name or abbreviation already used.{ "error": "Human-readable message." }
/api/takeoff-groups/{id}SessionDelete a takeoff group
200Deleted (or already gone).{ "deleted": true }
SketchUp tools
SketchUp tools are global product definitions with stable ids (concrete.slab_from_face, structure.beam, timber.wall_frame). A tool runs with one takeoff group selected and every item it creates inherits that group. Each organisation chooses which of its groups a tool may offer — several can be made available, empty by default, managed at /app/tools or from a takeoff group's form. New groups are never linked automatically. The extension reads the result from GET /api/v1/material-library.
/api/sketchup-toolsSessionThe tool catalogue with this organisation's available groups
200Every tool with its assigned group ids.rolesis the compatibility view for older extensions and mirrors the tool's groups.{ "tools": [ { "id": "concrete.slab_from_face", "name": "Concrete Slab", "status": "active", "description": "…", "takeoff_group_ids": ["uuid"], "roles": [ { "id": "concrete.slab_from_face.slab_body", "name": "Slab body", "unit": "m3", "takeoff_group_ids": ["uuid"] } ] }, { "id": "structure.beam", "name": "Beam", "status": "active", "description": "…", "takeoff_group_ids": [], "roles": [] } ] }
/api/sketchup-tools/{toolId}/takeoff-groupsSession · owner or adminReplace the groups a tool may offer
Sets the complete list for the caller's organisation. Groups must belong to your organisation and be active; a retired group may be kept if it is already assigned but cannot be newly added. Changes the material-library ETag.
| Field | Type | Notes |
|---|---|---|
| takeoff_group_idsrequired | uuid[] | The complete list of available groups. |
200Saved.{ "tool_id": "concrete.slab_from_face", "takeoff_group_ids": ["uuid"] }400Unknown group, or a retired group newly added.{ "error": "Human-readable message." }404Unknown tool.{ "error": "Human-readable message." }
curl -X PUT https://app.basegrid.com.au/api/sketchup-tools/concrete.slab_from_face/takeoff-groups -b cookies.txt -H "Content-Type: application/json" -d '{"takeoff_group_ids":["<uuid>"]}'/api/sketchup-tools/roles/{roleId}/takeoff-groupsSession · owner or adminDeprecated: replace a role's groups
Retained for the extension's compatibility window. Configuration is now per tool, so this writes to the role's tool — identical to PUT /api/sketchup-tools/{toolId}/takeoff-groups — and will be removed once the extension migration is confirmed.
| Field | Type | Notes |
|---|---|---|
| takeoff_group_idsrequired | uuid[] | The complete list. |
200Saved.{ "role_id": "concrete.slab_from_face.slab_body", "tool_id": "concrete.slab_from_face", "takeoff_group_ids": ["uuid"] }400Unknown group, or a retired group newly added.{ "error": "Human-readable message." }404Unknown role.{ "error": "Human-readable message." }
Materials
A material type owns the shape (profile) and unit; each material under it carries the values — dimensions in millimetres, grade and finish — plus a construction texture and an optional display texture. Profiles: rectangular, sheet, round, i_section, rhs, shs, chs, flashing, bar_group, mesh, bulk. Units: m, m2, m3, each. Which dimensions a profile requires is documented in docs/MATERIAL_MODEL.md.
/api/material-typesSessionCreate a material type
| Field | Type | Notes |
|---|---|---|
| namerequired | string | 1–120 characters, unique per organisation. |
| profilerequired | enum | One of the profiles above. |
| uomrequired | enum | m, m2, m3 or each. |
| density_kg_m3 | number | null | Derives mass for every size under the type. |
| notes | string | Up to 2000 characters. |
201Created.{ "materialType": { … }, "redirectTo": "/app/materials/<id>" }409A type with that name exists.{ "error": "Human-readable message." }
/api/material-types/{id}SessionRename a material type
| Field | Type | Notes |
|---|---|---|
| namerequired | string | 1–120 characters. |
200Renamed.{ "materialType": { … } }404Not found in your organisation.{ "error": "Human-readable message." }409A type with that name exists.{ "error": "Human-readable message." }
/api/material-types/{id}SessionDelete a material type and its materials
200Deleted.{ "deleted": true, "redirectTo": "/app/materials" }
/api/material-types/{id}/materialsSessionAdd a material to a type
Multipart form. Dimension fields are accepted only if the type's profile uses them, and the profile's required dimensions must be present. Appearance fields describe the construction texture; repeat them with a display_ prefix for the optional display texture. Images must be JPEG, PNG or WebP and 256 KB or smaller.
| Field | Type | Notes |
|---|---|---|
| namerequired | string | Material name, 1–200 characters. |
| grade | string | MGP10, F17, 300PLUS… |
| finish | string | Zincalume, Colorbond… |
| width_mm, depth_mm, thickness_mm, height_mm, diameter_mm, wall_thickness_mm, flange_width_mm, web_thickness_mm, flange_thickness_mm, girth_mm, folds, bars, spacing_mm | number | Positive. Which apply depends on the profile. |
| mass_per_uom | number | Overrides the density-derived mass. |
| appearance_mode | image | color | Defaults to image. |
| texture | file | Required when appearance_mode=image. |
| texture_aspect_ratio | number | Width ÷ height of the image. |
| texture_width_mm | number | Real-world width the image represents. |
| texture_height_mm | number | Real-world height. |
| texture_size_last | width | height | Which dimension was edited last. |
| texture_color | string | #rrggbb, when appearance_mode=color. |
| display_* | as above | Same fields for the display texture. |
201Created.{ "material": { … } }400Validation or image problem.{ "error": "Human-readable message." }404That material type no longer exists.{ "error": "Human-readable message." }
/api/materials/{id}SessionUpdate a material
Send only the fields to change. Use it to rename, set grade/finish, retire, or assign textures: texture_id (construction) and display_texture_id (display) must be textures in your organisation; null clears them. fit is fitted (stretch to the face) or natural (tile at the texture's physical size); when natural, send the size pair.
| Field | Type | Notes |
|---|---|---|
| name | string | 1–200 characters. |
| grade | string | null | |
| finish | string | null | |
| mass_per_uom | number | null | Overrides the density-derived mass. |
| status | active | retired | |
| texture_id | uuid | null | Construction texture. |
| texture_fit | fitted | natural | |
| texture_width_mm, texture_height_mm | number | null | Send together. |
| texture_rotation | 0 | 90 | |
| display_texture_id | uuid | null | Display texture. |
| display_texture_fit | fitted | natural | |
| display_texture_width_mm, display_texture_height_mm | number | null | Send together. |
| display_texture_rotation | 0 | 90 |
200Updated.{ "material": { … } }404Material or texture not found in your organisation.{ "error": "Human-readable message." }
curl -X PATCH https://app.basegrid.com.au/api/materials/<id> -b cookies.txt \
-H "Content-Type: application/json" \
-d '{"texture_id":"<texture uuid>","texture_fit":"natural","texture_width_mm":200,"texture_height_mm":200}'/api/materials/{id}SessionDelete a material
| Field | Type | Notes |
|---|---|---|
| materialTypeId | uuid | Optional; refreshes that type's page in the app. |
200Deleted.{ "deleted": true }
Textures
A texture is the appearance a material carries into the model. Three kinds: solid — a flat colour; labelled — text rendered server-side to a 512 px PNG (Arimo, Arial-metric) at the physical size you give it, over either a flat colour or an uploaded picture (source_image_url), in which case the colour acts as a tint; image — an uploaded photo with no text, created through the material form. Solid and labelled textures are fully editable: every formatting field lives in format and the PNG is re-rendered on change, so the picture under a label is kept untouched in storage. Assign a texture to a material with PATCH /api/materials/{id}. Sizes are in millimetres and describe what the swatch represents in the real world, so a natural-fit material tiles it correctly.
/api/texturesSessionList textures
| Field | Type | Notes |
|---|---|---|
| kind | solid | labelled | image | Optional filter. |
200Ordered by name.{ "textures": [ { "id": "uuid", "name": "40MPa", "kind": "labelled", "image_url": "material-textures/<org>/<file>.png", "source_image_url": null, "color": null, "aspect_ratio": 1, "width_mm": 200, "height_mm": 200, "format": { "background": "#b8b8b8", "background_opacity": 1, "text": "40MPa", "text_color": "#101614", "font_family": "sans", "font_size_percent": 18, "font_weight": 400, "text_align": "center", "vertical_align": "middle", "line_height": 1.05, "padding_percent": 8 }, "created_at": "…", "updated_at": "…" } ] }
/api/texturesSessionCreate a solid or labelled texture
Formatting fields are optional and default to the values shown in the list example (centred, 18 % of height, weight 400, ink #101614, 8 % padding). Use \n in text for multiple lines. Labelled textures need text, width_mm and height_mm; the image is rendered 512 px on its long side and must come out under 256 KB.
| Field | Type | Notes |
|---|---|---|
| kindrequired | solid | labelled | |
| name | string | Defaults to the text (labelled) or Colour #RRGGBB (solid). |
| backgroundrequired | string | #rrggbb. The fill, or the tint over an image. |
| background_opacity | number | 0–1. Default 1, or 0 when an image is present. |
| text | string | null | Required for labelled. Up to 200 characters. |
| text_color | string | #rrggbb. Default #101614. |
| font_family | sans | mono | Default sans (Arimo, Arial-metric). |
| font_size_percent | number | 4–60, % of image height. Default 18. Shrinks to fit. |
| font_weight | 400 | 600 | 700 | Default 400. |
| text_align | left | center | right | Default center. |
| vertical_align | top | middle | bottom | Default middle. |
| line_height | number | 0.8–2. Default 1.05. |
| padding_percent | number | 0–40. Default 8. |
| width_mm, height_mm | number | Physical size. Both or neither; required for labelled. |
201Created.{ "texture": { … } }400Validation failed.{ "error": "Human-readable message." }500Rendering or upload failed.{ "error": "Human-readable message." }
curl https://app.basegrid.com.au/api/textures -b cookies.txt \
-H "Content-Type: application/json" \
-d '{"kind":"labelled","text":"40MPa","background":"#b8b8b8","width_mm":200,"height_mm":200}'
# Text over a picture
curl https://app.basegrid.com.au/api/textures -b cookies.txt -F kind=labelled -F text="Exposed Aggregate" -F background=#000000 -F background_opacity=0.25 -F width_mm=600 -F height_mm=600 -F image=@exposed-agg.jpg/api/textures/{id}SessionGet a texture
Includes one-hour signed_url (the rendered texture) and source_signed_url (the picture under a label); null where there is no stored file.
200The texture.{ "texture": { …, "signed_url": "https://…", "source_signed_url": null } }404Not found in your organisation.{ "error": "Human-readable message." }
/api/textures/{id}SessionEdit a texture's formatting, size, picture or name
Any subset of the create fields except kind, as JSON or multipart. Solid and labelled textures are re-rendered (a label over a picture is re-composited from the stored source) and superseded files removed; materials using the texture pick up the change. Uploaded image-kind textures accept only name.
| Field | Type | Notes |
|---|---|---|
| name, background, text, … | as for create | Any formatting or size field. |
| image | file | Multipart only. Replaces the picture under a labelled texture. |
| remove_image | boolean | Drop the picture and render on the flat colour instead. |
200Updated.{ "texture": { … } }404Not found in your organisation.{ "error": "Human-readable message." }409Formatting sent for an uploaded image.{ "error": "Human-readable message." }
curl -X PATCH https://app.basegrid.com.au/api/textures/<id> -b cookies.txt \
-H "Content-Type: application/json" \
-d '{"text":"40MPa\nN40","font_weight":600,"background":"#b0b0ab"}'/api/textures/{id}SessionDelete a texture
Materials and types that used it fall back to no texture.
200Deleted.{ "deleted": true }
Projects and takeoff items
A project holds one quote. Takeoff items belong to a project; rate is sent in dollars and stored as rate_cents, and total_cents is computed by the database.
/api/projectsSessionCreate a project
| Field | Type | Notes |
|---|---|---|
| coderequired | string | Your job code, up to 24 characters, unique per organisation. |
| namerequired | string | Up to 200 characters. |
| address | string | Up to 300 characters. |
201Created.{ "project": { "id": "uuid", "code": "029BRO", "name": "…", "address": "…" }, "redirectTo": "/app/projects/<id>" }409Project code already in use.{ "error": "Human-readable message." }
/api/projects/{id}/takeoff-itemsSessionAdd a takeoff item
| Field | Type | Notes |
|---|---|---|
| code | string | Cost code, up to 40 characters. |
| materialrequired | string | Up to 300 characters. |
| qtyrequired | number | Zero or more. |
| unit | string | Up to 16 characters, e.g. m, m2, each. |
| raterequired | number | Dollars; stored as cents. |
201Created, tagged, sourcemanual.{ "item": { … } }
/api/projects/{id}/takeoff-items/{itemId}SessionRemove a takeoff item
200Deleted.{ "deleted": true }
Workspace
Organisation settings and invitations. Owners and admins only. An invite carries a token; the person joins by opening /invite/{token}, which either signs them up straight into this workspace or, if they already have an account, adds them to it. Email delivery is not wired up, so the link is sent by hand. Invites expire after 14 days and can only be used once.
/api/orgSession · owner or adminRename the workspace
| Field | Type | Notes |
|---|---|---|
| namerequired | string | 1–120 characters. |
200Renamed.{ "org": { "id": "uuid", "name": "…" } }
/api/org/invitesSession · owner or adminInvite someone
| Field | Type | Notes |
|---|---|---|
| emailrequired | string | |
| role | owner | admin | member | Defaults to member. Only an owner may invite an owner. |
201Invite created.tokenis the only delivery mechanism — put it in /invite/{token} and send that.{ "invite": { "id": "…", "email": "…", "role": "member", "token": "…", "expires_at": "…" } }409That person already has a pending invite.{ "error": "Human-readable message." }
/api/org/invites/{id}Session · owner or adminWithdraw an invite
200Deleted.{ "deleted": true }
Billing
Subscription management through Stripe. Both endpoints return a URL to send the browser to.
/api/billing/checkoutSession · owner or adminStart a subscription checkout
200Stripe Checkout URL.{ "url": "https://checkout.stripe.com/…" }503Stripe is not configured.{ "error": "Human-readable message." }
/api/billing/portalSession · owner or adminOpen the customer portal
200Stripe portal URL.{ "url": "https://billing.stripe.com/…" }409No Stripe customer for this workspace yet.{ "error": "Human-readable message." }503Stripe is not configured.{ "error": "Human-readable message." }
/api/stripe/webhookStripe signatureStripe webhook receiver
Receives subscription lifecycle events and writes billing state to the organisation with the service role. Not for client use.
200Event handled.