# Basegrid API

<!-- Generated from lib/api-reference.ts by `npm run docs:api`. Do not edit by hand. -->

Browse this reference at https://app.basegrid.com.au/docs/api. Base URL `https://app.basegrid.com.au`.

## Authentication

| Scheme | How it works |
| --- | --- |
| Public | No credentials required. |
| 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. |
| 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. |
| Stripe signature | Called by Stripe only; verified with the webhook signing secret. |

## Endpoints

- `GET /api/v1/oauth/config` — OAuth discovery for the SketchUp client
- `GET /api/v1/material-library` — The organisation's material library
- `GET /api/v1/takeoff-groups` — The organisation's takeoff groups
- `POST /api/auth/signup` — Create an account and organisation
- `POST /api/auth/login` — Sign in with email and password
- `POST /api/auth/logout` — Sign out
- `GET /api/takeoff-groups` — List takeoff groups
- `POST /api/takeoff-groups` — Create a takeoff group
- `PATCH /api/takeoff-groups/{id}` — Update a takeoff group
- `DELETE /api/takeoff-groups/{id}` — Delete a takeoff group
- `GET /api/sketchup-tools` — The tool catalogue with this organisation's available groups
- `PUT /api/sketchup-tools/{toolId}/takeoff-groups` — Replace the groups a tool may offer
- `PUT /api/sketchup-tools/roles/{roleId}/takeoff-groups` — Deprecated: replace a role's groups
- `POST /api/material-types` — Create a material type
- `PATCH /api/material-types/{id}` — Rename a material type
- `DELETE /api/material-types/{id}` — Delete a material type and its materials
- `POST /api/material-types/{id}/materials` — Add a material to a type
- `PATCH /api/materials/{id}` — Update a material
- `DELETE /api/materials/{id}` — Delete a material
- `GET /api/textures` — List textures
- `POST /api/textures` — Create a solid or labelled texture
- `GET /api/textures/{id}` — Get a texture
- `PATCH /api/textures/{id}` — Edit a texture's formatting, size, picture or name
- `DELETE /api/textures/{id}` — Delete a texture
- `POST /api/projects` — Create a project
- `POST /api/projects/{id}/takeoff-items` — Add a takeoff item
- `DELETE /api/projects/{id}/takeoff-items/{itemId}` — Remove a takeoff item
- `PATCH /api/org` — Rename the workspace
- `POST /api/org/invites` — Invite someone
- `DELETE /api/org/invites/{id}` — Withdraw an invite
- `POST /api/billing/checkout` — Start a subscription checkout
- `POST /api/billing/portal` — Open the customer portal
- `POST /api/stripe/webhook` — Stripe webhook receiver

## 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.

### `GET /api/v1/oauth/config`

**OAuth discovery for the SketchUp client** · Auth: Public

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.

Responses:

- `200` — Discovery document. Never cached.

  ```json
  {
    "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"
  }
  ```


### `GET /api/v1/material-library`

**The organisation's material library** · Auth: Bearer token

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.

Headers:

| Field | Type | Notes |
| --- | --- | --- |
| `If-None-Match` | string | The `ETag` from a previous 200. Returns 304 when unchanged. |

Responses:

- `200` — The library, with `ETag`.

  ```json
  {
    "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
          }
        ]
      }
    ]
  }
  ```

- `304` — Unchanged; reuse the cached body.
- `401` — Token missing, malformed, unknown, or revoked.
- `500` — Library or texture signing could not be completed.

Example:

```sh
curl https://app.basegrid.com.au/api/v1/material-library \
  -H "Authorization: Bearer bgc_…"
```

### `GET /api/v1/takeoff-groups`

**The organisation's takeoff groups** · Auth: Bearer token

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.

Headers:

| Field | Type | Notes |
| --- | --- | --- |
| `If-None-Match` | string | The `ETag` from a previous 200. Returns 304 when unchanged. |

Responses:

- `200` — The groups, with `ETag`.

  ```json
  {
    "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"
      }
    ]
  }
  ```

- `304` — Unchanged; reuse the cached body.
- `401` — Token missing, malformed, unknown, or revoked.
- `500` — The groups could not be loaded.

Example:

