Log in to Admin

HTTP API (v1)

Machine-readable

OpenAPI 3 (YAML) for clients and AI tools; llms.txt lists canonical URLs. Paths are relative — they work on both the API and Admin hostnames for this deployment.

openapi.yamlllms.txt

Introduction

Versioned public REST API for integrations. Protected endpoints require an organization API key issued by your org admins in Grindzero Admin (Manager → Organization API).

API keys

Each integration uses one organization API key: a long secret; only a hash is stored on the server. Create the key in Admin, copy it once, and store it securely (secrets manager, vault).

Keys use the gz_app_ prefix. Never log the raw secret or return it in error messages.

Rotate by creating a new key and revoking the old one in Admin. Revocation is permanent; the old key stops working immediately.

Request headers

Send the secret either as Authorization: Bearer or as the X-Api-Key header — same raw value; do not send two different values in one request. Bearer is the recommended default (library compatibility).

Bearer (recommended)

Authorization: Bearer gz_app_<your_api_key>

Alternative

X-Api-Key: gz_app_<your_api_key>

Scopes

Allowed scopes are set when the key is created. A typical integration key combines read:orgs and read:data. Individual values:

  • read:orgs — read access for the organization bound to the key (e.g. GET /api/v1/orgs).
  • read:data — read access for sites, surpluses, receiving points, reports, billing, trucks, members, factories, grants, nearby search, geocode, and webhook list.
  • write:orgs:self — partial update of that same org (PATCH /api/v1/orgs/{orgId}) and creating a sub-organization (POST /api/v1/orgs).
  • write:data — operational writes (sites, receiving points, trucks, members, surpluses, factories, grants, support tickets, webhooks).
  • * — allows all scopes (use only when you really need it).

Missing permission returns HTTP 403 with a machine-readable code (e.g. insufficient_scope).

Base URL

In production use https://api.grindzero.app as the host. The versioned catalog lives under /api/v1/...

Rate limits

On protected /api/v1/ routes, Grindzero may enforce a per-API-key budget: about 100 successful requests per 60-second sliding window. When exceeded, the API returns HTTP 429 with JSON field code set to rate_limited (example below).

The limit applies after the key is accepted (scopes and org entitlements). Invalid or missing keys do not consume the same quota as successful authenticated calls.

Example 429 response body:

{
  "error": "Too Many Requests",
  "message": "Rate limit exceeded",
  "code": "rate_limited"
}

AI assistants & automation

At the top of this page you will find quick links to the OpenAPI 3 description (YAML) and a short llms.txt index. They are easier for tools to ingest than HTML alone, and you can import them into editors or API clients.

Practical tips:

  • Import `/openapi.yaml` (full production URL, or the same path on your local dev host) into anything that understands OpenAPI — for example Postman, Insomnia, or a coding agent. It lists paths, headers, and the shared error shape.
  • When prompting a chat-based assistant, give the full OpenAPI URL plus a short recap from this page: base URL, authentication, and rate limits. Never paste a production `gz_app_` secret into an untrusted service or a public thread.
  • Prefer a dedicated test or rotatable key while iterating; handle HTTP `429` so generated polling or scripts do not spin in a tight loop.

Health

Short summaries below; call the API from your integration to see full error payloads and status codes.

GET /api/health

Liveness: plain OK response body with no API key.

Organizations

GET /api/v1/orgs

GET /api/v1/orgs

Returns the organization bound to the API key, including `parent_id` and `children` (direct sub-orgs: `id`, `name`, `status`). `data` has one row. Requires scope `read:orgs` or `*`.

Parameters

No path or query parameters in this v1 version — the URL is always `GET /api/v1/orgs`.

Headers

Send the API key as under Request headers (`Authorization: Bearer …` or `X-Api-Key: …`). This route has no request body; `Content-Type` is not required.

Authorization: Bearer gz_app_<your_api_key>

Successful response (200)

{
  "data": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "name": "Acme Construction",
      "status": "approved",
      "parent_id": null,
      "children": [
        {
          "id": "660e8400-e29b-41d4-a716-446655440111",
          "name": "Acme Concrete Plant",
          "status": "approved"
        }
      ],
      "business_code": "1234567-8",
      "street_address": "Mukulakuja 4 A 9",
      "postal_code": "04300",
      "city": "Tuusula",
      "invoice_email": "billing@acme.example",
      "created_at": "2026-01-15T08:00:00.000Z",
      "updated_at": "2026-08-25T12:00:00.000Z"
    }
  ],
  "meta": {
    "count": 1
  }
}

Fields: `id` (UUID), `name`, `status`, `parent_id` (may be null), `children` (direct sub-orgs), `business_code` (Y-tunnus, may be null), `street_address`, `postal_code`, `city`, `invoice_email` (may be null), `created_at`, `updated_at`. `meta.count` is `1`.

Errors (this route)

  • 404org_not_foundOrganization missing or soft-deleted (`org_not_found`).
  • 500internal_errorUnexpected error while loading (`internal_error`).

