# Public API

> Specs and response examples for the two endpoints you can use without logging in (published content as JSON, and form submissions).

URL: https://siteon.dev/docs/api

Siteon is infrastructure for AI-built websites: it adds hosting, a CMS your client can edit, contact forms, analytics and control from AI agents (CLI and MCP) around static HTML built with AI or by hand, so it is ready to ship.

## Endpoints

Normally the script and the Cloudflare Pages relay call these for you, so you don't need to use them directly. Use them when you want to render content yourself or submit from a server.

| Method | Path | What it does |
|---|---|---|
| `GET` | `/v1/sites/<site>/content?locale=<locale>` | Published content (JSON) |
| `POST` | `/v1/sites/<site>/forms/<form>` | Form submissions |
| `GET` | `/sitekit.js` | The script you add to your site |

The origin is `https://cdn.siteon.dev` (the same as the script's `src`).

The management API (creating sites, applying schema, listing inquiries, etc.) is available with an API key (`stk_…`). You'll usually use it through the [CLI and MCP](https://siteon.dev/docs/cli-mcp).

## Published content (content)

```bash
curl -s "https://cdn.siteon.dev/v1/sites/hanaya/content?locale=en"
```

- If `locale` is omitted, the primary locale is used. A locale not listed in `locales` in schema.json returns `400 {"ok":false,"error":"bad_request"}`
- A site that doesn't exist returns `404 {"ok":false,"error":"not_found"}`
- No login required, read-only, with `Access-Control-Allow-Origin: *`
- `cache-control: public, max-age=60` (changes appear within 1 minute of publishing)
- Only published articles are included (drafts are not)

### Example response

```json
{
  "values": {
    "hours.days": "Mon–Fri",
    "hours.time": "10:00 - 19:00"
  },
  "lists": {
    "news": [
      {
        "id": "a1b2c3d4e5f6g7h8i9j0",
        "title": "New autumn menu",
        "date": "2026-10-01",
        "tags": ["autumn"],
        "category": "new-items",
        "category_label": "New items",
        "category_color": "red",
        "thumbnail": "https://cdn.siteon.dev/a/hanaya/…",
        "body": [{ "type": "…" }]
      }
    ]
  },
  "tags": { "news": [{ "tag": "autumn", "count": 3 }] },
  "tags_meta": { "news": { "autumn": { "color": "gold" } } },
  "tracking": { "gtm_id": "GTM-XXXXXXX" }
}
```

| Key | Contents |
|---|---|
| `values` | Group values. Keys are `<group>.<field>`. If a translation is missing or empty, the primary locale's value is used |
| `lists` | Published articles in collections. Up to 100 of the newest items per list, ordered by `sort` in schema.json (or by the dashboard order if there is none) |
| `image` fields | Absolute URL of the photo |
| `select` fields | The stored value, plus `<field>_label`, and `<field>_color` only for options that have a color |
| `tags` | For each collection with tags, the tags in use and their counts (most used first) |
| `tags_meta` | Colors of tags that have one. Tags without a color have no key at all |
| `tracking` | GTM ID (dashboard setting first, then schema.json) |

The article `url` (link to the article page) and the conversion of tags to `{tag, url, color}` are added **by the Cloudflare Pages relay**, not in this JSON.

## Form submissions (forms)

```bash
curl -s -X POST -H 'content-type: application/json' \
  -d '{"name":"Jane Smith","email":"jane@example.com","message":"Can I make a reservation?"}' \
  https://cdn.siteon.dev/v1/sites/hanaya/forms/contact
```

### What to send

- `application/json` or `application/x-www-form-urlencoded`. multipart is not accepted
- The body can be up to 16KB
- Send fields by their key in `forms[].fields` in schema.json. Unknown keys are ignored
- If the same key appears more than once, the last value is used

These fields can be sent without being defined in schema.json.

| Field | Contents |
|---|---|
| `_hp` | Honeypot. If it has a non-empty value, the submission is treated as a bot and not saved |
| `idempotency_key` | Up to 100 characters. A resend with the same key returns the original submission instead of creating a second one |
| `attribution` | Traffic source, in the form `{first, last}` (a JSON string for form-urlencoded). Values over 500 characters are dropped. A malformed value doesn't make the submission fail |
| `cf-turnstile-response` | Turnstile token |

### Success

| Situation | Response |
|---|---|
| Accepted | `200 {"ok":true,"id":"…","gtm_event":"sitekit_form_submit"}` |
| Caught by the honeypot (not saved) | `200 {"ok":true,"id":null,"gtm_event":"…"}` (same shape as success, so bots don't notice) |
| Resend with an `idempotency_key` | `200 {"ok":true,"id":"<original id>","duplicate":true,"gtm_event":"…"}` |
| Sent as form-urlencoded and schema.json has `success_url` | `303` to that URL (only within the site's allowed origins) |

`gtm_event` is the completion event name set in the dashboard.

### Errors

All errors have the shape `{"ok":false,"error":"…"}`.

| Status | `error` | Meaning |
|---|---|---|
| 400 | `invalid_request` | Invalid input. `errors` lists the keys of the invalid fields (values are not returned). A body that can't be parsed as JSON also returns this |
| 403 | `turnstile_failed` | The Turnstile token is missing or can't be verified |
| 403 | `origin_not_allowed` | The sending origin is not in the site's allowed origins. Not saved ([how to fix](https://siteon.dev/docs/domains#origin-not-allowed)) |
| 404 | `not_found` | The site or form doesn't exist |
| 413 | `payload_too_large` | Over 16KB |
| 429 | `rate_limited` | More than 20 submissions in 10 minutes from the same source |

### Submitting from a browser on another origin (CORS)

For submissions from a browser, the sending origin must be in the site's **allowed origins**. Submissions without an `Origin` (curl, servers) are accepted regardless of the allowed origins. Cookies are not used.

Submissions from an `Origin` not in the allowed origins are not saved and return `403 origin_not_allowed` (they do not count toward your quota either, so other people's sites can't use your form and consume it). No CORS headers are returned, so a browser sending `Content-Type: application/json` stops at the pre-flight check before sending. How to register origins and fix this is in [Custom domains and allowed origins](https://siteon.dev/docs/domains#origin-not-allowed). When you reach a limit, the response is `429 quota_exceeded` ([Plans and limits](https://siteon.dev/docs/plans#when-you-reach-a-limit)).