```sh
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.

### `POST /api/auth/signup`

**Create an account and organisation** · Auth: Public

Request body (application/json):

| Field | Type | Notes |
| --- | --- | --- |
| `email` *(required)* | string | Lower-cased and trimmed. |
| `password` *(required)* | string | At least 8 characters. |
| `fullName` *(required)* | string | Up to 120 characters. |
| `orgName` | string | Workspace name. Defaults to `fullName`. |

Responses:

- `201` — Signed in immediately.

  ```json
  { "redirectTo": "/app" }
  ```

- `202` — Email confirmation required before the session exists.

  ```json
  { "notice": "Check you@example.com — we've sent a link to confirm your account." }
  ```

- `400` — Validation failed or the email is taken.

  ```json
  { "error": "Human-readable message." }
  ```


### `POST /api/auth/login`

**Sign in with email and password** · Auth: Public

Request body (application/json):

| Field | Type | Notes |
| --- | --- | --- |
| `email` *(required)* | string |  |
| `password` *(required)* | string |  |
| `next` | string | Same-origin path to return to. Anything else falls back to `/app`. |

Responses:

- `200` — Cookies set.

  ```json
  { "redirectTo": "/app" }
  ```

- `401` — Those details didn't match an account.

  ```json
  { "error": "Human-readable message." }
  ```


Example:

```sh
curl https://app.basegrid.com.au/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"you@example.com","password":"…"}' -c cookies.txt
```

### `POST /api/auth/logout`

**Sign out** · Auth: Public

Responses:

- `200` — Session cleared.

  ```json
  { "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.

### `GET /api/takeoff-groups`

**List takeoff groups** · Auth: Session

Responses:

- `200` — Ordered by `sort_index`, then name.

  ```json
  {
    "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…" }
    ]
  }
  ```


### `POST /api/takeoff-groups`

**Create a takeoff group** · Auth: Session

New groups are appended to the end of the order.

Request body (application/json):

| Field | Type | Notes |
| --- | --- | --- |
| `name` *(required)* | string | 1–120 characters. |
| `abbreviation` | string \| null | Up to 8 letters or digits; upper-cased on save. |
| `description` | string \| null | Up to 2000 characters. |

Responses:

- `201` — Created.

  ```json
  { "takeoffGroup": { … } }
  ```

- `409` — Name or abbreviation already used.

  ```json
  { "error": "Human-readable message." }
  ```


Example:

```sh
curl https://app.basegrid.com.au/api/takeoff-groups -b cookies.txt \
  -H "Content-Type: application/json" \
  -d '{"name":"Wall Framing","abbreviation":"WF"}'
```

### `PATCH /api/takeoff-groups/{id}`

**Update a takeoff group** · Auth: Session

Send only the fields to change. At least one is required.

Request body (application/json):

| 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. |

Responses:

- `200` — Updated.

  ```json
  { "takeoffGroup": { … } }
  ```

- `404` — Not found in your organisation.

  ```json
  { "error": "Human-readable message." }
  ```

- `409` — Name or abbreviation already used.

  ```json
  { "error": "Human-readable message." }
  ```


### `DELETE /api/takeoff-groups/{id}`

**Delete a takeoff group** · Auth: Session

Responses:

- `200` — Deleted (or already gone).

  ```json
  { "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`.

### `GET /api/sketchup-tools`

**The tool catalogue with this organisation's available groups** · Auth: Session

Responses:

- `200` — Every tool with its assigned group ids. `roles` is the compatibility view for older extensions and mirrors the tool's groups.

  ```json
  {
    "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": [] }
    ]
  }
  ```


### `PUT /api/sketchup-tools/{toolId}/takeoff-groups`

**Replace the groups a tool may offer** · Auth: Session · owner or admin

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.

Request body (application/json):

| Field | Type | Notes |
| --- | --- | --- |
| `takeoff_group_ids` *(required)* | uuid[] | The complete list of available groups. |

Responses:

- `200` — Saved.

  ```json
  { "tool_id": "concrete.slab_from_face", "takeoff_group_ids": ["uuid"] }
  ```

- `400` — Unknown group, or a retired group newly added.

  ```json
  { "error": "Human-readable message." }
  ```

- `404` — Unknown tool.

  ```json
  { "error": "Human-readable message." }
  ```


Example:

```sh
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>"]}'
```

### `PUT /api/sketchup-tools/roles/{roleId}/takeoff-groups`

**Deprecated: replace a role's groups** · Auth: Session · owner or admin

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.

Request body (application/json):

| Field | Type | Notes |
| --- | --- | --- |
| `takeoff_group_ids` *(required)* | uuid[] | The complete list. |

Responses:

- `200` — Saved.

  ```json
  { "role_id": "concrete.slab_from_face.slab_body", "tool_id": "concrete.slab_from_face", "takeoff_group_ids": ["uuid"] }
  ```

- `400` — Unknown group, or a retired group newly added.

  ```json
  { "error": "Human-readable message." }
  ```

- `404` — Unknown role.

  ```json
  { "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.

### `POST /api/material-types`

**Create a material type** · Auth: Session

Request body (application/json):

| Field | Type | Notes |
| --- | --- | --- |
| `name` *(required)* | string | 1–120 characters, unique per organisation. |
| `profile` *(required)* | enum | One of the profiles above. |
| `uom` *(required)* | 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. |

Responses:

- `201` — Created.

  ```json
  { "materialType": { … }, "redirectTo": "/app/materials/<id>" }
  ```

- `409` — A type with that name exists.

  ```json
  { "error": "Human-readable message." }
  ```


### `PATCH /api/material-types/{id}`

**Rename a material type** · Auth: Session

Request body (application/json):

| Field | Type | Notes |
| --- | --- | --- |
| `name` *(required)* | string | 1–120 characters. |

Responses:

- `200` — Renamed.

  ```json
  { "materialType": { … } }
  ```

- `404` — Not found in your organisation.

  ```json
  { "error": "Human-readable message." }
  ```

- `409` — A type with that name exists.

  ```json
  { "error": "Human-readable message." }
  ```


### `DELETE /api/material-types/{id}`

**Delete a material type and its materials** · Auth: Session

Responses:

- `200` — Deleted.

  ```json
  { "deleted": true, "redirectTo": "/app/materials" }
  ```


### `POST /api/material-types/{id}/materials`

**Add a material to a type** · Auth: Session

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.

Request body (multipart/form-data):

| Field | Type | Notes |
| --- | --- | --- |
| `name` *(required)* | 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. |

Responses:

- `201` — Created.

  ```json
  { "material": { … } }
  ```

- `400` — Validation or image problem.

  ```json
  { "error": "Human-readable message." }
  ```

- `404` — That material type no longer exists.

  ```json
  { "error": "Human-readable message." }
  ```


### `PATCH /api/materials/{id}`

**Update a material** · Auth: Session

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.

Request body (application/json):

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

Responses:

- `200` — Updated.

  ```json
  { "material": { … } }
  ```

- `404` — Material or texture not found in your organisation.

  ```json
  { "error": "Human-readable message." }
  ```


Example:

```sh
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}'
```

### `DELETE /api/materials/{id}`

**Delete a material** · Auth: Session

Query parameters:

| Field | Type | Notes |
| --- | --- | --- |
| `materialTypeId` | uuid | Optional; refreshes that type's page in the app. |

Responses:

- `200` — Deleted.

  ```json
  { "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.

### `GET /api/textures`

**List textures** · Auth: Session

Query parameters:

| Field | Type | Notes |
| --- | --- | --- |
| `kind` | `solid` \| `labelled` \| `image` | Optional filter. |

Responses:

- `200` — Ordered by name.

  ```json
  {
    "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": "…"
      }
    ]
  }
  ```


### `POST /api/textures`

**Create a solid or labelled texture** · Auth: Session

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.

Request body (application/json):

| Field | Type | Notes |
| --- | --- | --- |
| `kind` *(required)* | `solid` \| `labelled` |  |
| `name` | string | Defaults to the text (labelled) or `Colour #RRGGBB` (solid). |
| `background` *(required)* | 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. |

Responses:

- `201` — Created.

  ```json
  { "texture": { … } }
  ```

- `400` — Validation failed.

  ```json
  { "error": "Human-readable message." }
  ```

- `500` — Rendering or upload failed.

  ```json
  { "error": "Human-readable message." }
  ```


Example:

```sh
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
```

### `GET /api/textures/{id}`

**Get a texture** · Auth: Session

Includes one-hour `signed_url` (the rendered texture) and `source_signed_url` (the picture under a label); `null` where there is no stored file.

Responses:

- `200` — The texture.

  ```json
  { "texture": { …, "signed_url": "https://…", "source_signed_url": null } }
  ```

- `404` — Not found in your organisation.

  ```json
  { "error": "Human-readable message." }
  ```


### `PATCH /api/textures/{id}`

**Edit a texture's formatting, size, picture or name** · Auth: Session

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`.

Request body (application/json):

| 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. |

Responses:

- `200` — Updated.

  ```json
  { "texture": { … } }
  ```

- `404` — Not found in your organisation.

  ```json
  { "error": "Human-readable message." }
  ```

- `409` — Formatting sent for an uploaded image.

  ```json
  { "error": "Human-readable message." }
  ```


Example:

```sh
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"}'
```

### `DELETE /api/textures/{id}`

**Delete a texture** · Auth: Session

Materials and types that used it fall back to no texture.

Responses:

- `200` — Deleted.

  ```json
  { "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.

### `POST /api/projects`

**Create a project** · Auth: Session

Request body (application/json):

| Field | Type | Notes |
| --- | --- | --- |
| `code` *(required)* | string | Your job code, up to 24 characters, unique per organisation. |
| `name` *(required)* | string | Up to 200 characters. |
| `address` | string | Up to 300 characters. |

Responses:

- `201` — Created.

  ```json
  { "project": { "id": "uuid", "code": "029BRO", "name": "…", "address": "…" }, "redirectTo": "/app/projects/<id>" }
  ```

- `409` — Project code already in use.

  ```json
  { "error": "Human-readable message." }
  ```


### `POST /api/projects/{id}/takeoff-items`

**Add a takeoff item** · Auth: Session

Request body (application/json):

| Field | Type | Notes |
| --- | --- | --- |
| `code` | string | Cost code, up to 40 characters. |
| `material` *(required)* | string | Up to 300 characters. |
| `qty` *(required)* | number | Zero or more. |
| `unit` | string | Up to 16 characters, e.g. `m`, `m2`, `each`. |
| `rate` *(required)* | number | Dollars; stored as cents. |

Responses:

- `201` — Created, tagged, source `manual`.

  ```json
  { "item": { … } }
  ```


### `DELETE /api/projects/{id}/takeoff-items/{itemId}`

**Remove a takeoff item** · Auth: Session

Responses:

- `200` — Deleted.

  ```json
  { "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.

### `PATCH /api/org`

**Rename the workspace** · Auth: Session · owner or admin

Request body (application/json):

| Field | Type | Notes |
| --- | --- | --- |
| `name` *(required)* | string | 1–120 characters. |

Responses:

- `200` — Renamed.

  ```json
  { "org": { "id": "uuid", "name": "…" } }
  ```


### `POST /api/org/invites`

**Invite someone** · Auth: Session · owner or admin

Request body (application/json):

| Field | Type | Notes |
| --- | --- | --- |
| `email` *(required)* | string |  |
| `role` | `owner` \| `admin` \| `member` | Defaults to `member`. Only an owner may invite an owner. |

Responses:

- `201` — Invite created. `token` is the only delivery mechanism — put it in /invite/{token} and send that.

  ```json
  { "invite": { "id": "…", "email": "…", "role": "member", "token": "…", "expires_at": "…" } }
  ```

- `409` — That person already has a pending invite.

  ```json
  { "error": "Human-readable message." }
  ```


### `DELETE /api/org/invites/{id}`

**Withdraw an invite** · Auth: Session · owner or admin

Responses:

- `200` — Deleted.

  ```json
  { "deleted": true }
  ```


## Billing

Subscription management through Stripe. Both endpoints return a URL to send the browser to.

### `POST /api/billing/checkout`

**Start a subscription checkout** · Auth: Session · owner or admin

Responses:

- `200` — Stripe Checkout URL.

  ```json
  { "url": "https://checkout.stripe.com/…" }
  ```

- `503` — Stripe is not configured.

  ```json
  { "error": "Human-readable message." }
  ```


### `POST /api/billing/portal`

**Open the customer portal** · Auth: Session · owner or admin

Responses:

- `200` — Stripe portal URL.

  ```json
  { "url": "https://billing.stripe.com/…" }
  ```

- `409` — No Stripe customer for this workspace yet.

  ```json
  { "error": "Human-readable message." }
  ```

- `503` — Stripe is not configured.

  ```json
  { "error": "Human-readable message." }
  ```


### `POST /api/stripe/webhook`

**Stripe webhook receiver** · Auth: Stripe signature

Receives subscription lifecycle events and writes billing state to the organisation with the service role. Not for client use.

Responses:

- `200` — Event handled.