You may also receive the usual protected-route responses, for example `401` (missing or invalid key, expired key), `403` when the versioned API is unavailable or not enabled for your organization, `insufficient_scope`, or `429` when rate limited — error bodies use `error`, `message`, and `code`.

Example (curl)

curl -sS "https://api.grindzero.app/api/v1/orgs" \
  -H "Authorization: Bearer API_KEY"

Replace `API_KEY` with your secret; never log the key or commit it to source control.

PATCH /api/v1/orgs/{orgId}

PATCH /api/v1/orgs/{orgId}

Partial update of the organization bound to the API key. Path `orgId` must be that same UUID. Requires scope `write:orgs:self` or `*`.

Path parameters

`orgId`: organization UUID (RFC 4122 string form; URL-encoded if needed). Malformed id → HTTP `400` with `invalid_org_id`. If the id does not match the key’s organization → `403` with `org_id_mismatch`.

Headers

Send `Content-Type: application/json` and authenticate as under Request headers (`Authorization: Bearer …` or `X-Api-Key: …`).

Content-Type: application/json
Authorization: Bearer gz_app_<your_api_key>

Request body

{
  "name": "Acme Construction",
  "business_code": "1234567-8",
  "street_address": "Mukulakuja 4 A 9",
  "postal_code": "04300",
  "city": "Tuusula",
  "invoice_email": "billing@acme.example"
}

JSON object with at least one of: `name` (non-empty, max 500), `business_code`, `street_address`, `postal_code`, `city`, `invoice_email` (email or null). `status` and other admin fields are not writable.

Successful response (200)

{
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "name": "Acme Construction",
    "status": "approved",
    "parent_id": null,
    "children": [
      {
        "id": "660e8400-e29b-41d4-a716-446655440111",
        "name": "Acme Concrete Plant",
        "status": "approved"
      }
    ],
    "business_code": "1234567-8",
    "street_address": "Mukulakuja 4 A 9",
    "postal_code": "04300",
    "city": "Tuusula",
    "invoice_email": "billing@acme.example",
    "created_at": "2026-01-15T08:00:00.000Z",
    "updated_at": "2026-08-25T12:00:00.000Z"
  }
}

Returns `{ "data": { …organization… } }` with the same fields as `GET /api/v1/orgs` (including `updated_at`).

Errors (this route)

  • 400invalid_org_idPath is not a valid UUID.
  • 400invalid_jsonBody is not valid JSON.
  • 422validation_errorNo writable fields, empty `name`, invalid `invoice_email`, or a string longer than 500 characters (`validation_error`).
  • 403org_id_mismatchPath `orgId` does not match this API key’s organization (`org_id_mismatch`).
  • 404org_not_foundOrganization missing or soft-deleted (`org_not_found`).
  • 500internal_errorUnexpected error while updating (`internal_error`).

You may also receive the usual protected-route responses, for example `401` (missing or invalid key, expired key), `403` when the versioned API is unavailable or not enabled for your organization, `insufficient_scope`, or `429` when rate limited — error bodies use `error`, `message`, and `code`.

Example (curl)

curl -sS -X PATCH "https://api.grindzero.app/api/v1/orgs/ORG_ID" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer API_KEY" \
  -d '{"name":"Acme Construction","city":"Tuusula"}'

Replace `ORG_ID` with your organization id (must match the key) and `API_KEY` with your secret; never log the key or commit it to source control.

GET /api/v1/orgs/{orgId}

The key org or a direct child. Other orgs → `404` `org_not_found`. Scope `read:orgs` or `*`.

curl -sS "https://api.grindzero.app/api/v1/orgs/ORG_ID" \
  -H "Authorization: Bearer API_KEY"

POST /api/v1/orgs

Creates a sub-organization under the key org. Does not create a top-level parent. Optional `parent_id` must be the key org. Copies the parent’s active `org_admin` memberships onto the child. Scope `write:orgs:self` or `*`.

{
  "name": "Acme Concrete Plant",
  "city": "Vantaa"
}

Required: `name`. Optional: `parent_id`, `business_code`, `street_address`, `postal_code`, `city`, `invoice_email`. `409` `org_name_conflict` if the name is already taken.

curl -sS -X POST "https://api.grindzero.app/api/v1/orgs" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer API_KEY" \
  -d '{"name":"Acme Concrete Plant","city":"Vantaa"}'

Sites

GET /api/v1/sites

Returns worksites owned by the API key's organization. By default only active (non-archived) sites are returned. Requires scope `read:data` or `*`.

Parameters

`?status=active` (default) — active sites only. `?status=all` — includes archived. Ordered by creation date descending.

Successful response (200)

{
  "data": [
    {
      "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "name": "Helsinki Central",
      "status": "active",
      "city": "Helsinki",
      "coordinates": { "lat": 60.1699, "lng": 24.9384 },
      "created_at": "2026-03-01T10:00:00.000Z"
    }
  ],
  "meta": { "count": 1 }
}

Fields: `id` (UUID), `name`, `status` (`active`/`archived`), `city` (may be `null`), `coordinates` (`{ lat, lng }` or `null`), `created_at` (ISO 8601). `meta.count` is the row count.

