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. Typical 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, trucks, and members (e.g. GET /api/v1/sites, /trucks, /members).
  • write:orgs:self — update the display name for that same org (PATCH /api/v1/orgs/{orgId}).
  • write:data — operational writes (e.g. POST /api/v1/trucks).
  • * — 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 organizations your API key may access. With a v1 organization-scoped key you typically get one row in `data` (the array shape leaves room for future expansion). 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",
      "created_at": "2026-01-15T08:00:00.000Z"
    }
  ],
  "meta": {
    "count": 1
  }
}

Fields: `id` (UUID), `name` (string), `status` (organization status), `created_at` (ISO 8601). `meta.count` is the number of rows returned (typically `1` for an org-scoped key).

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}

Updates the organization display name (`name` field). Path `orgId` must be the same UUID as the organization bound to your API key. 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"
}

JSON object with required string field `name`: non-empty after trimming, at most 500 characters. Other fields are ignored in this v1 endpoint.

Successful response (200)

{
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "name": "Acme Construction",
    "updated_at": "2026-05-11T12:00:00.000Z"
  }
}

Returns `{ "data": { "id", "name", "updated_at" } }` with ISO 8601 timestamps.

Errors (this route)

  • 400invalid_org_idPath is not a valid UUID.
  • 400invalid_jsonBody is not valid JSON.
  • 422validation_error`name` missing, wrong type, empty after trim, or 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"}'

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.

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

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",
      "volume_m3": 50,
      "status": "new",
      "created_at": "2026-05-20T14:30:00.000Z",
      "delivered_at": null
    }
  ],
  "meta": { "count": 1, "next_cursor": null }
}

Fields: `id` (UUID), `site_id` (worksite), `volume_m3` (cubic metres), `status`, `created_at`, `delivered_at` (may be `null`). `meta.count` is the page row count, `meta.next_cursor` is the cursor for the next page (`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.

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

Reports

GET /api/v1/reports/summary

Lightweight summary report of delivered surpluses within a date range. Calculates loads, volumes, and estimated CO₂ savings. Requires scope `read:data` or `*`.

Parameters

`?from=YYYY-MM-DD` and `?to=YYYY-MM-DD` (default: last 30 days). Only `delivered` surpluses within the period are counted.

Successful response (200)

{
  "data": {
    "period": { "from": "2026-04-26", "to": "2026-05-26" },
    "loads_count": 42,
    "volume_m3": 1200,
    "co2_savings_kg": 8400,
    "heavy_vehicle_km_saved": 210
  }
}

Fields: `period.from` and `period.to` (range), `loads_count`, `volume_m3`, `co2_savings_kg` (estimated, coefficient 7 kg/m³), `heavy_vehicle_km_saved` (coefficient 0.175 km/m³).

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.

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. 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",
      "driver_id": "713e48dc-d5b9-424f-98a0-c307de3c46a1",
      "created_at": "2026-03-01T10:00:00.000Z"
    }
  ],
  "meta": { "count": 1 }
}

Fields: `id`, `license`, `country`, `capacity_m3`, `name`, `driver_id` (`profiles.id` or `null`), `created_at`. `meta.count` is the row count.

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",
    "driver_id": "713e48dc-d5b9-424f-98a0-c307de3c46a1",
    "created_at": "2026-06-16T12:00:00.000Z"
  }
}

HTTP 201 with `{ data: { id, license, country, capacity_m3, name, driver_id, created_at } }`. `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.

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 active organization members (`profiles`). 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"
    }
  ],
  "meta": { "count": 1 }
}

Fields: `id` (`profiles.id`), `first_name`, `last_name`, `role` (`org_members.role`), `phone`. No `platform_role` or email.

Example (curl)

curl -sS "https://api.grindzero.app/api/v1/members" \
  -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`.

Webhooks

Grindzero sends HTTP POST requests to your registered URL when a surplus is delivered and the waybill exists in Supabase Storage. Webhooks are configured per-organization by a superadmin (HMAC or bearer token).

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": "Saarenmaankatu 2",
      "postalCode": "00980",
      "city": "Helsinki",
      "countryCode": "FI"
    },
    "fromSiteCoordinates": { "lat": 60.1987, "lng": 25.1253 },
    "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.