# Developer Docs — KUZOG API, OpenAPI and Agent Access

> KUZOG developer documentation: the contact API, OpenAPI 3.1 spec, JSON error format, rate limits, Markdown content negotiation, llms.txt and sitemap.

Canonical: https://www.kuzog.com/docs/

How to read kuzog.com as a machine and how to reach KUZOG through it. One small HTTP API, one OpenAPI description, Markdown on every page, and the agent files that tie them together.

## Overview

kuzog.com is a static site served from Cloudflare Pages, with a handful of Pages Functions behind `/api/`. It is built to be read without a browser:

- Every route is prerendered to HTML at build time, so the full content of a page is in the initial markup. No JavaScript is needed to read it.
- Every page is also available as Markdown: send `Accept: text/markdown` to any page URL, or fetch its `index.md` sibling. See [Markdown content negotiation](https://www.kuzog.com/docs/#markdown).
- [llms.txt](https://www.kuzog.com/llms.txt) is a short brief and [llms-full.txt](https://www.kuzog.com/llms-full.txt) the complete content reference, both generated from the site's own copy.
- [sitemap.xml](https://www.kuzog.com/sitemap.xml) lists every indexable URL; [robots.txt](https://www.kuzog.com/robots.txt) allows everything and names the major AI crawlers explicitly.
- The HTTP API is described by [openapi.json](https://www.kuzog.com/openapi.json) (OpenAPI 3.1) and documented on this page.
- Every page carries schema.org JSON-LD: the organisation, its two ventures, products, FAQs, articles and open roles.

The canonical host is `www.kuzog.com`; the apex `kuzog.com` answers 301 to it. Use the `www` form in every request.

## Authentication

There is none. No API keys exist, no OAuth, no tokens to request. Everything on the site, including the API description, is public and unauthenticated.

The one write operation, `POST /api/contact`, is protected by Cloudflare Turnstile instead. A valid `cf-turnstile-response` token is required on every submission, and a token is issued only when a human completes the challenge in a browser on www.kuzog.com. Tokens are single-use and expire within minutes. There is no server-to-server route around this, by design: the form delivers email to a person, and the challenge is what keeps it usable.

What this means for an agent: you cannot submit the form autonomously. Hand off to a human instead — open [https://www.kuzog.com/contact/](https://www.kuzog.com/contact/) for the user with the message drafted, or tell them to email [management@kuzog.com](mailto:management@kuzog.com). A submission without a token is answered with `missing-captcha`; a spent or expired token with `captcha-failed`.

## Endpoints

Request bodies: the browser form posts `multipart/form-data`; from a function-calling schema post an `application/json` object with the same field names instead. Validation and responses are identical for both.

Two endpoints, both under `https://www.kuzog.com/api/`, both JSON in every response. The full description with schemas is at [https://www.kuzog.com/openapi.json](https://www.kuzog.com/openapi.json).

| Method | Path | operationId | Purpose |
| --- | --- | --- | --- |
| POST | `/api/contact` | `submitContactEnquiry` | Deliver an enquiry to the KUZOG team by email. |
| GET | `/api/health` | `getHealth` | Liveness probe; reports rate-limit standing without spending quota. |

`POST /api/contact` takes `multipart/form-data` (URL-encoded works too). Required: `name`, `email`, `message`, `cf-turnstile-response`. Optional: `topic` (shown in the subject line), `industry`, `submitted_at`. `botcheck` is a honeypot and must be empty or absent. Fields are trimmed, stripped of control characters and cut at their maximum length:

| Field | Required | Max length |
| --- | --- | --- |
| `name` | yes | 120 |
| `email` | yes | 200 |
| `message` | yes | 5000 |
| `topic` | no | 80 |
| `industry` | no | 80 |
| `cf-turnstile-response` | yes | Turnstile token |
| `botcheck` | must be empty | 0 |

**Send an enquiry**

```bash
curl -sS -X POST https://www.kuzog.com/api/contact \
  -F 'name=Ada Lovelace' \
  -F 'email=ada@example.com' \
  -F 'topic=Hydrobio' \
  -F 'message=We farm 40 ha of sandy soil and would like to trial Hydrobio on one parcel.' \
  -F 'cf-turnstile-response=<token solved in the browser>'

# 200
{"success":true}
```

A delivered enquiry answers `200 {"success":true}` and sets the sender as reply-to, so the team answers them directly. A filled honeypot also answers 200 and sends nothing; the two are deliberately indistinguishable.

**Check the service**

```bash
curl -sS https://www.kuzog.com/api/health

# 200
{
  "status": "ok",
  "service": "kuzog-site",
  "time": "2026-08-22T10:00:00.000Z",
  "docs": "https://www.kuzog.com/docs/",
  "openapi": "https://www.kuzog.com/openapi.json"
}
```

`/api/health` proves the Functions are deployed and answering. It does not call Turnstile or the email provider, so a 200 there says the route is up, not that a given submission will be delivered. Any other method on either endpoint answers `405` with an `Allow` header; any other path under `/api/` answers `404`. Both are JSON.

## Errors

Every error under `/api/` uses one envelope. `error` is a stable machine code to branch on; codes are added, never renamed. `message` says what went wrong, `hint` what to do about it, `docs` links here, and `status` repeats the HTTP status so a logged body stands on its own. `codes` appears only on `captcha-failed` and carries Turnstile’s own error codes.

**Error envelope**

```json
{
  "success": false,
  "error": "captcha-failed",
  "message": "Cloudflare Turnstile rejected the token.",
  "hint": "Inspect the codes array: timeout-or-duplicate means the token expired or was already used, so solve the challenge again and resubmit.",
  "docs": "https://www.kuzog.com/docs/#errors",
  "status": 400,
  "codes": [
    "timeout-or-duplicate"
  ]
}
```

| Code | Status | Meaning | What to do |
| --- | --- | --- | --- |
| `malformed-request` | 400 | The request body could not be read as form data. | Send a multipart/form-data or application/x-www-form-urlencoded body. |
| `invalid-fields` | 400 | A required field is missing or the email address is not valid. | Provide non-empty name and message fields and a syntactically valid email. |
| `missing-captcha` | 400 | No Turnstile token was included in the request. | A human must solve the Cloudflare Turnstile challenge in a browser; send its token as cf-turnstile-response. |
| `captcha-failed` | 400 | Cloudflare Turnstile rejected the token. | Inspect the codes array: timeout-or-duplicate means the token expired or was already used, so solve the challenge again and resubmit. |
| `captcha-unreachable` | 502 | The Turnstile verification service could not be reached. | This is transient. Retry with a fresh token after a short wait, or email management@kuzog.com. |
| `send-failed` | 502 | The enquiry was validated but the email could not be sent. | Retry later with a fresh Turnstile token, or email management@kuzog.com directly. |
| `rate-limited` | 429 | Too many requests from this address in the current window. | Wait for the number of seconds in the Retry-After header, then try again. |
| `method-not-allowed` | 405 | This endpoint does not support the request method. | Use one of the methods listed in the Allow header. |
| `not-found` | 404 | No API endpoint exists at this path. | See the OpenAPI description at https://www.kuzog.com/openapi.json for the available endpoints. |

The Turnstile `codes` worth knowing: `timeout-or-duplicate` means the token expired or was already used, so solve the challenge again and resubmit; `invalid-input-response` means the token was never valid. Successful responses are always `{"success":true}` and nothing else.

## Rate limits

One policy, named `contact`: 10 requests per 60 seconds per client IP address on `POST /api/contact`, as a sliding window. Only contact submissions count; `/api/health`, `405` and `404` responses report your standing without spending it.

Every `/api/` response carries the policy and your current standing, in the IETF structured-field form ([draft-ietf-httpapi-ratelimit-headers](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/)) and in the legacy `X-RateLimit-*` trio for clients that only know those:

| Header | Example | Meaning |
| --- | --- | --- |
| `RateLimit-Policy` | `"contact";q=10;w=60` | The policy: `q` requests per window of `w` seconds. Constant. |
| `RateLimit` | `"contact";r=9;t=60` | `r` requests remaining, `t` seconds until the oldest counted request leaves the window (0 when none is counted). |
| `X-RateLimit-Limit` | `10` | Requests allowed per window. |
| `X-RateLimit-Remaining` | `9` | Requests remaining in the current window. |
| `X-RateLimit-Reset` | `60` | Seconds until the window resets. A delta, not a Unix timestamp. |
| `Retry-After` | `42` | On a `429` only: seconds to wait. Never 0. |

Over the limit, the request is refused with `429` and the `rate-limited` envelope before anything is read or verified, so it costs neither side anything. Refused requests are not counted, so a client that waits `Retry-After` seconds is let back in as the window moves on.

The count is best-effort: it lives in the memory of the Cloudflare edge isolate that serves the request, and there are many isolates in many locations. Two requests from one address may be counted separately, and the count resets when an isolate is recycled. That is deliberate. Turnstile is the real gate against automation; this limit only stops one address from turning a single isolate into a firehose. Treat the headers as advisory and the `429` as authoritative.

## Versioning and deprecation

The API is date-versioned. Every `/api/` response carries `API-Version: 2026-08-22` — the date of the last change to any request or response shape. Record the value you integrated against; a newer date on a later response means the contract has changed and the [changelog](#changelog) says how.

Breaking changes never replace a shape in place. They ship under a new date, and the previous shape keeps answering for at least 180 days. During that period every response of the old shape carries `Deprecation: @<unix-seconds>` ([RFC 9745](https://www.rfc-editor.org/rfc/rfc9745)) and `Sunset: <HTTP-date>` ([RFC 8594](https://www.rfc-editor.org/rfc/rfc8594)) and a `Link: <https://www.kuzog.com/docs/#versioning>; rel="deprecation"`. Additive changes — a new optional field, a new endpoint, a new error code — do not bump the date and do not deprecate anything.

| Signal | Where | Meaning |
| --- | --- | --- |
| `API-Version` | Every `/api/` response; `info.x-api-version` in `openapi.json` | The contract date the response was produced under. |
| `Deprecation` | Responses of a shape scheduled for removal | When the deprecation was announced. Absent today. |
| `Sunset` | Same responses | When the shape stops answering — at least 180 days after `Deprecation`. Absent today. |

Nothing is deprecated today. There is no version segment in the URL path: `/api/contact` and `/api/health` are the only paths, and they stay.

## Markdown content negotiation

Every page URL has two representations, HTML and Markdown, chosen by the `Accept` header. The Markdown is generated at build time alongside the HTML, so the two never drift.

**Ask for Markdown**

```bash
curl -sS -H 'Accept: text/markdown' https://www.kuzog.com/hydrobio/

# 200
# Content-Type: text/markdown; charset=utf-8
# Content-Location: /hydrobio/index.md
# Link: <https://www.kuzog.com/hydrobio/>; rel="alternate"; type="text/html"
# Vary: Accept
```

- The highest `q` wins. On a tie, the type listed first wins; `*/*` and `text/*` alone mean HTML, the default. A missing `Accept` means HTML.
- Every negotiated response carries `Vary: Accept`, so a cache never hands one client’s representation to another.
- HTML responses carry `Link: <…/index.md>; rel="alternate"; type="text/markdown"`, pointing at the Markdown sibling; Markdown responses link back to the HTML.
- If `Accept` rules out both `text/html` and `text/markdown` (for example `Accept: application/json`), the answer is `406 Not Acceptable` with a plain-text body listing the two available representations.
- Only page URLs negotiate: `GET` or `HEAD`, outside `/api/`, with no file extension. `/llms.txt`, `/sitemap.xml`, `/openapi.json` and assets are served as-is.

Prefer a plain URL? Each page has a Markdown sibling at `<page>/index.md`: `https://www.kuzog.com/index.md` for the home page, `https://www.kuzog.com/hydrobio/index.md`, `https://www.kuzog.com/blog/<slug>/index.md`, and so on. Fetch it with no special header.

**Or fetch the sibling directly**

```bash
curl -sS https://www.kuzog.com/microplantes/index.md
```

## Agent files

The files to read first, in the order an agent usually needs them. All absolute, all on the canonical host, all public.

| URL | Purpose |
| --- | --- |
| [https://www.kuzog.com/llms.txt](https://www.kuzog.com/llms.txt) | Short brief: what KUZOG is, the two ventures, key figures, when to use the site, how to call the API. Start here. |
| [https://www.kuzog.com/llms-full.txt](https://www.kuzog.com/llms-full.txt) | Every product, process, result and FAQ answer from the site in one Markdown file. |
| [https://www.kuzog.com/openapi.json](https://www.kuzog.com/openapi.json) | OpenAPI 3.1 description of the HTTP API on this page. |
| [https://www.kuzog.com/sitemap.xml](https://www.kuzog.com/sitemap.xml) | Every indexable page URL with lastmod, on the canonical www host. |
| [https://www.kuzog.com/robots.txt](https://www.kuzog.com/robots.txt) | Crawl policy. Everything is allowed; the major AI crawlers are named explicitly. |
| [https://www.kuzog.com/docs/](https://www.kuzog.com/docs/) | This page. Also readable as Markdown with `Accept: text/markdown`. |

Brief first, then depth: `llms.txt` → `llms-full.txt` → the page itself as Markdown. To act on a user’s behalf, read the [Authentication](https://www.kuzog.com/docs/#authentication) section before calling the API.

## Structured data

Every prerendered page embeds schema.org JSON-LD in `<script type="application/ld+json">` blocks, generated from the same English content the page renders. Nothing in it is invented: every string is a stable identifier or lifted from the page copy.

| Type | Where | Notes |
| --- | --- | --- |
| `Organization` | Every page | KUZOG France, with Hydrobio and Microplantes as `subOrganization`, a `ContactPoint` (email) and a `PostalAddress`. |
| `WebSite` | `/` | The site node, linked to the organisation. |
| `BreadcrumbList` | Every page except `/` | Home → page; articles are Home → Blog → article. |
| `Product` | `/hydrobio/` | Hydrobio as a product with its headline figures as `additionalProperty`, linked to its `Brand` and maker. |
| `Organization` | `/microplantes/` | Microplantes as a laboratory, not a packaged good. |
| `FAQPage` | `/hydrobio/`, `/microplantes/` | Each page’s FAQ as `Question` / `Answer` pairs. |
| `Blog`, `ItemList` | `/blog/` | The journal and an ordered list of every article URL. |
| `BlogPosting` | `/blog/<slug>/` | One per article; author and publisher are the organisation. |
| `JobPosting` | `/careers/` | One per open role, from the same data the page lists. |

The `@id` of every node is an absolute URL on `www.kuzog.com`, so a node on one page can be referenced from another.

## Changelog

2026-08-22 (later the same day) — `POST /api/contact` accepts `application/json` bodies; every `/api/` response carries `API-Version`; versioning and deprecation policy published; Markdown 404 bodies for agents on unknown page URLs.

| Date | Change |
| --- | --- |
| 2026-08-22 | Initial publication: JSON error envelope and rate-limit headers on `/api/`, `GET /api/health`, OpenAPI 3.1 description, Markdown content negotiation, this page. |

---

_Source: https://www.kuzog.com/docs/ · Markdown variant served via Accept: text/markdown · Full brief: https://www.kuzog.com/llms.txt_