Example (curl)

curl -sS "https://api.grindzero.app/api/v1/sites" \
  -H "Authorization: Bearer API_KEY"

Replace `API_KEY` with your secret. Add `?status=all` to include archived sites.

You may also receive the usual protected-route responses, for example `401` (missing or invalid key, expired key), `403` when the versioned API is unavailable or not enabled for your organization, `insufficient_scope`, or `429` when rate limited — error bodies use `error`, `message`, and `code`.

POST /api/v1/sites

Creates a worksite for the key's organization. Requires scope `write:data` or `*`. JSON is snake_case. Optional `customer_id` must match the key org (omit to default to it). `DELETE` archives; `POST …/restore` unarchives. Hard delete is not on the public API.

Request body

{
  "name": "Helsinki Central",
  "street_address": "Mannerheimintie 1",
  "postal_code": "00100",
  "city": "Helsinki",
  "lat": 60.1699,
  "lng": 24.9384,
  "metadata": { "aluetunnus": "A-12", "tahtialue": "west" }
}

Required: `name`. Optional: `street_address`, `postal_code`, `city`, `lat`, `lng`, `construction_id`, `external_id`, `metadata` (string key-value object), `contact_first_name`, `contact_last_name`, `contact_phone`, `info`, `site_role` (`construction_site` | `reception_site`), `type`.

curl -sS -X POST "https://api.grindzero.app/api/v1/sites" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer API_KEY" \
  -d '{"name":"Helsinki Central","city":"Helsinki"}'

GET /api/v1/sites/{siteId}

Returns one worksite (including archived) plus `members` and `metadata`. Requires scope `read:data` or `*`.

curl -sS "https://api.grindzero.app/api/v1/sites/SITE_ID" \
  -H "Authorization: Bearer API_KEY"

PATCH /api/v1/sites/{siteId}

Partial update. `customer_id` cannot be changed. `metadata` replaces the whole object. `409` if archived. Scope `write:data` or `*`.

curl -sS -X PATCH "https://api.grindzero.app/api/v1/sites/SITE_ID" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer API_KEY" \
  -d '{"name":"Helsinki Central A"}'

DELETE /api/v1/sites/{siteId}

Archives the worksite (`deleted_at`). `409` if already archived. Scope `write:data` or `*`.

curl -sS -X DELETE "https://api.grindzero.app/api/v1/sites/SITE_ID" \
  -H "Authorization: Bearer API_KEY"

POST /api/v1/sites/{siteId}/restore

Clears the archive. `409` if the worksite is not archived. Scope `write:data` or `*`.

curl -sS -X POST "https://api.grindzero.app/api/v1/sites/SITE_ID/restore" \
  -H "Authorization: Bearer API_KEY"

GET /api/v1/sites/{siteId}/members

Worksite people (`site_members`): first name, last name, phone, role. No email. The same list is on `GET /sites/{id}` as `members`. Scope `read:data` or `*`.

curl -sS "https://api.grindzero.app/api/v1/sites/SITE_ID/members" \
  -H "Authorization: Bearer API_KEY"

Surpluses

GET /api/v1/surpluses

Returns surplus orders from the organization's worksites. Uses keyset (cursor-based) pagination. Requires scope `read:data` or `*`.

Parameters

`?status=open|delivered|expired|all` (default `all`). `?site_id=UUID` filters by worksite. `?limit=1–100` (default `50`), `?cursor=<previous meta.next_cursor>`.

Successful response (200)

{
  "data": [
    {
      "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
      "site_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "to_site_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
      "to_receiving_point_id": "cccccccc-dddd-eeee-ffff-000000000000",
      "volume_m3": 8.5,
      "status": "delivered",
      "additive": true,
      "additive_billed_to": "origin_site",
      "material_grade": "C30/37",
      "manufacturer": "Rudus",
      "description": "Pump load remainder",
      "notes": null,
      "created_at": "2026-05-20T14:30:00.000Z",
      "delivered_at": "2026-05-20T16:00:00.000Z",
      "waybill": {
        "storage_path": "surpluses/b2c3d4e5-f6a7-8901-bcde-f12345678901/1710000000.jpg",
        "url": "https://example.supabase.co/storage/v1/object/sign/media/surpluses/…",
        "url_expires_at": "2026-08-26T12:00:00.000Z"
      }
    }
  ],
  "meta": { "count": 1, "next_cursor": null }
}

Fields: `id`, `site_id`, `to_site_id`, `to_receiving_point_id`, `volume_m3`, `status`, `additive`, `additive_billed_to` (`origin_site`|`truck`), `material_grade`, `manufacturer`, `description`, `notes` (`ai_metadata`), `created_at`, `delivered_at`, `waybill` (`{ storage_path, url, url_expires_at }` or `null`; URL ~24 h). `meta.count` is the page row count, `meta.next_cursor` is the next-page cursor (`null` on the last page).

Example (curl)

curl -sS "https://api.grindzero.app/api/v1/surpluses?status=open&limit=10" \
  -H "Authorization: Bearer API_KEY"

Replace `API_KEY` with your secret. Add `?status=open` to get only open surpluses.

GET /api/v1/surpluses/{surplusId}

One surplus + signed waybill URL. `404` `surplus_not_found` if the origin worksite is not in this org or the row is archived. Scope `read:data` or `*`.

curl -sS "https://api.grindzero.app/api/v1/surpluses/SURPLUS_ID" \
  -H "Authorization: Bearer API_KEY"

GET /api/v1/surpluses/{surplusId}/log

Surplus change log (`surplus_log`): `id`, `created_at`, `status_from`, `status_to`, `message`, `user_id` (nullable). Oldest first. Scope `read:data` or `*`.

curl -sS "https://api.grindzero.app/api/v1/surpluses/SURPLUS_ID/log" \
  -H "Authorization: Bearer API_KEY"

PATCH /api/v1/surpluses/{surplusId}

Partial update matching the org-admin edit form: `volume_m3`, `additive`, `additive_billed_to` (`origin_site`|`truck`), `material_grade`, `manufacturer`, `description`, `notes`, `site_id`, `to_receiving_point_id`, `status`. Not `created_at`/`delivered_at` or truck/driver (superadmin). Route is locked after delivery (`422` `route_locked`). `delivered` requires a waybill. Scope `write:data` or `*`.

{
  "volume_m3": 8.5,
  "material_grade": "C30/37",
  "manufacturer": "Rudus",
  "description": "Read from waybill"
}
curl -sS -X PATCH "https://api.grindzero.app/api/v1/surpluses/SURPLUS_ID" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer API_KEY" \
  -d '{"volume_m3":8.5,"material_grade":"C30/37"}'

POST /api/v1/surpluses

Creates a surplus from an origin worksite. Required: `site_id`, `volume_m3`. Optional: `to_receiving_point_id`, `truck_id`, `additive`, `additive_billed_to` (`origin_site`|`truck`), `material_grade`, `manufacturer`, `description`, `notes`. Truck is not required. Scope `write:data` or `*`.

{
  "site_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "volume_m3": 8.5,
  "to_receiving_point_id": "cccccccc-dddd-eeee-ffff-000000000000"
}

HTTP 201. `truck_id` must be this org's (or a granted partner's) truck. `422` `truck_not_found` / `site_not_found` / `site_archived`.

curl -sS -X POST "https://api.grindzero.app/api/v1/surpluses" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer API_KEY" \
  -d '{"site_id":"SITE_ID","volume_m3":8.5}'

POST /api/v1/surpluses/{surplusId}/waybill

Upload a waybill image (multipart, field `file`). JPEG/PNG/WebP, max 10 MB. Required before `status: delivered`. Scope `write:data` or `*`.

curl -sS -X POST "https://api.grindzero.app/api/v1/surpluses/SURPLUS_ID/waybill" \
  -H "Authorization: Bearer API_KEY" \
  -F "file=@waybill.jpg"

You may also receive the usual protected-route responses, for example `401` (missing or invalid key, expired key), `403` when the versioned API is unavailable or not enabled for your organization, `insufficient_scope`, or `429` when rate limited — error bodies use `error`, `message`, and `code`.

Receiving Points

GET /api/v1/receiving-points

Returns receiving points (dump sites) belonging to the organization's sites. By default only active. Requires scope `read:data` or `*`.

Parameters

`?status=active` (default) — active only. `?status=all` — includes archived. Ordered by creation date descending.

Successful response (200)

{
  "data": [
    {
      "id": "c3d4e5f6-a7b8-9012-cdef-123456789012",
      "name": "Dump Site A",
      "site_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "coordinates": { "lat": 60.2055, "lng": 24.6559 },
      "capacity_m3": 500,
      "is_public": false,
      "status": "active",
      "created_at": "2026-04-10T08:00:00.000Z"
    }
  ],
  "meta": { "count": 1 }
}

Fields: `id` (UUID), `name`, `site_id`, `coordinates` (`{ lat, lng }` or `null`), `capacity_m3` (may be `null`), `is_public` (boolean), `status` (`active`/`archived`), `created_at` (ISO 8601). `meta.count` is the row count.

Example (curl)

curl -sS "https://api.grindzero.app/api/v1/receiving-points" \
  -H "Authorization: Bearer API_KEY"

Replace `API_KEY` with your secret.

You may also receive the usual protected-route responses, for example `401` (missing or invalid key, expired key), `403` when the versioned API is unavailable or not enabled for your organization, `insufficient_scope`, or `429` when rate limited — error bodies use `error`, `message`, and `code`.

POST /api/v1/receiving-points

Creates a receiving point under `site_id` (must belong to the key org). Requires scope `write:data` or `*`. JSON is snake_case. `DELETE` archives; `POST …/restore` unarchives. Photo upload is not on the public API.

Request body

{
  "site_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "name": "Dump Site A",
  "lat": 60.2055,
  "lng": 24.6559,
  "capacity_m3": 500,
  "is_public": false
}

Required: `site_id`, `name`. Optional: `lat`, `lng`, `capacity_m3`, `is_public`, `notes`, `directions`, `open_hours` (Mon–Sun, `{ closed, from, to }`).

curl -sS -X POST "https://api.grindzero.app/api/v1/receiving-points" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer API_KEY" \
  -d '{"site_id":"SITE_ID","name":"Dump Site A"}'

GET /api/v1/receiving-points/{rpId}

Returns one receiving point, including `open_hours` and `photos` (signed URLs, same pattern as waybill). Requires scope `read:data` or `*`.

curl -sS "https://api.grindzero.app/api/v1/receiving-points/RP_ID" \
  -H "Authorization: Bearer API_KEY"

PATCH /api/v1/receiving-points/{rpId}

Partial update. `site_id` cannot be changed. `409` if archived. Scope `write:data` or `*`.

curl -sS -X PATCH "https://api.grindzero.app/api/v1/receiving-points/RP_ID" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer API_KEY" \
  -d '{"capacity_m3":600}'

DELETE /api/v1/receiving-points/{rpId}

Archives the receiving point. `409` if already archived. Scope `write:data` or `*`.

curl -sS -X DELETE "https://api.grindzero.app/api/v1/receiving-points/RP_ID" \
  -H "Authorization: Bearer API_KEY"

POST /api/v1/receiving-points/{rpId}/restore

Clears the archive. `409` if the point is not archived. Scope `write:data` or `*`.

curl -sS -X POST "https://api.grindzero.app/api/v1/receiving-points/RP_ID/restore" \
  -H "Authorization: Bearer API_KEY"

Reports

GET /api/v1/reports/summary

Lightweight summary of delivered surpluses. Counts only `delivered` rows whose origin worksite (`from_site_id`) belongs to the API key org. The window is UTC calendar days on `created_at`, not `delivered_at`. CO₂ and km are estimates; use `GET /api/v1/surpluses` for line items. Requires scope `read:data` or `*`.

Parameters

`?from=YYYY-MM-DD` and `?to=YYYY-MM-DD` (default: last 30 days). Invalid date, impossible calendar day, or `from` after `to` → `422` `validation_error`.

Successful response (200)

{
  "data": {
    "period": { "from": "2026-04-26", "to": "2026-05-26", "basis": "created_at" },
    "loads_count": 42,
    "volume_m3": 1200,
    "co2_savings_kg": 8400,
    "heavy_vehicle_km_saved": 210,
    "methodology": {
      "status": "estimated",
      "co2_kg_per_m3": 7,
      "km_per_m3": 0.175
    }
  }
}

Fields: `period.from` / `period.to` / `period.basis` (`created_at`), `loads_count`, `volume_m3`, `co2_savings_kg`, `heavy_vehicle_km_saved`, `methodology` (`status` estimated, `co2_kg_per_m3` 7, `km_per_m3` 0.175).

Example (curl)

curl -sS "https://api.grindzero.app/api/v1/reports/summary?from=2026-01-01&to=2026-05-26" \
  -H "Authorization: Bearer API_KEY"

Replace `API_KEY` with your secret. Without `from`/`to` the last 30 days are used.

GET /api/v1/reports/loads

Source rows for month-end. Same origin-site scope and `created_at` window as summary. `additive_usage_m3` is ceil per load (same as admin `/reports`). Keyset pagination. Scope `read:data` or `*`.

curl -sS "https://api.grindzero.app/api/v1/reports/loads?from=2026-05-01&to=2026-05-31" \
  -H "Authorization: Bearer API_KEY"

GET /api/v1/billing/additive-usage

Monthly additive usage (m³) billed to this org. Same ceil rule as `/manager/billing`. Month is UTC from `delivered_at` else `created_at`. Scope `read:data` or `*`.

curl -sS "https://api.grindzero.app/api/v1/billing/additive-usage?from=2026-05-01&to=2026-05-31" \
  -H "Authorization: Bearer API_KEY"

You may also receive the usual protected-route responses, for example `401` (missing or invalid key, expired key), `403` when the versioned API is unavailable or not enabled for your organization, `insufficient_scope`, or `429` when rate limited — error bodies use `error`, `message`, and `code`.

Trucks

GET /api/v1/trucks

Returns active trucks for the API key's organization. Includes last known GPS (`coordinates`, `position_updated_at`) when the driver app has reported a position. Requires scope `read:data` or `*`.

Successful response (200)

{
  "data": [
    {
      "id": "8473f6b8-3748-539b-b498-4cc2a8083127",
      "license": "ABC-123",
      "country": "fi",
      "capacity_m3": 16,
      "name": "Volvo FH",
      "notes": null,
      "driver_id": "713e48dc-d5b9-424f-98a0-c307de3c46a1",
      "coordinates": { "lat": 60.3795, "lng": 25.0336 },
      "position_updated_at": "2026-08-25T12:04:11.000Z",
      "created_at": "2026-03-01T10:00:00.000Z",
      "updated_at": "2026-08-25T12:04:11.000Z"
    }
  ],
  "meta": { "count": 1 }
}

Fields: `id`, `license`, `country`, `capacity_m3`, `name`, `notes`, `driver_id`, `coordinates` (`{ lat, lng }` or `null`), `position_updated_at`, `created_at`, `updated_at`. Position is a snapshot, not a live stream.

Example (curl)

curl -sS "https://api.grindzero.app/api/v1/trucks" \
  -H "Authorization: Bearer API_KEY"

Replace `API_KEY` with your secret.

POST /api/v1/trucks

POST /api/v1/trucks

Creates a new truck for the organization. Requires scope `write:data` or `*`. Request `orgId` must match the key's organization.

Request body

{
  "orgId": "550e8400-e29b-41d4-a716-446655440000",
  "license": "ABC-123",
  "country": "fi",
  "capacity": 16,
  "name": "Volvo FH",
  "driverId": "713e48dc-d5b9-424f-98a0-c307de3c46a1"
}

JSON: `orgId` (UUID), `license`, `country` (`fi`/`se`/`ee`), optional `capacity` (0–100 m³), `name`, `driverId` (`profiles.id` in the same org).

Successful response (200) (201)

{
  "data": {
    "id": "8473f6b8-3748-539b-b498-4cc2a8083127",
    "license": "ABC-123",
    "country": "fi",
    "capacity_m3": 16,
    "name": "Volvo FH",
    "notes": null,
    "driver_id": "713e48dc-d5b9-424f-98a0-c307de3c46a1",
    "coordinates": null,
    "position_updated_at": null,
    "created_at": "2026-06-16T12:00:00.000Z",
    "updated_at": "2026-06-16T12:00:00.000Z"
  }
}

HTTP 201 with `{ data: { …truck… } }` using the same fields as GET (including `coordinates`). `409` `license_conflict` when the plate already exists.

Example (curl)

curl -sS -X POST "https://api.grindzero.app/api/v1/trucks" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer API_KEY" \
  -d '{"orgId":"ORG_ID","license":"ABC-123","country":"fi","capacity":16,"name":"Volvo FH"}'

Replace `API_KEY` and `ORG_ID`. Never log the secret.

GET /api/v1/trucks/{truckId}

One truck plus last known position. `404` `truck_not_found` if the truck is not an active vehicle in this org. Scope `read:data` or `*`.

curl -sS "https://api.grindzero.app/api/v1/trucks/TRUCK_ID" \
  -H "Authorization: Bearer API_KEY"

PATCH /api/v1/trucks/{truckId}

Partial update. Writable: `driver_id` (or `driverId`), `name`, `license`, `country`, `capacity_m3` (or `capacity`), `notes`. `driver_id` must be an active member from `GET /api/v1/members`; `null` unassigns. GPS is read-only. Scope `write:data` or `*`.

{
  "driver_id": "713e48dc-d5b9-424f-98a0-c307de3c46a1",
  "name": "Volvo FH",
  "capacity_m3": 16
}

At least one field. Changing driver releases them from any other truck in the org (same as admin PATCH).

curl -sS -X PATCH "https://api.grindzero.app/api/v1/trucks/TRUCK_ID" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer API_KEY" \
  -d '{"driver_id":"DRIVER_PROFILE_ID"}'

You may also receive the usual protected-route responses, for example `401` (missing or invalid key, expired key), `403` when the versioned API is unavailable or not enabled for your organization, `insufficient_scope`, or `429` when rate limited — error bodies use `error`, `message`, and `code`.

Members

GET /api/v1/members

Returns organization members (`profiles`). Default: `active: true` only. `?include_inactive=true` includes deactivated memberships. Requires scope `read:data` or `*`.

Successful response (200)

{
  "data": [
    {
      "id": "713e48dc-d5b9-424f-98a0-c307de3c46a1",
      "first_name": "Alex",
      "last_name": "Driver",
      "role": "driver",
      "phone": "+358401234567",
      "active": true
    }
  ],
  "meta": { "count": 1 }
}

Fields: `id` (`profiles.id`), `first_name`, `last_name`, `role` (`org_members.role`), `phone`, `active`. No `platform_role`, email, or `org_admin` creation on this API.

Example (curl)

curl -sS "https://api.grindzero.app/api/v1/members" \
  -H "Authorization: Bearer API_KEY"

Replace `API_KEY` with your secret.

POST /api/v1/members

Creates an Auth user, profile, and active membership in this org. Roles: `member`, `driver`, `site_manager`, `factory_manager`, `additive_supplier` (not `org_admin`). Phone is E.164. Scope `write:data` or `*`.

{
  "first_name": "Alex",
  "last_name": "Driver",
  "phone": "+358401234567",
  "role": "driver"
}

Required: `first_name`, `last_name`, `phone`. Optional `role` (default `member`), `email`+`password` together. `409` `phone_conflict` if the number is already in use.

curl -sS -X POST "https://api.grindzero.app/api/v1/members" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer API_KEY" \
  -d '{"first_name":"Alex","last_name":"Driver","phone":"+358401234567","role":"driver"}'

GET /api/v1/members/{memberId}

One membership (including inactive). `404` `member_not_found` if the profile is not in this org. Scope `read:data` or `*`.

curl -sS "https://api.grindzero.app/api/v1/members/MEMBER_ID" \
  -H "Authorization: Bearer API_KEY"

PATCH /api/v1/members/{memberId}

Partial update: `first_name`, `last_name`, `phone`, `role`, `active`. `role: null` or `DELETE …/role` resets to base `member`. `active: false` leaves this org. Cannot assign `org_admin`. Scope `write:data` or `*`.

{
  "first_name": "Alex",
  "role": "driver",
  "active": true
}
curl -sS -X PATCH "https://api.grindzero.app/api/v1/members/MEMBER_ID" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer API_KEY" \
  -d '{"role":"driver"}'

DELETE /api/v1/members/{memberId}

Same as PATCH `{ "active": false }`. `409` `already_inactive` if already left. Releases this org's trucks where the member is the assigned driver.

curl -sS -X DELETE "https://api.grindzero.app/api/v1/members/MEMBER_ID" \
  -H "Authorization: Bearer API_KEY"

PUT /api/v1/members/{memberId}/role

Grants an operational role (one per membership): PUT `{ "role": "driver" }`. Allowed: `member`, `driver`, `site_manager`, `factory_manager`, `additive_supplier`. Demoting a driver releases trucks. Scope `write:data` or `*`.

curl -sS -X PUT "https://api.grindzero.app/api/v1/members/MEMBER_ID/role" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer API_KEY" \
  -d '{"role":"driver"}'

DELETE /api/v1/members/{memberId}/role

Removes the special role: resets `role` to `member`. Does not deactivate membership (use `DELETE /members/{id}` for that).

curl -sS -X DELETE "https://api.grindzero.app/api/v1/members/MEMBER_ID/role" \
  -H "Authorization: Bearer API_KEY"

You may also receive the usual protected-route responses, for example `401` (missing or invalid key, expired key), `403` when the versioned API is unavailable or not enabled for your organization, `insufficient_scope`, or `429` when rate limited — error bodies use `error`, `message`, and `code`.

Nearby

GET /api/v1/places/nearby

Nearest places by straight-line km. Receiving points: this org plus other orgs' `is_public` RPs. Worksites and factories: this org only. Scope `read:data` or `*`.

Required: `lat`, `lng`. Optional: `radius_km` (1–100, default 40), `types` (`receiving_point`,`site`,`factory` — default `receiving_point`), `limit` (1–50, default 20).

curl -sS "https://api.grindzero.app/api/v1/places/nearby?lat=60.17&lng=24.94&radius_km=40&types=receiving_point" \
  -H "Authorization: Bearer API_KEY"

Support

POST /api/v1/support

Opens a one-way support ticket (Linear). Org from the API key. Extra quota 5 / hour / key (`support_rate_limited`). Scope `write:data` or `*`.

curl -sS -X POST "https://api.grindzero.app/api/v1/support" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer API_KEY" \
  -d '{"title":"Waybill OCR failed","description":"surplus id …"}'

Factories

GET /api/v1/factories

Factories owned by this org. `?status=active` (default) or `all`. Scope `read:data` or `*`.

Creates a factory. Required `name`. Optional `street_address`, `city`, `lat`, `lng`. `DELETE` archives; `POST …/restore` unarchives. Scope `write:data` or `*`.

curl -sS -X POST "https://api.grindzero.app/api/v1/factories" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer API_KEY" \
  -d '{"name":"Plant Espoo","lat":60.2055,"lng":24.6559}'

Org grants

GET /api/v1/org-access-grants

Partner grants this org has issued (hauliers). Scope `read:data` or `*`.

curl -sS "https://api.grindzero.app/api/v1/org-access-grants" \
  -H "Authorization: Bearer API_KEY"

Grants another org access to this org's sites. Body: `grantee_org_id`, optional `scope` (`read`|`full`). Idempotent upsert. Scope `write:data` or `*`.

curl -sS -X POST "https://api.grindzero.app/api/v1/org-access-grants" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer API_KEY" \
  -d '{"grantee_org_id":"ORG_ID","scope":"read"}'

Revokes a grant this org issued. Scope `write:data` or `*`.

Geocode

POST /api/v1/geocode

Address → coordinates (Google Geocoding). Body: `address` or `street_address`/`postal_code`/`city`. `503` `geocode_not_configured` if the key is missing, `404` `geocode_not_found`. Scope `read:data` or `*`.

curl -sS -X POST "https://api.grindzero.app/api/v1/geocode" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer API_KEY" \
  -d '{"address":"Mannerheimintie 1, Helsinki"}'

Webhooks

Grindzero sends HTTP POST requests to your registered URL when a surplus is delivered and the waybill exists in Supabase Storage. Register the URL with `GET`/`POST /api/v1/webhooks` (HMAC or bearer token). Superadmin can still manage the same rows.

Lists this org's outbound webhooks (no secrets). Scope `read:data` or `*`.

curl -sS "https://api.grindzero.app/api/v1/webhooks" \
  -H "Authorization: Bearer API_KEY"

Registers `surplus.delivered`. Required: `event`, `url` (HTTPS). Optional `verification_mode` (`hmac_sha256`|`bearer_token`). Secret is shown once. `409` `webhook_already_exists`. Scope `write:data` or `*`.

curl -sS -X POST "https://api.grindzero.app/api/v1/webhooks" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer API_KEY" \
  -d '{"event":"surplus.delivered","url":"https://partner.example/webhooks/gz"}'

Deletes a webhook. Scope `write:data` or `*`.

curl -sS -X DELETE "https://api.grindzero.app/api/v1/webhooks/WEBHOOK_ID" \
  -H "Authorization: Bearer API_KEY"

Supported events (v1)

  • surplus.delivered — one POST per delivered load when status is delivered and the waybill is stored. Rich payload: org/site/receiving point/truck, contacts, waybill signed URL, adminUrl. surplus.created is deprecated (TECH-57 MVP).

Successful response (200)

Envelope: `id` (delivery id, same as `x-gz-delivery-id`), `event`, `timestamp`, `subject`, `data`. Fields are `null` when unknown. `deliveredAt` / `deliveredByLabel` may be `null` even when `status` is `delivered`. Full OpenAPI schema: `/openapi.yaml` → `WebhookPayload`.

{
  "id": "c1c632e8-444a-4c13-b02b-c3e2cc980c23",
  "event": "surplus.delivered",
  "timestamp": "2026-06-16T14:25:30.118Z",
  "subject": { "type": "surplus", "id": "cd295847-0554-4136-b429-aafd325f6d30" },
  "data": {
    "surplusId": "cd295847-0554-4136-b429-aafd325f6d30",
    "orgId": "15e8514a-3522-2439-de83-f829fa4118bc",
    "orgName": "Grindzero Oy",
    "fromSiteId": "ced69863-5a85-4d22-8d7e-b36287738245",
    "fromSiteName": "TEST Grindzero SITE 1",
    "fromSiteAddress": {
      "street": "Mukulakuja 4 A 9",
      "postalCode": "04300",
      "city": "Tuusula",
      "countryCode": "FI"
    },
    "fromSiteCoordinates": { "lat": 60.3795, "lng": 25.0336 },
    "toReceivingPointId": "7b27761c-ba70-4395-83d6-f133408dadd3",
    "toReceivingPointName": "TEST POINT 01",
    "toLabel": "TEST POINT 01 · TEST Grindzero SITE 1",
    "truckId": "8473f6b8-3748-539b-b498-4cc2a8083127",
    "truckLabel": "TEST GZ 2 (AAA-222)",
    "truckLicense": "AAA-222",
    "truckCountry": "fi",
    "truckCapacity_m3": 20,
    "truckOrgId": "15e8514a-3522-2439-de83-f829fa4118bc",
    "truckOrgName": "Grindzero Oy",
    "driverId": "713e48dc-d5b9-424f-98a0-c307de3c46a1",
    "driverLabel": "Driver name · +358…",
    "createdById": "a590d5a6-5b71-47cf-afb8-8aca8d82e5a9",
    "createdByLabel": "Creator name · +358…",
    "originContactLabel": "Site contact",
    "receivingContactLabel": "RP contact",
    "volume_m3": 1.2,
    "status": "delivered",
    "previousStatus": "in_progress",
    "additive": false,
    "additiveBilledTo": "origin_site",
    "materialGrade": null,
    "manufacturer": null,
    "description": null,
    "createdAt": "2026-06-16T14:24:49.652+00:00",
    "updatedAt": "2026-06-16T14:25:26.661872+00:00",
    "deliveredAt": null,
    "deliveredById": null,
    "deliveredByLabel": null,
    "waybill": {
      "storagePath": "surpluses/cd295847-…/photo.jpg",
      "url": "https://…supabase…/object/sign/media/…",
      "urlExpiresAt": "2026-06-17T14:25:30.118Z"
    },
    "fieldCapture": {
      "haulierLabel": "Grindzero Oy",
      "driverName": null,
      "driverPhone": null,
      "notes": null
    },
    "adminUrl": "https://admin.grindzero.app/surpluses/cd295847-0554-4136-b429-aafd325f6d30"
  }
}

HTTP headers

Content-Type: application/json · x-gz-event · x-gz-delivery-id (envelope id, same as body.id) · x-gz-signature (HMAC mode) or x-gz-delivery-token (bearer mode).

HMAC validation (default)

In HMAC mode each webhook includes `x-gz-signature: sha256=<HMAC-SHA256 of JSON body>`. Validate by computing your own HMAC and comparing.

import { createHmac } from 'crypto'

function verifyWebhook(body: string, signature: string, secret: string): boolean {
  const expected = createHmac('sha256', secret).update(body).digest('hex')
  return signature === `sha256=${expected}`
}

Bearer token mode

Optional at registration: compare the `x-gz-delivery-token` header to your registered token. No body integrity — use only when HMAC verification is not feasible.