# Crossword Generator API

> Search words, find clues, fill grids, and publish puzzles in 24 languages.  
> Contract v1.0.0 · 17 operations · base URL `https://crossword.texs.org/api/v1`

Machine-readable forms of this documentation:
- This document: https://crossword.texs.org/developers.md (also served for `Accept: text/markdown` on https://crossword.texs.org/developers)
- Error codes: https://crossword.texs.org/developers/errors.md
- OpenAPI 3.1: https://crossword.texs.org/api/v1/openapi.json
- Index: https://crossword.texs.org/llms.txt
- MCP server (stdio): `npx -y xword mcp` · CLI and TypeScript client: `npm i xword`

Use the same crossword tools as
[crossword.texs.org](https://crossword.texs.org). You can search words, find
clues, fill grids, and publish puzzles.

## Authentication

Send your key as a bearer token:

```
Authorization: Bearer cw_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```

If your platform reserves `Authorization`, use `X-Api-Key` instead:

```
X-Api-Key: cw_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```

Send one header, not both. Create a key in
[Dashboard → API](https://crossword.texs.org/dashboard?tab=api).

A scope controls what a key can do:

| Scope | Covers |
|---|---|
| `read` | Search words, find clues, and read or export puzzles |
| `solve` | Fill and clean grids, cancel a fill, and create AI clues |
| `puzzles:write` | Create, update, delete, and publish puzzles |

New keys get all three scopes. Remove any your app does not need. A missing
scope returns `403 FORBIDDEN_SCOPE`.

## Naming

Solver request fields use snake_case, such as `min_score`. Response fields
use camelCase, such as `slotsFilled`.

## Grids

A grid is a list of text rows. Use `.` for an empty cell, `#` for a black
cell, and a letter for a fixed cell. Grids must be square and 3 to 23 cells
wide. See the `Grid` schema.

## Errors

Errors use `application/problem+json`. Use `code` in your program. The
`detail` text is for people and may change.

## Rate limits and quotas

Limits depend on the account plan. Read responses include
`X-RateLimit-Limit` and `X-RateLimit-Remaining`. A 429 response includes
`Retry-After`. Fill responses include `X-Fill-Quota-Remaining`. A value of
`-1` means there is no limit.

## Rules for Use

The Crossword API lets you build, submit, and showcase your puzzles programmatically. You must follow the Terms of Use (https://crossword.texs.org/terms), and we may turn off API keys for abuse.

1. **Give credit.** Link to https://crossword.texs.org and use one of the logos below. Printed puzzles must show the logo or say "Powered by Crossword Generator" with the URL.
2. **Do not impersonate anyone.** The `author` can be you, your site, a pen name, or someone who agreed to be credited.
3. **Follow the content rules.** They apply to every part of a puzzle. You are responsible for what you publish.
4. **Do not copy the full data set.** Do not rebuild the word lists or clue collection by making many API calls. See the data licenses below.

Light logo: [AVIF](https://crossword.texs.org/crossword-logo.avif) · [WebP](https://crossword.texs.org/crossword-logo.webp) · [GIF](https://crossword.texs.org/crossword-logo.gif) · [JPG](https://crossword.texs.org/crossword-logo.jpg) · [SVG](https://crossword.texs.org/crossword-logo.svg)

Dark logo: [AVIF](https://crossword.texs.org/crossword-logo-dark.avif) · [WebP](https://crossword.texs.org/crossword-logo-dark.webp) · [GIF](https://crossword.texs.org/crossword-logo-dark.gif) · [JPG](https://crossword.texs.org/crossword-logo-dark.jpg) · [SVG](https://crossword.texs.org/crossword-logo-dark.svg)

```html
<a href="https://crossword.texs.org" aria-label="Crossword Generator">
  <picture>
    <source srcset="https://crossword.texs.org/crossword-logo.avif" type="image/avif">
    <source srcset="https://crossword.texs.org/crossword-logo.webp" type="image/webp">
    <img src="https://crossword.texs.org/crossword-logo.gif"
         alt="Powered by texs.org — Crossword Generator" width="500" height="120"
         style="max-width:100%;height:auto">
  </picture>
</a>
```

## Pricing

API access is included with your account. There is no extra API bill. Your key uses the same limits as the web app.

| Tier | Price | Reads / minute | Fills / month | New puzzles / month | Max grid | AI clues / month |
|---|---|---|---|---|---|---|
| Free | Free | 30 | 30 | 30 | 13×13 | 0 |
| Starter | $6/mo or $60/yr | 60 | 200 | 200 | 23×23 | 250 |
| Partner | $12/mo or $120/yr | 120 | 600 | 600 | 23×23 | 1,200 |
| Patron | $25/mo or $250/yr | 300 | 2,000 | 2,000 | 23×23 | 5,000 |

Upgrade at https://account.texs.org/. One membership covers every texs.org app. A clean-up counts as one fill. Creating a puzzle uses the monthly puzzle limit. A repeated create with the same `Idempotency-Key`, an edit, or a publish does not count again. Need more than Patron? Email tex@texs.org with your expected use, grid size, language, and whether you need AI clues.

## Quickstart

Create a key at https://crossword.texs.org/dashboard?tab=api. Copy it right away. You will only see it once.

```bash
export CROSSWORD_API_KEY=cw_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```

Search the word list. Use `_` for an unknown letter. Scores run from 0 to 100, and 40 or more is usually good for a clean fill:

```bash
curl -s 'https://crossword.texs.org/api/v1/words?pattern=C_T&lang=en&min_score=40' \
  -H "Authorization: Bearer $CROSSWORD_API_KEY"
```

JavaScript (`xword` package):

```ts
import { CrosswordClient } from "xword";

const client = new CrosswordClient({ apiKey: process.env.CROSSWORD_API_KEY });
const words = await client.searchWords({ pattern: "C_T", lang: "en", min_score: 40 });
```

CLI:

```bash
xword words "C_T" --lang en --min-score 40
```

```json
{
  "words": [
    { "word": "COT", "score": 90 },
    { "word": "CUT", "score": 90 },
    { "word": "CAT", "score": 80 },
    { "word": "CDT", "score": 50 },
    { "word": "CST", "score": 50 },
    { "word": "CIT", "score": 40 },
    { "word": "CRT", "score": 40 }
  ]
}
```

Fill a grid. Send text rows. Use `.` for an empty cell, `#` for a black cell, and a letter for a fixed cell. The solver may return a different answer each time:

```bash
curl -s https://crossword.texs.org/api/v1/fill \
  -H "Authorization: Bearer $CROSSWORD_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "grid": ["..#..", ".....", "..A..", ".....", "..#.."],
    "language": "en",
    "min_score": 40
  }'
```

JavaScript (`xword` package):

```ts
const result = await client.fillGrid({
  grid: ["..#..", ".....", "..A..", ".....", "..#.."],
  language: "en",
  min_score: 40,
});
console.log(result.grid, result.quality.rough);
```

CLI:

```bash
printf '..#..\n.....\n..A..\n.....\n..#..\n' > small.txt
xword fill small.txt --lang en --min-score 40
```

```json
{
  "grid": ["KD#MC", "NINER", "OVATE", "LEGOS", "LR#OT"],
  "slotsFilled": 8,
  "slotsTotal": 8,
  "sessionId": "kKq2x9Wn0ZqQm5Yc1Lr7Hs3T",
  "quality": { "scored": 8, "rough": [] }
}
```

## Example: build and publish a puzzle

This example uses the theme layout from "Greek Mythology" by Jerrie Stack (https://crossword.texs.org/puzzle/6qix6k1x). It keeps the black cells and five theme words (ATLAS, ZEUS, PAN, MEDUSA, APOLLO). Every response came from a real request.

1. **Check the theme words.** Unknown words are left out, not scored 0:

```bash
curl -s https://crossword.texs.org/api/v1/words/scores \
  -H "Authorization: Bearer $CROSSWORD_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"words": ["ZEUS", "HADES", "PERSEPHONE", "XQZZY"], "language": "en"}'
```

JavaScript (`xword` package):

```ts
const scores = await client.scoreWords({
  words: ["ZEUS", "HADES", "PERSEPHONE", "XQZZY"],
  language: "en",
});
```

CLI:

```bash
xword scores ZEUS HADES PERSEPHONE XQZZY
```

```json
{
  "scores": { "ZEUS": 80, "HADES": 80, "PERSEPHONE": 90 }
}
```

2. **Fill around the theme.** Letters already in the grid stay in place:

```bash
curl -s https://crossword.texs.org/api/v1/fill \
  -H "Authorization: Bearer $CROSSWORD_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "language": "en",
    "min_score": 40,
    "max_time": 25,
    "grid": [
      "##M..###Z..",
      "#.E..###E..",
      "..D....#U..",
      "..U##...S##",
      "..S##PAN###",
      "#.A.##..A..",
      "###....#P..",
      "#ATLAS##O..",
      "...#....L..",
      "...##...L##",
      "...##...O##"
    ]
  }'
```

JavaScript (`xword` package):

```ts
const skeleton = [
  "##M..###Z..",
  "#.E..###E..",
  "..D....#U..",
  "..U##...S##",
  "..S##PAN###",
  "#.A.##..A..",
  "###....#P..",
  "#ATLAS##O..",
  "...#....L..",
  "...##...L##",
  "...##...O##"
];
const filled = await client.fillGrid({ grid: skeleton, language: "en", min_score: 40 });
```

CLI:

```bash
cat > theme.txt <<'EOF'
##M..###Z..
#.E..###E..
..D....#U..
..U##...S##
..S##PAN###
#.A.##..A..
###....#P..
#ATLAS##O..
...#....L..
...##...L##
...##...O##
EOF
xword fill theme.txt --stream --out filled.txt
```

```json
{
  "grid": [
    "##MMI###ZAP",
    "#HEAR###EYE",
    "CODDERS#USC",
    "IOU##APES##",
    "ODS##PAN###",
    "#SAC##CLAMP",
    "###PACE#POE",
    "#ATLAS##ORS",
    "AME#STARLET",
    "DMS##ARAL##",
    "DOT##RENO##"
  ],
  "slotsFilled": 44,
  "slotsTotal": 44,
  "sessionId": "p8Vq2mJx0Rt5Kc7Yw1Ln3Hs9",
  "quality": { "scored": 39, "rough": [] }
}
```

3. **Clean up weak words.** If `quality.rough` is not empty, call `POST /fill/improve`. Put theme cells in `locked` so they do not change:

```bash
curl -s https://crossword.texs.org/api/v1/fill/improve \
  -H "Authorization: Bearer $CROSSWORD_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "language": "en",
    "grid": [ ...the filled grid... ],
    "locked": ["0,2", "1,2", "2,2", "3,2", ...every theme cell... ]
  }'
```

JavaScript (`xword` package):

```ts
if (filled.quality.rough.length > 0) {
  const cleaned = await client.improveFill({
    grid: filled.grid,
    locked: themeCells,   // "row,col" of every theme letter — never touched
    language: "en",
  });
  console.log(cleaned.improved, cleaned.quality.rough);
}
```

CLI:

```bash
xword improve filled.txt --lock 0,2 --lock 1,2 --lock 2,2 --out clean.txt
```

4. **Find clues in one call.** Send up to 500 answers. The API returns up to five clues for each answer, best first:

```bash
curl -s https://crossword.texs.org/api/v1/clues/bulk \
  -H "Authorization: Bearer $CROSSWORD_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"words": ["ATLAS", "MEDUSA"], "language": "en"}'
```

JavaScript (`xword` package):

```ts
const clues = await client.lookupCluesBulk({
  words: ["ATLAS", "MEDUSA"],   // up to 500 — every answer in the grid, in one call
  language: "en",
});
```

CLI:

```bash
xword clues ATLAS
xword clues MEDUSA
```

```json
{
  "clues": {
    "ATLAS": [
      { "text": "Geography reference volume", "source": "original", "pubCount": 0 },
      { "text": "Book of map charts", "source": "original", "pubCount": 0 },
      { "text": "World music compilation, loosely", "source": "original", "pubCount": 0 },
      { "text": "Titan bearing heavens in myth", "source": "original", "pubCount": 0 },
      ...
    ],
    "MEDUSA": [
      { "text": "Gorgon slain by Perseus", "source": "original", "pubCount": 0 },
      { "text": "Snake-haired mythological figure", "source": "original", "pubCount": 0 },
      { "text": "One of three Gorgons", "source": "original", "pubCount": 0 },
      ...
    ]
  }
}
```

5. **Save it.** Use clue numbers as keys. Do not send `size`; it comes from the grid:

```bash
curl -s https://crossword.texs.org/api/v1/puzzles \
  -H "Authorization: Bearer $CROSSWORD_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "title": "Greek Mythology",
    "author": "Jerrie Stack",
    "language": "en",
    "grid": [ ...the filled grid... ],
    "clues": {
      "across": { "1": "Poli ___", "4": "Driving hazard" },
      "down":   { "1": "Southwest Japanese port", "2": "Young horse" }
    },
    "themeWords": ["ATLAS", "ZEUS", "PAN", "MEDUSA", "APOLLO"]
  }'
```

JavaScript (`xword` package):

```ts
const puzzle = await client.createPuzzle({
  title: "Greek Mythology",
  author: "Jerrie Stack",
  language: "en",
  grid: filled.grid,
  clues: { across, down },      // clue number → clue text
  themeWords: ["ATLAS", "ZEUS", "PAN", "MEDUSA", "APOLLO"],
});
```

CLI:

```bash
xword puzzles create puzzle.json
```

6. **Publish.** The puzzle is unlisted by default. Send `submitToShowcase: true` to ask for a review. The response includes the public page and embed URL:

```bash
curl -s -X POST https://crossword.texs.org/api/v1/puzzles/6qix6k1x/publish \
  -H "Authorization: Bearer $CROSSWORD_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"submitToShowcase": true}'
```

JavaScript (`xword` package):

```ts
const live = await client.publishPuzzle(puzzle.id, { submitToShowcase: true });
console.log(live.url, live.embedUrl);
```

CLI:

```bash
xword puzzles publish 6qix6k1x --showcase
```

```json
{
  "id": "6qix6k1x",
  "status": "published",
  "showcaseStatus": "pending",
  "url": "https://crossword.texs.org/puzzle/6qix6k1x",
  "embedUrl": "https://crossword.texs.org/embed/6qix6k1x",
  ...
}
```

7. **Export.** Download JSON or an Across Lite `.puz` file. `.puz` only works for supported Latin-script languages:

```bash
curl -s 'https://crossword.texs.org/api/v1/puzzles/6qix6k1x/export?format=puz' \
  -H "Authorization: Bearer $CROSSWORD_API_KEY" -o greek-mythology.puz
```

JavaScript (`xword` package):

```ts
const { data, filename } = await client.exportPuzzle(puzzle.id, { format: "puz" });
await fs.writeFile(filename, data);
```

CLI:

```bash
xword export 6qix6k1x --puz --out greek-mythology.puz
```

8. **Embed it.** The optional script adjusts the frame height and matches the puzzle's light/dark appearance to the surrounding page (`?scheme=light|dark` on the src pins it instead). The caption is ordinary HTML that inherits your page's font and colour:

```html
<iframe src="https://crossword.texs.org/embed/6qix6k1x"
        title="Greek Mythology" width="100%" height="1260"
        style="border:0;max-width:100%;display:block" loading="lazy"></iframe>
<script src="https://crossword.texs.org/embed.js" async></script>
<p style="display:flex;gap:1em;margin:0.5em 0 0;font-size:0.8em;opacity:0.75">
  <span>Made with <a href="https://crossword.texs.org/" style="color:inherit;text-decoration:none;font-weight:600">Crossword Generator</a></span>
  <a href="https://crossword.texs.org/puzzle/6qix6k1x" style="color:inherit;text-decoration:none;margin-left:auto">Open full puzzle ↗</a>
</p>
```

## Rate limits and quotas

See the Pricing table for each plan's limits.

- Read requests have a per-minute limit. Fills and clean-ups have a monthly limit. Only a few fills can run at once.
- A clean-up (`/fill/improve`) counts as one fill. AI clues share a limit with the web app.
- Useful headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `Retry-After`, `X-Fill-Quota-Remaining`, `X-Fill-Session-Id`, and `X-Ai-Clues-Remaining`. `-1` means there is no limit.
- A word search returns at most 100 results. You cannot export the full word or clue database.

## Streaming a fill

By default, `POST /fill` waits and returns JSON. Send `Accept: text/event-stream` to get live updates. Events arrive as `session`, zero or more `progress` events, then `complete` or `error`. A later update may show fewer filled cells because the solver backed up. Always use the newest event.

```bash
curl -N https://crossword.texs.org/api/v1/fill \
  -H "Authorization: Bearer $CROSSWORD_API_KEY" \
  -H 'Accept: text/event-stream' \
  -H 'Content-Type: application/json' \
  -d '{"grid": ["..#..", ".....", "..A..", ".....", "..#.."], "language": "en"}'
```

JavaScript (`xword` package):

```ts
for await (const event of client.fillGridStream({ grid, language: "en" })) {
  if (event.type === "session") sessionId = event.sessionId; // cancel handle
  if (event.type === "progress") render(event.fill);           // newest frame wins
  if (event.type === "complete") console.log(event.grid, event.quality);
  if (event.type === "error") console.error(event.reason);
}
```

CLI:

```bash
xword fill small.txt --stream
```

```
data: {"type":"session","sessionId":"kKq2x9Wn0ZqQm5Yc1Lr7Hs3T"}

data: {"type":"progress","filled":3,"total":8,"fill":{"1,0":"N","1,1":"I","1,2":"N"}}

data: {"type":"progress","filled":6,"total":8,"fill":{"1,0":"N","2,0":"O","3,0":"L"}}

data: {"type":"complete","grid":["KD#MC","NINER","OVATE","LEGOS","LR#OT"],
       "slotsFilled":8,"slotsTotal":8,"quality":{"scored":8,"rough":[]}}
```

Cancel with the session ID from the first event or the `X-Fill-Session-Id` header:

```bash
curl -s https://crossword.texs.org/api/v1/fill/cancel \
  -H "Authorization: Bearer $CROSSWORD_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"sessionId": "kKq2x9Wn0ZqQm5Yc1Lr7Hs3T"}'
```

JavaScript (`xword` package):

```ts
await client.cancelFill(sessionId);
```

## Errors

Errors use `application/problem+json`. Use `code` in your program. The `detail` text is for people and may change. See every code at https://crossword.texs.org/developers/errors.md

| Code | Title |
|---|---|
| `UNAUTHORIZED` | Unauthorized |
| `FORBIDDEN_SCOPE` | Forbidden |
| `GRID_SIZE_LOCKED` | Grid size locked |
| `MEMBERS_ONLY` | Members only |
| `AI_QUOTA_REACHED` | AI clue quota reached |
| `FILL_QUOTA_REACHED` | Fill quota reached |
| `PUZZLE_QUOTA_REACHED` | Puzzle quota reached |
| `SHOWCASE_QUEUE_FULL` | Showcase queue full |
| `RATE_LIMITED` | Rate limited |
| `SOLVER_BUSY` | Solver busy |
| `LANGUAGE_UNAVAILABLE` | Language unavailable |
| `VALIDATION_ERROR` | Validation error |
| `NOT_FOUND` | Not found |
| `UPSTREAM_UNAVAILABLE` | Upstream unavailable |

## Endpoint reference

## Meta

Check the service and list languages. No key is needed.

### GET /status

Service status  
No key required.

Check whether the API and each language index are ready. No key is
needed, but requests are limited by IP address.

This endpoint returns `200` even when the solver is down. In that case,
`status` is `"degraded"` and `languages` is empty. Watch the `status`
field, not only the HTTP status. The only other response is `429`.

**Responses**

- `200` — `ok` when the solver is healthy. `degraded` when it is not.

`application/json`

- `status` *string* **required** (one of `"ok"`, `"degraded"`)
- `version` *string* **required** — API contract version.
- `languages` *object<string, object>* **required** — Per-language index state, keyed by language code.

Healthy, English resident:

```json
{
  "status": "ok",
  "version": "1.0.0",
  "languages": {
    "en": {"available":true,"loaded":true},
    "ru": {"available":true,"loaded":false},
    "hi": {"available":true,"loaded":false}
  }
}
```

The solver could not be reached:

```json
{"status":"degraded","version":"1.0.0","languages":{}}
```

- `429` — Per-minute rate limit exceeded for this key's tier. Headers: `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`.

### GET /languages

List puzzle languages  
No key required.

List every supported language and its main rules. The response shows
whether the language is ready and whether it needs a criss-cross grid.
It also shows reading direction, `.puz` support, and the shortest answer.

`pt-BR` is accepted as input but is returned as `pt`. Both forms of
Portuguese use the same data. `available: false` means word search and
fill will not work for that language.

No key is needed, but requests are limited by IP address.

**Responses**

- `200` — The language registry.

`application/json`

- `languages` *array of object* **required**
  - `code` *string* **required** — Puzzle language code. You may send `pt-BR`, but the API returns `pt`. Both use the same Portuguese data. (default `"en"`; one of 25: `"en"`, `"es"`, `"fr"`, `"de"`, `"it"`, `"pt"`, `"pt-BR"`, `"pl"` …)
  - `name` *string* **required** — English name.
  - `nativeName` *string* **required** — The language's own name.
  - `available` *boolean* **required** — `true` when word search and fill work for this language.
  - `crissCrossOnly` *boolean* **required** — `true` for Chinese, Japanese, and Korean. Build these as criss-cross grids. `POST /fill` cannot fill a dense grid for them.
  - `rtl` *boolean* **required** — `true` for a right-to-left language. The data still starts at column 0. Mirror only the display.
  - `puzExportable` *boolean* **required** — `true` when this language can be saved as a `.puz` file.
  - `minSlotLength` *integer* **required** — The shortest space that counts as an answer. Usually 3 cells, or 2 for Chinese, Japanese, and Korean.

Three representative entries:

```json
{
  "languages": [
    {
      "code": "en",
      "name": "English",
      "nativeName": "English",
      "available": true,
      "crissCrossOnly": false,
      "rtl": false,
      "puzExportable": true,
      "minSlotLength": 3
    },
    {
      "code": "he",
      "name": "Hebrew",
      "nativeName": "עברית",
      "available": true,
      "crissCrossOnly": false,
      "rtl": true,
      "puzExportable": false,
      "minSlotLength": 3
    },
    {
      "code": "ja",
      "name": "Japanese",
      "nativeName": "日本語",
      "available": true,
      "crissCrossOnly": true,
      "rtl": false,
      "puzExportable": false,
      "minSlotLength": 2
    }
  ]
}
```

- `429` — Per-minute rate limit exceeded for this key's tier. Headers: `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`.

## Words

Search and score words in 24 languages.

### GET /words

Search words by pattern  
Scope: `read`

Search a language's word list. Use `_` or `?` for an unknown letter.
Results are sorted by score, highest first.

Scores run from 0 to 100. A score of 40 or more is usually good for a
clean fill. A search returns at most 100 words. You cannot use this
endpoint to download the full word list.

**Parameters**

| Name | In | Type | Notes |
|---|---|---|---|
| `pattern` | query | string | **required** · The slot pattern. `_` or `?` for an unknown cell. · 1–23 characters · e.g. `C_T` |
| `lang` | query | string | Puzzle language code. · default `"en"` · one of 25: `"en"`, `"es"`, `"fr"`, `"de"`, `"it"`, `"pt"`, `"pt-BR"`, `"pl"` … · e.g. `en` |
| `min_score` | query | integer | Drop entries scoring below this. · default `0` · 0–1000 · e.g. `40` |
| `limit` | query | integer | Maximum matches to return. · default `50` · 1–100 |

**Responses**

- `200` — Matching words, best first. Headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`.

`application/json`

- `words` *array of object* **required**
  - `word` *string* **required** — The entry, normalized for its language.
  - `score` *integer* **required** — Word quality from 0 to 100. A score of 40 or more is usually good for a clean fill.

GET /words?pattern=C_T&lang=en&min_score=40:

```json
{
  "words": [
    {"word":"COT","score":90},
    {"word":"CUT","score":90},
    {"word":"CAT","score":80},
    {"word":"CDT","score":50},
    {"word":"CST","score":50},
    {"word":"CIT","score":40},
    {"word":"CRT","score":40}
  ]
}
```

GET /words?pattern=Л_НА&lang=ru:

```json
{"words":[{"word":"ЛУНА","score":70}]}
```

- `400` — The request body or query string did not validate.

- `401` — Missing, malformed, unknown or revoked key.

- `403` — The key is valid but lacks the scope this operation needs.

- `429` — Per-minute rate limit exceeded for this key's tier. Headers: `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`.

- `503` — The solver or word-index service could not be reached, or the language index could not be loaded.

### POST /words/scores

Score a batch of words  
Scope: `read`

Get a score for each known word. Response keys match the words you sent.
Unknown words are left out instead of getting a score of 0. This lets
you tell the difference between an unknown word and a weak word.

**Request body**

`application/json`

- `words` *array of string* **required** (1–200 items)
- `language` *string* — Puzzle language code. You may send `pt-BR`, but the API returns `pt`. Both use the same Portuguese data. (default `"en"`; one of 25: `"en"`, `"es"`, `"fr"`, `"de"`, `"it"`, `"pt"`, `"pt-BR"`, `"pl"` …)

basic:

```json
{"words":["CAT","ESNE","ZZTOP"],"language":"en"}
```

**Responses**

- `200` — Score per known word. Headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`.

`application/json`

- `scores` *object<string, integer>* **required** — Word → score. Unknown words are omitted.

ZZTOP is not in the index, so it is absent:

```json
{"scores":{"CAT":80,"ESNE":25}}
```

- `400` — The request body or query string did not validate.

- `401` — Missing, malformed, unknown or revoked key.

- `403` — The key is valid but lacks the scope this operation needs.

- `429` — Per-minute rate limit exceeded for this key's tier. Headers: `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`.

- `503` — The solver or word-index service could not be reached, or the language index could not be loaded.

## Clues

Find saved clues or create new AI clues.

### GET /clues/{word}

Clues for one answer  
Scope: `read`

Find clues for one answer. The best clues come first. Repeated clues are
removed.

**Parameters**

| Name | In | Type | Notes |
|---|---|---|---|
| `word` | path | string | **required** · The answer. Normalized (uppercased, accents folded per language) server-side. · 1–23 characters · e.g. `SUB` |
| `language` | query | string | default `"en"` · one of 25: `"en"`, `"es"`, `"fr"`, `"de"`, `"it"`, `"pt"`, `"pt-BR"`, `"pl"` … |
| `limit` | query | integer | default `10` · 1–50 |

**Responses**

- `200` — Clues for the answer, best first. An answer with no clues returns an empty list. Headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`.

`application/json`

- `clues` *array of object* **required**
  - `text` *string* **required** — The clue itself. (0–150 characters)
  - `source` *string* **required** — Where the clue came from: `published`, `dictionary`, `original`, or `ai`. Only a new, unsaved `ai` clue includes a `token`. (one of `"published"`, `"dictionary"`, `"original"`, `"ai"`)
  - `pubCount` *integer* — How many times a published clue appeared in print. Other clue types return `0`.
  - `token` *string* — Proof that this API created a new AI clue. Keep it with the clue if you publish the puzzle. Saved clues leave this field out.

GET /clues/SUB?language=en&limit=3:

```json
{
  "clues": [
    {"text":"Submarine sandwich","source":"published","pubCount":118},
    {"text":"Fill-in teacher","source":"published","pubCount":64},
    {"text":"Stand-in","source":"original","pubCount":0}
  ]
}
```

- `400` — The request body or query string did not validate.

- `401` — Missing, malformed, unknown or revoked key.

- `403` — The key is valid but lacks the scope this operation needs.

- `429` — Per-minute rate limit exceeded for this key's tier. Headers: `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`.

- `503` — The solver or word-index service could not be reached, or the language index could not be loaded.

### POST /clues/bulk

Clues for many answers  
Scope: `read`

Find clues for up to 500 answers in one request. The API returns up to
five clues per answer. Answers with no clues are left out. Response keys
may be uppercased or cleaned up for the language.

**Request body**

`application/json`

- `words` *array of string* **required** (1–500 items)
- `language` *string* — Puzzle language code. You may send `pt-BR`, but the API returns `pt`. Both use the same Portuguese data. (default `"en"`; one of 25: `"en"`, `"es"`, `"fr"`, `"de"`, `"it"`, `"pt"`, `"pt-BR"`, `"pl"` …)

afterFill:

```json
{"words":["SUB","OREO","QWERTYX"],"language":"en"}
```

**Responses**

- `200` — Clues keyed by normalized answer. Headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`.

`application/json`

- `clues` *object<string, array of object>* **required**

QWERTYX has no clues, so it is absent:

```json
{
  "clues": {
    "SUB": [{"text":"Submarine sandwich","source":"published","pubCount":118}],
    "OREO": [{"text":"Cookie with a creme center","source":"published","pubCount":402}]
  }
}
```

- `400` — The request body or query string did not validate.

- `401` — Missing, malformed, unknown or revoked key.

- `403` — The key is valid but lacks the scope this operation needs.

- `429` — Per-minute rate limit exceeded for this key's tier. Headers: `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`.

- `503` — The solver or word-index service could not be reached, or the language index could not be loaded.

### POST /clues/generate

Generate AI clues for an answer  
Scope: `solve`

Write new clues for one answer with AI. This feature needs a paid plan
and the `solve` scope. The Free plan returns `403 MEMBERS_ONLY`.

Each clue includes a `token` that proves this API made it. This request
does not save the clue. A clue may be added to the shared collection
after you publish a puzzle that uses it.

`X-Ai-Clues-Remaining` shows how many AI clue requests are left this
month. A value of `-1` means there is no limit.

**Request body**

`application/json`

- `word` *string* **required** (2–23 characters)
- `count` *integer* (default `5`; 1–10)
- `language` *string* — Puzzle language code. You may send `pt-BR`, but the API returns `pt`. Both use the same Portuguese data. (default `"en"`; one of 25: `"en"`, `"es"`, `"fr"`, `"de"`, `"it"`, `"pt"`, `"pt-BR"`, `"pl"` …)

basic:

```json
{"word":"LIGHTHOUSE","count":3,"language":"en"}
```

**Responses**

- `200` — Freshly written clue candidates. Headers: `X-Ai-Clues-Remaining`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`.

`application/json`

- `clues` *array of object* **required**
  - `text` *string* **required** — The clue itself. (0–150 characters)
  - `source` *string* **required** — Where the clue came from: `published`, `dictionary`, `original`, or `ai`. Only a new, unsaved `ai` clue includes a `token`. (one of `"published"`, `"dictionary"`, `"original"`, `"ai"`)
  - `pubCount` *integer* — How many times a published clue appeared in print. Other clue types return `0`.
  - `token` *string* — Proof that this API created a new AI clue. Keep it with the clue if you publish the puzzle. Saved clues leave this field out.
- `remaining` *integer* — AI clues left this month; `-1` for unlimited tiers.

basic:

```json
{
  "clues": [
    {
      "text": "Beacon on a rocky point",
      "source": "ai",
      "pubCount": 0,
      "token": "3f1c…"
    },
    {"text":"Keeper's workplace","source":"ai","pubCount":0,"token":"9ab2…"}
  ],
  "remaining": 247
}
```

- `400` — The request body or query string did not validate.

- `401` — Missing, malformed, unknown or revoked key.

- `403` — `FORBIDDEN_SCOPE` (key lacks `solve`), or `MEMBERS_ONLY` (Free tier).

- `429` — `AI_QUOTA_REACHED` (monthly allowance spent) or `RATE_LIMITED`. Headers: `Retry-After`, `X-Ai-Clues-Remaining`.

- `502` — The solver or word-index service could not be reached, or the language index could not be loaded.

- `503` — The solver or word-index service could not be reached, or the language index could not be loaded.

## Solver

Fill a grid, clean it up, or stop a fill.

### POST /fill

Auto-fill a grid  
Scope: `solve`

Fill the empty cells while keeping all black cells and fixed letters.
Most 15×15 grids take 1 to 30 seconds.

Choose the response with the `Accept` header:

* `application/json` waits for the final result.
* `text/event-stream` sends live `session`, `progress`, `complete`, and
  `error` events.

`min_score` sets the word quality goal. The default is 40. The solver
may use a lower score when a grid is hard to finish. Check
`quality.rough` for those words. Send a rough fill to
`POST /fill/improve` to try for cleaner words.

A fill can return `200` without solving the grid. Check whether
`slotsFilled` equals `slotsTotal`. When nothing is filled, `reason`
explains why. A server failure returns `502 UPSTREAM_UNAVAILABLE`.

Free accounts can fill grids up to 13×13. Paid plans can fill up to
23×23. A fill or clean-up uses one monthly fill. This is true even when
the solver finds no answer. `X-Fill-Quota-Remaining` shows the balance.

Chinese, Japanese, and Korean need criss-cross grids. The solver cannot
fill dense grids in those languages.

**Parameters**

| Name | In | Type | Notes |
|---|---|---|---|
| `Accept` | header | string | `application/json` (default) or `text/event-stream`. · default `"application/json"` · one of `"application/json"`, `"text/event-stream"` |

**Request body**

`application/json`

- `grid` *array of string* **required** — A square grid made of text rows, starting with the top row: * `.`: an empty white cell the solver may fill * `#`: a black square * anything else: a fixed letter A grid must be 3 to 23 cells wide, with the same number of rows. Some visible letters use more than one Unicode character. Count visible letters with a grapheme tool such as `Intl.Segmenter`, not JavaScript `.length`. The API may uppercase or clean up letters for the language. The returned text may not exactly match the bytes you sent. (3–23 items)
- `min_score` *integer* — Word quality goal. The default is 40. The solver may use lower scores when a grid is hard to finish. Check `quality.rough` after the fill. (default `40`; 0–1000)
- `max_time` *number* — How many seconds the solver may work. The maximum is 30 seconds. (default `25`; 1–30)
- `language` *string* — Puzzle language code. You may send `pt-BR`, but the API returns `pt`. Both use the same Portuguese data. (default `"en"`; one of 25: `"en"`, `"es"`, `"fr"`, `"de"`, `"it"`, `"pt"`, `"pt-BR"`, `"pl"` …)
- `try_hard_grid` *boolean* — Keep trying until `max_time` ends. Use this when a grid you know is possible returns `too_difficult`. Hard grids may use the full time. (default `false`)

A 5×5 with two black squares and one theme letter:

```json
{
  "grid": ["..#..",".....","..A..",".....","..#.."],
  "min_score": 40,
  "max_time": 25,
  "language": "en"
}
```

A Hebrew grid (data model stays logical/LTR):

```json
{"grid":["...#.",".....",".#ש..",".....",".#..."],"language":"he"}
```

**Responses**

- `200` — The completed fill (JSON), or the progress stream (SSE). Headers: `X-Fill-Session-Id`, `X-Fill-Quota-Remaining`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`.

`application/json`

- `grid` *array of string* **required** — A square grid made of text rows, starting with the top row: * `.`: an empty white cell the solver may fill * `#`: a black square * anything else: a fixed letter A grid must be 3 to 23 cells wide, with the same number of rows. Some visible letters use more than one Unicode character. Count visible letters with a grapheme tool such as `Intl.Segmenter`, not JavaScript `.length`. The API may uppercase or clean up letters for the language. The returned text may not exactly match the bytes you sent. (3–23 items)
- `slotsFilled` *integer* **required** — Entries the solver placed.
- `slotsTotal` *integer* **required** — Entries the grid has. Equal to `slotsFilled` on a complete fill; a smaller `slotsFilled` is a partial.
- `quality` *object* **required** — A report on the fill. `scored` is the number of words the API checked. `rough` lists weak words, worst first. An empty list means the fill is clean. Use `POST /fill/improve` when the list is not empty.
  - `scored` *integer* **required**
  - `rough` *array of object* **required**
    - `word` *string* **required**
    - `score` *integer* **required**
    - `row` *integer* **required** — Zero-based row of the entry's first cell.
    - `col` *integer* **required** — Zero-based column of the entry's first cell.
    - `number` *integer* **required** — The clue number at that cell.
    - `direction` *string* **required** (one of `"across"`, `"down"`)
- `reason` *string* — Present when no cells were filled. `too_difficult` means the solver stopped early. `no_solution` means time ran out. `cancelled` means a cancel request stopped the fill. (one of `"too_difficult"`, `"no_solution"`, `"cancelled"`)
- `sessionId` *string* — Also returned in `X-Fill-Session-Id`. Usable with `POST /fill/cancel`.

The `mini` request above, filled:

```json
{
  "grid": ["KD#MC","NINER","OVATE","LEGOS","LR#OT"],
  "slotsFilled": 8,
  "slotsTotal": 8,
  "sessionId": "kKq2x9Wn0ZqQm5Yc1Lr7Hs3T",
  "quality": {"scored":8,"rough":[]}
}
```

A fill the solver had to settle for:

```json
{
  "grid": ["IF#JG","MANOR","PIQUE","ERASE","LY#TK"],
  "slotsFilled": 8,
  "slotsTotal": 8,
  "sessionId": "kKq2x9Wn0ZqQm5Yc1Lr7Hs3T",
  "quality": {
    "scored": 8,
    "rough": [{"word":"NQA","score":35,"row":1,"col":2,"number":6,"direction":"down"}]
  }
}
```

A well-formed grid the solver could not fill:

```json
{
  "grid": ["..#..",".....","..A..",".....","..#.."],
  "slotsFilled": 0,
  "slotsTotal": 8,
  "reason": "too_difficult",
  "sessionId": "kKq2x9Wn0ZqQm5Yc1Lr7Hs3T",
  "quality": {"scored":0,"rough":[]}
}
```

`text/event-stream` — object | object | object | object

The event sequence (each line is one `data:` frame):

```
data: {"type":"session","sessionId":"kKq2x9Wn0ZqQm5Yc1Lr7Hs3T"}

data: {"type":"progress","filled":2,"total":8,"fill":{"0,0":"K","1,0":"N","2,0":"O","3,0":"L","4,0":"L","0,1":"D","1,1":"I","2,1":"V","3,1":"E","4,1":"R"}}

data: {"type":"complete","grid":["KD#MC","NINER","OVATE","LEGOS","LR#OT"],"fill":{"0,0":"K","0,1":"D"},"slotsFilled":8,"slotsTotal":8,"quality":{"scored":8,"rough":[]}}
```

- `400` — `VALIDATION_ERROR` (bad grid) or `LANGUAGE_UNAVAILABLE`.

- `401` — Missing, malformed, unknown or revoked key.

- `403` — `FORBIDDEN_SCOPE`, or `GRID_SIZE_LOCKED` when the grid is larger than the caller's tier allows.

- `429` — `RATE_LIMITED`, `FILL_QUOTA_REACHED` (monthly fills spent), or `SOLVER_BUSY` (too many solves already running; retry shortly). Headers: `Retry-After`, `X-Fill-Quota-Remaining`.

- `502` — `UPSTREAM_UNAVAILABLE`: the solver failed or the stream broke. A normal solve that finds nothing returns `200` with `reason` set.

- `503` — The solver or word-index service could not be reached, or the language index could not be loaded.

### POST /fill/improve

Clean up a filled grid  
Scope: `solve`

Try to replace weak words with better ones. The black cells do not
change. The solver may rebuild much of the fill, so many words can
change at once.

Put theme cells in `locked`. Those cells will not change. Unknown words
are treated as your own content and are also left alone.

`improved: false` means the fill was already clean or the remaining weak
words cannot be changed. A clean-up uses one monthly fill.

**Request body**

`application/json`

- `grid` *array of string* **required** — A square grid made of text rows, starting with the top row: * `.`: an empty white cell the solver may fill * `#`: a black square * anything else: a fixed letter A grid must be 3 to 23 cells wide, with the same number of rows. Some visible letters use more than one Unicode character. Count visible letters with a grapheme tool such as `Intl.Segmenter`, not JavaScript `.length`. The API may uppercase or clean up letters for the language. The returned text may not exactly match the bytes you sent. (3–23 items)
- `locked` *array of string* — Cells the clean-up must not change. Write each one as `"row,col"`, with counting from 0. Lock every cell in a theme answer to protect the answer. (0–529 items)
- `language` *string* — Puzzle language code. You may send `pt-BR`, but the API returns `pt`. Both use the same Portuguese data. (default `"en"`; one of 25: `"en"`, `"es"`, `"fr"`, `"de"`, `"it"`, `"pt"`, `"pt-BR"`, `"pl"` …)
- `max_time` *number* — Wall-clock seconds for the clean-up pass. (default `25`; 1–30)

The grid `POST /fill` reported `NQA` in, with the theme `Q` locked:

```json
{
  "grid": ["IF#JG","MANOR","PIQUE","ERASE","LY#TK"],
  "locked": ["2,2"],
  "language": "en",
  "max_time": 25
}
```

**Responses**

- `200` — The clean-up result. Headers: `X-Fill-Quota-Remaining`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`.

`application/json`

- `improved` *boolean* **required**
- `replaced` *integer* — How many entries changed. Present when `improved` is true.
- `grid` *array of string* — A square grid made of text rows, starting with the top row: * `.`: an empty white cell the solver may fill * `#`: a black square * anything else: a fixed letter A grid must be 3 to 23 cells wide, with the same number of rows. Some visible letters use more than one Unicode character. Count visible letters with a grapheme tool such as `Intl.Segmenter`, not JavaScript `.length`. The API may uppercase or clean up letters for the language. The returned text may not exactly match the bytes you sent. (3–23 items)
- `quality` *object* **required** — A report on the fill. `scored` is the number of words the API checked. `rough` lists weak words, worst first. An empty list means the fill is clean. Use `POST /fill/improve` when the list is not empty.
  - `scored` *integer* **required**
  - `rough` *array of object* **required**
    - `word` *string* **required**
    - `score` *integer* **required**
    - `row` *integer* **required** — Zero-based row of the entry's first cell.
    - `col` *integer* **required** — Zero-based column of the entry's first cell.
    - `number` *integer* **required** — The clue number at that cell.
    - `direction` *string* **required** (one of `"across"`, `"down"`)

The `cleanup` request above, cleaned:

```json
{
  "improved": true,
  "replaced": 8,
  "grid": ["PA#PK","EVIAN","TOQUE","AISLE","LD#AS"],
  "quality": {"scored":8,"rough":[]}
}
```

Nothing rough, or the junk is forced:

```json
{
  "improved": false,
  "quality": {
    "scored": 8,
    "rough": [{"word":"NQA","score":35,"row":1,"col":2,"number":6,"direction":"down"}]
  }
}
```

- `400` — The request body or query string did not validate.

- `401` — Missing, malformed, unknown or revoked key.

- `403` — `FORBIDDEN_SCOPE` or `GRID_SIZE_LOCKED`.

- `429` — `RATE_LIMITED`, `FILL_QUOTA_REACHED`, or `SOLVER_BUSY`. Headers: `Retry-After`.

- `503` — The solver or word-index service could not be reached, or the language index could not be loaded.

### POST /fill/cancel

Cancel a running fill  
Scope: `solve`

Stop a streaming `POST /fill`. Get the ID from the first `session` event.
It also appears in the `X-Fill-Session-Id` header.

A finished or unknown ID still returns `200`. Nothing changes.

**Request body**

`application/json`

- `session_id` *string* **required** (1–128 characters)

cancel:

```json
{"session_id":"kKq2x9Wn0ZqQm5Yc1Lr7Hs3T"}
```

**Responses**

- `200` — Cancellation recorded.

`application/json`

- `cancelled` *boolean* **required**
- `sessionId` *string* **required**

ok:

```json
{"cancelled":true,"sessionId":"kKq2x9Wn0ZqQm5Yc1Lr7Hs3T"}
```

- `400` — The request body or query string did not validate.

- `401` — Missing, malformed, unknown or revoked key.

- `403` — The key is valid but lacks the scope this operation needs.

- `429` — Per-minute rate limit exceeded for this key's tier. Headers: `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`.

- `503` — The solver or word-index service could not be reached, or the language index could not be loaded.

## Puzzles

Save, publish, and export puzzles.

### GET /puzzles

List your puzzles  
Scope: `read`

List the account's drafts and published puzzles. The newest edit comes
first. This endpoint does not list other people's puzzles.

**Parameters**

| Name | In | Type | Notes |
|---|---|---|---|
| `status` | query | string | Filter by lifecycle state. · one of `"draft"`, `"published"` |
| `limit` | query | integer | default `50` · 1–100 |
| `offset` | query | integer | default `0` · 0–∞ |

**Responses**

- `200` — Your puzzles. Headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`.

`application/json`

- `puzzles` *array of object* **required**
  - `id` *string* **required** (matches `^[a-z0-9]{8}$`)
  - `title` *string* **required** (0–200 characters)
  - `author` *string* **required** — The name printed on the puzzle. It does not have to match the account name. (0–100 characters)
  - `language` *string* **required** — Puzzle language code. You may send `pt-BR`, but the API returns `pt`. Both use the same Portuguese data. (default `"en"`; one of 25: `"en"`, `"es"`, `"fr"`, `"de"`, `"it"`, `"pt"`, `"pt-BR"`, `"pl"` …)
  - `size` *integer* **required** (3–23; read-only)
  - `grid` *array of string* **required** — A square grid made of text rows, starting with the top row: * `.`: an empty white cell the solver may fill * `#`: a black square * anything else: a fixed letter A grid must be 3 to 23 cells wide, with the same number of rows. Some visible letters use more than one Unicode character. Count visible letters with a grapheme tool such as `Intl.Segmenter`, not JavaScript `.length`. The API may uppercase or clean up letters for the language. The returned text may not exactly match the bytes you sent. (3–23 items)
  - `clues` *object* **required**
    - `across` *object<string, string>* **required** — Clue text keyed by clue number. Across and Down use the numbers printed on the grid.
    - `down` *object<string, string>* **required** — Clue text keyed by clue number. Across and Down use the numbers printed on the grid.
  - `themeWords` *array of string* **required** — Theme answers to highlight on the puzzle page.
  - `status` *string* **required** (one of `"draft"`, `"published"`)
  - `showcaseStatus` *string | null* **required** — The puzzle's showcase review state. `null` means it was not submitted. A normal review moves from `pending` to `pre_approved`, then to `approved`. A blocked word moves it to `flagged` for a person to check. (one of `"pending"`, `"pre_approved"`, `"flagged"`, `"approved"`, `"rejected"`, `null`)
  - `adultThemes` *boolean* **required** — `true` when the safety check found adult themes. The public page has an age check, and the embed will not load. Add the same protection if you show the puzzle on your own site. (read-only)
  - `difficulty` *string | null* **required** — How hard the puzzle is to solve. The rating looks at clue style and word difficulty. Drafts return `null`. A new publish may also return `null` while the rating is being made. Editing clues makes a new rating. (one of `"easy"`, `"medium"`, `"hard"`, `"expert"`, `null`; read-only)
  - `writeup` *string* — Constructor's note shown on the puzzle page.
  - `showProfile` *boolean* — Whether this puzzle is linked from the author's public page.
  - `noIndex` *boolean* — Author asked search engines to skip the puzzle page.
  - `createdAt` *string (date-time)* **required**
  - `updatedAt` *string (date-time)* **required**
  - `url` *string (uri)* — Public puzzle page. Present once `status` is `published`. (read-only)
  - `embedUrl` *string (uri)* — Chrome-less frame for embedding. Present once `status` is `published`. (read-only)
- `total` *integer* **required**

oneDraft:

```json
{
  "total": 1,
  "puzzles": [
    {
      "id": "k3n8q1zp",
      "title": "Coastal Mini",
      "author": "Tex",
      "language": "en",
      "size": 5,
      "grid": ["KD#MC","NINER","OVATE","LEGOS","LR#OT"],
      "clues": {
        "across": {
          "5": "San Francisco footballer, informally",
          "7": "Egg-shaped",
          "8": "Bricks from Billund"
        },
        "down": {
          "1": "Grassy mound",
          "2": "One off the high board",
          "3": "2017 hashtag movement",
          "4": "Wave's high point",
          "6": "Pester"
        }
      },
      "themeWords": ["CREST"],
      "status": "draft",
      "showcaseStatus": null,
      "adultThemes": false,
      "difficulty": null,
      "createdAt": "2026-09-15T10:04:00Z",
      "updatedAt": "2026-09-15T10:22:31Z"
    }
  ]
}
```

- `401` — Missing, malformed, unknown or revoked key.

- `403` — The key is valid but lacks the scope this operation needs.

- `429` — Per-minute rate limit exceeded for this key's tier. Headers: `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`.

### POST /puzzles

Create a puzzle  
Scope: `puzzles:write`

Save a new puzzle to the account. Do not send `size`; the API gets it
from the grid. The account plan controls the largest allowed grid.

A new puzzle is a draft unless you send `publish: true`. To publish, the
puzzle needs a title and a clue for every finished answer. If it is not
ready, the API returns `400 VALIDATION_ERROR` and saves nothing.

`publish: true` makes an unlisted public puzzle. It does not submit the
puzzle to the showcase. Use `POST /puzzles/{id}/publish` with
`submitToShowcase: true` for that. Unlisted puzzles are hidden from
search engines unless you set `noIndex: false`.

Use `Idempotency-Key` to prevent copies when a request is sent twice.
Reuse the same value for the same puzzle. A repeat returns the first
puzzle with `200` and `Idempotent-Replayed: true`.

Creating a new puzzle uses one monthly puzzle. Repeats, edits, and
publishing are free. `X-Puzzle-Quota-Remaining` shows the balance.

**Parameters**

| Name | In | Type | Notes |
|---|---|---|---|
| `Idempotency-Key` | header | string | A 1 to 255 character value that is unique to this puzzle, such as a UUID or CMS post ID. Reusing it returns the first puzzle instead of making a copy. These values do not expire. · 1–255 characters · e.g. `post-4821` |

**Request body**

`application/json`

- `title` *string* **required** (1–200 characters)
- `author` *string* (0–100 characters)
- `language` *string* — Puzzle language code. You may send `pt-BR`, but the API returns `pt`. Both use the same Portuguese data. (default `"en"`; one of 25: `"en"`, `"es"`, `"fr"`, `"de"`, `"it"`, `"pt"`, `"pt-BR"`, `"pl"` …)
- `grid` *array of string* **required** — A square grid made of text rows, starting with the top row: * `.`: an empty white cell the solver may fill * `#`: a black square * anything else: a fixed letter A grid must be 3 to 23 cells wide, with the same number of rows. Some visible letters use more than one Unicode character. Count visible letters with a grapheme tool such as `Intl.Segmenter`, not JavaScript `.length`. The API may uppercase or clean up letters for the language. The returned text may not exactly match the bytes you sent. (3–23 items)
- `clues` *object*
  - `across` *object<string, string>* — Clue text keyed by clue number. Across and Down use the numbers printed on the grid.
  - `down` *object<string, string>* — Clue text keyed by clue number. Across and Down use the numbers printed on the grid.
- `themeWords` *array of string*
- `writeup` *string* — Note from the puzzle maker. Text over 2,000 characters is cut short. (0–2000 characters)
- `showProfile` *boolean*
- `noIndex` *boolean*
- `publish` *boolean* — Create a public, unlisted puzzle. The puzzle needs a title and a clue for every finished answer. An invalid puzzle is not saved. Use `POST /puzzles/{id}/publish` to ask for showcase review. (default `false`)

A draft, clues still partial:

```json
{
  "title": "Coastal Mini",
  "author": "Tex",
  "language": "en",
  "grid": ["KD#MC","NINER","OVATE","LEGOS","LR#OT"],
  "clues": {
    "across": {"5":"San Francisco footballer, informally"},
    "down": {"1":"Grassy mound"}
  },
  "themeWords": ["CREST"]
}
```

Published on creation, so every entry needs a clue:

```json
{
  "title": "Coastal Mini",
  "author": "Tex",
  "language": "en",
  "grid": ["KD#MC","NINER","OVATE","LEGOS","LR#OT"],
  "clues": {
    "across": {
      "5": "San Francisco footballer, informally",
      "7": "Egg-shaped",
      "8": "Bricks from Billund"
    },
    "down": {
      "1": "Grassy mound",
      "2": "One off the high board",
      "3": "2017 hashtag movement",
      "4": "Wave's high point",
      "6": "Pester"
    }
  },
  "themeWords": ["CREST"],
  "publish": true
}
```

**Responses**

- `200` — This `Idempotency-Key` was already used. The API returns the first puzzle and does not read the new request body. Headers: `Idempotent-Replayed`.

`application/json`

- `id` *string* **required** (matches `^[a-z0-9]{8}$`)
- `title` *string* **required** (0–200 characters)
- `author` *string* **required** — The name printed on the puzzle. It does not have to match the account name. (0–100 characters)
- `language` *string* **required** — Puzzle language code. You may send `pt-BR`, but the API returns `pt`. Both use the same Portuguese data. (default `"en"`; one of 25: `"en"`, `"es"`, `"fr"`, `"de"`, `"it"`, `"pt"`, `"pt-BR"`, `"pl"` …)
- `size` *integer* **required** (3–23; read-only)
- `grid` *array of string* **required** — A square grid made of text rows, starting with the top row: * `.`: an empty white cell the solver may fill * `#`: a black square * anything else: a fixed letter A grid must be 3 to 23 cells wide, with the same number of rows. Some visible letters use more than one Unicode character. Count visible letters with a grapheme tool such as `Intl.Segmenter`, not JavaScript `.length`. The API may uppercase or clean up letters for the language. The returned text may not exactly match the bytes you sent. (3–23 items)
- `clues` *object* **required**
  - `across` *object<string, string>* **required** — Clue text keyed by clue number. Across and Down use the numbers printed on the grid.
  - `down` *object<string, string>* **required** — Clue text keyed by clue number. Across and Down use the numbers printed on the grid.
- `themeWords` *array of string* **required** — Theme answers to highlight on the puzzle page.
- `status` *string* **required** (one of `"draft"`, `"published"`)
- `showcaseStatus` *string | null* **required** — The puzzle's showcase review state. `null` means it was not submitted. A normal review moves from `pending` to `pre_approved`, then to `approved`. A blocked word moves it to `flagged` for a person to check. (one of `"pending"`, `"pre_approved"`, `"flagged"`, `"approved"`, `"rejected"`, `null`)
- `adultThemes` *boolean* **required** — `true` when the safety check found adult themes. The public page has an age check, and the embed will not load. Add the same protection if you show the puzzle on your own site. (read-only)
- `difficulty` *string | null* **required** — How hard the puzzle is to solve. The rating looks at clue style and word difficulty. Drafts return `null`. A new publish may also return `null` while the rating is being made. Editing clues makes a new rating. (one of `"easy"`, `"medium"`, `"hard"`, `"expert"`, `null`; read-only)
- `writeup` *string* — Constructor's note shown on the puzzle page.
- `showProfile` *boolean* — Whether this puzzle is linked from the author's public page.
- `noIndex` *boolean* — Author asked search engines to skip the puzzle page.
- `createdAt` *string (date-time)* **required**
- `updatedAt` *string (date-time)* **required**
- `url` *string (uri)* — Public puzzle page. Present once `status` is `published`. (read-only)
- `embedUrl` *string (uri)* — Chrome-less frame for embedding. Present once `status` is `published`. (read-only)

- `201` — The stored puzzle. Headers: `X-Puzzle-Quota-Remaining`.

`application/json`

- `id` *string* **required** (matches `^[a-z0-9]{8}$`)
- `title` *string* **required** (0–200 characters)
- `author` *string* **required** — The name printed on the puzzle. It does not have to match the account name. (0–100 characters)
- `language` *string* **required** — Puzzle language code. You may send `pt-BR`, but the API returns `pt`. Both use the same Portuguese data. (default `"en"`; one of 25: `"en"`, `"es"`, `"fr"`, `"de"`, `"it"`, `"pt"`, `"pt-BR"`, `"pl"` …)
- `size` *integer* **required** (3–23; read-only)
- `grid` *array of string* **required** — A square grid made of text rows, starting with the top row: * `.`: an empty white cell the solver may fill * `#`: a black square * anything else: a fixed letter A grid must be 3 to 23 cells wide, with the same number of rows. Some visible letters use more than one Unicode character. Count visible letters with a grapheme tool such as `Intl.Segmenter`, not JavaScript `.length`. The API may uppercase or clean up letters for the language. The returned text may not exactly match the bytes you sent. (3–23 items)
- `clues` *object* **required**
  - `across` *object<string, string>* **required** — Clue text keyed by clue number. Across and Down use the numbers printed on the grid.
  - `down` *object<string, string>* **required** — Clue text keyed by clue number. Across and Down use the numbers printed on the grid.
- `themeWords` *array of string* **required** — Theme answers to highlight on the puzzle page.
- `status` *string* **required** (one of `"draft"`, `"published"`)
- `showcaseStatus` *string | null* **required** — The puzzle's showcase review state. `null` means it was not submitted. A normal review moves from `pending` to `pre_approved`, then to `approved`. A blocked word moves it to `flagged` for a person to check. (one of `"pending"`, `"pre_approved"`, `"flagged"`, `"approved"`, `"rejected"`, `null`)
- `adultThemes` *boolean* **required** — `true` when the safety check found adult themes. The public page has an age check, and the embed will not load. Add the same protection if you show the puzzle on your own site. (read-only)
- `difficulty` *string | null* **required** — How hard the puzzle is to solve. The rating looks at clue style and word difficulty. Drafts return `null`. A new publish may also return `null` while the rating is being made. Editing clues makes a new rating. (one of `"easy"`, `"medium"`, `"hard"`, `"expert"`, `null`; read-only)
- `writeup` *string* — Constructor's note shown on the puzzle page.
- `showProfile` *boolean* — Whether this puzzle is linked from the author's public page.
- `noIndex` *boolean* — Author asked search engines to skip the puzzle page.
- `createdAt` *string (date-time)* **required**
- `updatedAt` *string (date-time)* **required**
- `url` *string (uri)* — Public puzzle page. Present once `status` is `published`. (read-only)
- `embedUrl` *string (uri)* — Chrome-less frame for embedding. Present once `status` is `published`. (read-only)

The `draft` request above, stored:

```json
{
  "id": "k3n8q1zp",
  "title": "Coastal Mini",
  "author": "Tex",
  "language": "en",
  "size": 5,
  "grid": ["KD#MC","NINER","OVATE","LEGOS","LR#OT"],
  "clues": {
    "across": {"5":"San Francisco footballer, informally"},
    "down": {"1":"Grassy mound"}
  },
  "themeWords": ["CREST"],
  "status": "draft",
  "showcaseStatus": null,
  "adultThemes": false,
  "difficulty": null,
  "createdAt": "2026-09-15T10:04:00Z",
  "updatedAt": "2026-09-15T10:04:00Z"
}
```

- `400` — `VALIDATION_ERROR`. With `publish: true` this also covers the publish-readiness check: a missing title, or an entry with no clue. Nothing is stored when it fails.

- `401` — Missing, malformed, unknown or revoked key.

- `403` — `FORBIDDEN_SCOPE` or `GRID_SIZE_LOCKED`.

- `413` — The request body exceeds 1 MB.

- `429` — `RATE_LIMITED`, or `PUZZLE_QUOTA_REACHED` when no new puzzles are left this month. The error includes the plan and next step. Headers: `Retry-After`, `X-Puzzle-Quota-Remaining`.

### GET /puzzles/{id}

Get one puzzle  
Scope: `read`

Get one of your puzzles, or any published puzzle, by ID. Someone else's
draft returns `404`. The API does not reveal whether that draft exists.

**Parameters**

| Name | In | Type | Notes |
|---|---|---|---|
| `id` | path | string | **required** · The puzzle's opaque 8-character id. · matches `^[a-z0-9]{8}$` · e.g. `k3n8q1zp` |

**Responses**

- `200` — The puzzle.

`application/json`

- `id` *string* **required** (matches `^[a-z0-9]{8}$`)
- `title` *string* **required** (0–200 characters)
- `author` *string* **required** — The name printed on the puzzle. It does not have to match the account name. (0–100 characters)
- `language` *string* **required** — Puzzle language code. You may send `pt-BR`, but the API returns `pt`. Both use the same Portuguese data. (default `"en"`; one of 25: `"en"`, `"es"`, `"fr"`, `"de"`, `"it"`, `"pt"`, `"pt-BR"`, `"pl"` …)
- `size` *integer* **required** (3–23; read-only)
- `grid` *array of string* **required** — A square grid made of text rows, starting with the top row: * `.`: an empty white cell the solver may fill * `#`: a black square * anything else: a fixed letter A grid must be 3 to 23 cells wide, with the same number of rows. Some visible letters use more than one Unicode character. Count visible letters with a grapheme tool such as `Intl.Segmenter`, not JavaScript `.length`. The API may uppercase or clean up letters for the language. The returned text may not exactly match the bytes you sent. (3–23 items)
- `clues` *object* **required**
  - `across` *object<string, string>* **required** — Clue text keyed by clue number. Across and Down use the numbers printed on the grid.
  - `down` *object<string, string>* **required** — Clue text keyed by clue number. Across and Down use the numbers printed on the grid.
- `themeWords` *array of string* **required** — Theme answers to highlight on the puzzle page.
- `status` *string* **required** (one of `"draft"`, `"published"`)
- `showcaseStatus` *string | null* **required** — The puzzle's showcase review state. `null` means it was not submitted. A normal review moves from `pending` to `pre_approved`, then to `approved`. A blocked word moves it to `flagged` for a person to check. (one of `"pending"`, `"pre_approved"`, `"flagged"`, `"approved"`, `"rejected"`, `null`)
- `adultThemes` *boolean* **required** — `true` when the safety check found adult themes. The public page has an age check, and the embed will not load. Add the same protection if you show the puzzle on your own site. (read-only)
- `difficulty` *string | null* **required** — How hard the puzzle is to solve. The rating looks at clue style and word difficulty. Drafts return `null`. A new publish may also return `null` while the rating is being made. Editing clues makes a new rating. (one of `"easy"`, `"medium"`, `"hard"`, `"expert"`, `null`; read-only)
- `writeup` *string* — Constructor's note shown on the puzzle page.
- `showProfile` *boolean* — Whether this puzzle is linked from the author's public page.
- `noIndex` *boolean* — Author asked search engines to skip the puzzle page.
- `createdAt` *string (date-time)* **required**
- `updatedAt` *string (date-time)* **required**
- `url` *string (uri)* — Public puzzle page. Present once `status` is `published`. (read-only)
- `embedUrl` *string (uri)* — Chrome-less frame for embedding. Present once `status` is `published`. (read-only)

published:

```json
{
  "id": "k3n8q1zp",
  "title": "Coastal Mini",
  "author": "Tex",
  "language": "en",
  "size": 5,
  "grid": ["KD#MC","NINER","OVATE","LEGOS","LR#OT"],
  "clues": {
    "across": {
      "5": "San Francisco footballer, informally",
      "7": "Egg-shaped",
      "8": "Bricks from Billund"
    },
    "down": {
      "1": "Grassy mound",
      "2": "One off the high board",
      "3": "2017 hashtag movement",
      "4": "Wave's high point",
      "6": "Pester"
    }
  },
  "themeWords": ["CREST"],
  "status": "published",
  "showcaseStatus": "approved",
  "adultThemes": false,
  "difficulty": "medium",
  "createdAt": "2026-09-15T10:04:00Z",
  "updatedAt": "2026-09-15T11:00:00Z",
  "url": "https://crossword.texs.org/puzzle/k3n8q1zp",
  "embedUrl": "https://crossword.texs.org/embed/k3n8q1zp"
}
```

- `401` — Missing, malformed, unknown or revoked key.

- `403` — The key is valid but lacks the scope this operation needs.

- `404` — No such puzzle, or it is a draft you do not own.

- `429` — Per-minute rate limit exceeded for this key's tier. Headers: `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`.

### PATCH /puzzles/{id}

Update a puzzle  
Scope: `puzzles:write`

Change one or more fields on a puzzle you own. Fields you leave out do
not change. The API saves the old version first. Editing a published
puzzle also runs the safety check again.

Take care with `clues`: it replaces the full clue set. It does not merge
with old clues. If you send `clues`, include every Across and Down clue
you want to keep.

**Parameters**

| Name | In | Type | Notes |
|---|---|---|---|
| `id` | path | string | **required** · The puzzle's opaque 8-character id. · matches `^[a-z0-9]{8}$` · e.g. `k3n8q1zp` |

**Request body**

`application/json`

- `title` *string* (1–200 characters)
- `author` *string* (0–100 characters)
- `language` *string* — Puzzle language code. You may send `pt-BR`, but the API returns `pt`. Both use the same Portuguese data. (default `"en"`; one of 25: `"en"`, `"es"`, `"fr"`, `"de"`, `"it"`, `"pt"`, `"pt-BR"`, `"pl"` …)
- `grid` *array of string* — A square grid made of text rows, starting with the top row: * `.`: an empty white cell the solver may fill * `#`: a black square * anything else: a fixed letter A grid must be 3 to 23 cells wide, with the same number of rows. Some visible letters use more than one Unicode character. Count visible letters with a grapheme tool such as `Intl.Segmenter`, not JavaScript `.length`. The API may uppercase or clean up letters for the language. The returned text may not exactly match the bytes you sent. (3–23 items)
- `clues` *object*
  - `across` *object<string, string>* — Clue text keyed by clue number. Across and Down use the numbers printed on the grid.
  - `down` *object<string, string>* — Clue text keyed by clue number. Across and Down use the numbers printed on the grid.
- `themeWords` *array of string*
- `writeup` *string* — Note from the puzzle maker. Text over 2,000 characters is cut short. (0–2000 characters)
- `showProfile` *boolean*
- `noIndex` *boolean*

Retitle without touching the grid:

```json
{"title":"Seaside Mini"}
```

Reword 7-Across and 4-Down (the whole clue set is resent):

```json
{
  "clues": {
    "across": {
      "5": "San Francisco footballer, informally",
      "7": "Shaped like an egg",
      "8": "Bricks from Billund"
    },
    "down": {
      "1": "Grassy mound",
      "2": "One off the high board",
      "3": "2017 hashtag movement",
      "4": "Top of a swell",
      "6": "Pester"
    }
  }
}
```

**Responses**

- `200` — The updated puzzle.

`application/json`

- `id` *string* **required** (matches `^[a-z0-9]{8}$`)
- `title` *string* **required** (0–200 characters)
- `author` *string* **required** — The name printed on the puzzle. It does not have to match the account name. (0–100 characters)
- `language` *string* **required** — Puzzle language code. You may send `pt-BR`, but the API returns `pt`. Both use the same Portuguese data. (default `"en"`; one of 25: `"en"`, `"es"`, `"fr"`, `"de"`, `"it"`, `"pt"`, `"pt-BR"`, `"pl"` …)
- `size` *integer* **required** (3–23; read-only)
- `grid` *array of string* **required** — A square grid made of text rows, starting with the top row: * `.`: an empty white cell the solver may fill * `#`: a black square * anything else: a fixed letter A grid must be 3 to 23 cells wide, with the same number of rows. Some visible letters use more than one Unicode character. Count visible letters with a grapheme tool such as `Intl.Segmenter`, not JavaScript `.length`. The API may uppercase or clean up letters for the language. The returned text may not exactly match the bytes you sent. (3–23 items)
- `clues` *object* **required**
  - `across` *object<string, string>* **required** — Clue text keyed by clue number. Across and Down use the numbers printed on the grid.
  - `down` *object<string, string>* **required** — Clue text keyed by clue number. Across and Down use the numbers printed on the grid.
- `themeWords` *array of string* **required** — Theme answers to highlight on the puzzle page.
- `status` *string* **required** (one of `"draft"`, `"published"`)
- `showcaseStatus` *string | null* **required** — The puzzle's showcase review state. `null` means it was not submitted. A normal review moves from `pending` to `pre_approved`, then to `approved`. A blocked word moves it to `flagged` for a person to check. (one of `"pending"`, `"pre_approved"`, `"flagged"`, `"approved"`, `"rejected"`, `null`)
- `adultThemes` *boolean* **required** — `true` when the safety check found adult themes. The public page has an age check, and the embed will not load. Add the same protection if you show the puzzle on your own site. (read-only)
- `difficulty` *string | null* **required** — How hard the puzzle is to solve. The rating looks at clue style and word difficulty. Drafts return `null`. A new publish may also return `null` while the rating is being made. Editing clues makes a new rating. (one of `"easy"`, `"medium"`, `"hard"`, `"expert"`, `null`; read-only)
- `writeup` *string* — Constructor's note shown on the puzzle page.
- `showProfile` *boolean* — Whether this puzzle is linked from the author's public page.
- `noIndex` *boolean* — Author asked search engines to skip the puzzle page.
- `createdAt` *string (date-time)* **required**
- `updatedAt` *string (date-time)* **required**
- `url` *string (uri)* — Public puzzle page. Present once `status` is `published`. (read-only)
- `embedUrl` *string (uri)* — Chrome-less frame for embedding. Present once `status` is `published`. (read-only)

- `400` — The request body or query string did not validate.

- `401` — Missing, malformed, unknown or revoked key.

- `403` — `FORBIDDEN_SCOPE` or `GRID_SIZE_LOCKED`.

- `404` — No such puzzle, or it is a draft you do not own.

- `429` — Per-minute rate limit exceeded for this key's tier. Headers: `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`.

### DELETE /puzzles/{id}

Delete a puzzle  
Scope: `puzzles:write`

Permanently delete a puzzle you own. This also deletes its version
history. The API cannot undo this action.

**Parameters**

| Name | In | Type | Notes |
|---|---|---|---|
| `id` | path | string | **required** · The puzzle's opaque 8-character id. · matches `^[a-z0-9]{8}$` · e.g. `k3n8q1zp` |

**Responses**

- `204` — Deleted.

- `401` — Missing, malformed, unknown or revoked key.

- `403` — The key is valid but lacks the scope this operation needs.

- `404` — No such puzzle, or it is a draft you do not own.

- `429` — Per-minute rate limit exceeded for this key's tier. Headers: `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`.

### POST /puzzles/{id}/publish

Publish a puzzle  
Scope: `puzzles:write`

Publish a draft and create its public page and embed URL. Publishing
also runs a safety check. The puzzle's clues may be added to the shared
clue collection under the Terms of Use.

The puzzle is unlisted unless you send `submitToShowcase: true`. A
person reviews every API showcase request; nothing is listed
automatically, so send your best work. An unlisted puzzle is hidden
from search engines unless you set `noIndex: false`.

An account can have up to three puzzles waiting for showcase review. A
fourth returns `409 SHOWCASE_QUEUE_FULL` and changes nothing.

You may publish an already published puzzle again. This updates the
optional fields and runs the safety check again. A puzzle already in the
review queue keeps its place.

**Parameters**

| Name | In | Type | Notes |
|---|---|---|---|
| `id` | path | string | **required** · The puzzle's opaque 8-character id. · matches `^[a-z0-9]{8}$` · e.g. `k3n8q1zp` |

**Request body** (optional)

`application/json`

- `writeup` *string* — Note shown on the puzzle page. Text over 2,000 characters is cut short. (0–2000 characters)
- `showProfile` *boolean* — Link this puzzle from your public author page.
- `noIndex` *boolean* — Hide the page from search engines. The share link still works.
- `submitToShowcase` *boolean* — Enter the public showcase review queue. Off by default. (default `false`)

publish:

```json
{
  "writeup": "My first API-built puzzle.",
  "showProfile": true,
  "submitToShowcase": true
}
```

**Responses**

- `200` — The published puzzle.

`application/json`

- `id` *string* **required** (matches `^[a-z0-9]{8}$`)
- `title` *string* **required** (0–200 characters)
- `author` *string* **required** — The name printed on the puzzle. It does not have to match the account name. (0–100 characters)
- `language` *string* **required** — Puzzle language code. You may send `pt-BR`, but the API returns `pt`. Both use the same Portuguese data. (default `"en"`; one of 25: `"en"`, `"es"`, `"fr"`, `"de"`, `"it"`, `"pt"`, `"pt-BR"`, `"pl"` …)
- `size` *integer* **required** (3–23; read-only)
- `grid` *array of string* **required** — A square grid made of text rows, starting with the top row: * `.`: an empty white cell the solver may fill * `#`: a black square * anything else: a fixed letter A grid must be 3 to 23 cells wide, with the same number of rows. Some visible letters use more than one Unicode character. Count visible letters with a grapheme tool such as `Intl.Segmenter`, not JavaScript `.length`. The API may uppercase or clean up letters for the language. The returned text may not exactly match the bytes you sent. (3–23 items)
- `clues` *object* **required**
  - `across` *object<string, string>* **required** — Clue text keyed by clue number. Across and Down use the numbers printed on the grid.
  - `down` *object<string, string>* **required** — Clue text keyed by clue number. Across and Down use the numbers printed on the grid.
- `themeWords` *array of string* **required** — Theme answers to highlight on the puzzle page.
- `status` *string* **required** (one of `"draft"`, `"published"`)
- `showcaseStatus` *string | null* **required** — The puzzle's showcase review state. `null` means it was not submitted. A normal review moves from `pending` to `pre_approved`, then to `approved`. A blocked word moves it to `flagged` for a person to check. (one of `"pending"`, `"pre_approved"`, `"flagged"`, `"approved"`, `"rejected"`, `null`)
- `adultThemes` *boolean* **required** — `true` when the safety check found adult themes. The public page has an age check, and the embed will not load. Add the same protection if you show the puzzle on your own site. (read-only)
- `difficulty` *string | null* **required** — How hard the puzzle is to solve. The rating looks at clue style and word difficulty. Drafts return `null`. A new publish may also return `null` while the rating is being made. Editing clues makes a new rating. (one of `"easy"`, `"medium"`, `"hard"`, `"expert"`, `null`; read-only)
- `writeup` *string* — Constructor's note shown on the puzzle page.
- `showProfile` *boolean* — Whether this puzzle is linked from the author's public page.
- `noIndex` *boolean* — Author asked search engines to skip the puzzle page.
- `createdAt` *string (date-time)* **required**
- `updatedAt` *string (date-time)* **required**
- `url` *string (uri)* — Public puzzle page. Present once `status` is `published`. (read-only)
- `embedUrl` *string (uri)* — Chrome-less frame for embedding. Present once `status` is `published`. (read-only)

Submitted to the showcase, scanned clean:

```json
{
  "id": "k3n8q1zp",
  "title": "Coastal Mini",
  "author": "Tex",
  "language": "en",
  "size": 5,
  "grid": ["KD#MC","NINER","OVATE","LEGOS","LR#OT"],
  "clues": {
    "across": {
      "5": "San Francisco footballer, informally",
      "7": "Egg-shaped",
      "8": "Bricks from Billund"
    },
    "down": {
      "1": "Grassy mound",
      "2": "One off the high board",
      "3": "2017 hashtag movement",
      "4": "Wave's high point",
      "6": "Pester"
    }
  },
  "themeWords": ["CREST"],
  "status": "published",
  "showcaseStatus": "pre_approved",
  "adultThemes": false,
  "difficulty": null,
  "writeup": "My first API-built puzzle.",
  "showProfile": true,
  "createdAt": "2026-09-15T10:04:00Z",
  "updatedAt": "2026-09-15T12:31:00Z",
  "url": "https://crossword.texs.org/puzzle/k3n8q1zp",
  "embedUrl": "https://crossword.texs.org/embed/k3n8q1zp"
}
```

- `400` — `VALIDATION_ERROR`: the puzzle needs a title and a clue for every finished answer. Unfinished answers do not need clues yet.

- `401` — Missing, malformed, unknown or revoked key.

- `403` — `FORBIDDEN_SCOPE` or `GRID_SIZE_LOCKED`.

- `404` — No such puzzle, or it is a draft you do not own.

- `409` — `SHOWCASE_QUEUE_FULL`: three puzzles are already waiting for review. Nothing was changed.

- `429` — Per-minute rate limit exceeded for this key's tier. Headers: `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`.

### GET /puzzles/{id}/export

Export a puzzle  
Scope: `read`

Download a puzzle as JSON or as an Across Lite `.puz` file.

JSON matches `GET /puzzles/{id}`. The `.puz` format only supports some
Latin-script languages. Check `puzExportable` in `GET /languages`.

**Parameters**

| Name | In | Type | Notes |
|---|---|---|---|
| `id` | path | string | **required** · The puzzle's opaque 8-character id. · matches `^[a-z0-9]{8}$` · e.g. `k3n8q1zp` |
| `format` | query | string | Output format. · default `"json"` · one of `"json"`, `"puz"` |

**Responses**

- `200` — The exported puzzle. Headers: `Content-Disposition`.

`application/json`

- `id` *string* **required** (matches `^[a-z0-9]{8}$`)
- `title` *string* **required** (0–200 characters)
- `author` *string* **required** — The name printed on the puzzle. It does not have to match the account name. (0–100 characters)
- `language` *string* **required** — Puzzle language code. You may send `pt-BR`, but the API returns `pt`. Both use the same Portuguese data. (default `"en"`; one of 25: `"en"`, `"es"`, `"fr"`, `"de"`, `"it"`, `"pt"`, `"pt-BR"`, `"pl"` …)
- `size` *integer* **required** (3–23; read-only)
- `grid` *array of string* **required** — A square grid made of text rows, starting with the top row: * `.`: an empty white cell the solver may fill * `#`: a black square * anything else: a fixed letter A grid must be 3 to 23 cells wide, with the same number of rows. Some visible letters use more than one Unicode character. Count visible letters with a grapheme tool such as `Intl.Segmenter`, not JavaScript `.length`. The API may uppercase or clean up letters for the language. The returned text may not exactly match the bytes you sent. (3–23 items)
- `clues` *object* **required**
  - `across` *object<string, string>* **required** — Clue text keyed by clue number. Across and Down use the numbers printed on the grid.
  - `down` *object<string, string>* **required** — Clue text keyed by clue number. Across and Down use the numbers printed on the grid.
- `themeWords` *array of string* **required** — Theme answers to highlight on the puzzle page.
- `status` *string* **required** (one of `"draft"`, `"published"`)
- `showcaseStatus` *string | null* **required** — The puzzle's showcase review state. `null` means it was not submitted. A normal review moves from `pending` to `pre_approved`, then to `approved`. A blocked word moves it to `flagged` for a person to check. (one of `"pending"`, `"pre_approved"`, `"flagged"`, `"approved"`, `"rejected"`, `null`)
- `adultThemes` *boolean* **required** — `true` when the safety check found adult themes. The public page has an age check, and the embed will not load. Add the same protection if you show the puzzle on your own site. (read-only)
- `difficulty` *string | null* **required** — How hard the puzzle is to solve. The rating looks at clue style and word difficulty. Drafts return `null`. A new publish may also return `null` while the rating is being made. Editing clues makes a new rating. (one of `"easy"`, `"medium"`, `"hard"`, `"expert"`, `null`; read-only)
- `writeup` *string* — Constructor's note shown on the puzzle page.
- `showProfile` *boolean* — Whether this puzzle is linked from the author's public page.
- `noIndex` *boolean* — Author asked search engines to skip the puzzle page.
- `createdAt` *string (date-time)* **required**
- `updatedAt` *string (date-time)* **required**
- `url` *string (uri)* — Public puzzle page. Present once `status` is `published`. (read-only)
- `embedUrl` *string (uri)* — Chrome-less frame for embedding. Present once `status` is `published`. (read-only)

`application/x-crossword` — string (binary)

- `400` — `VALIDATION_ERROR`: `.puz` requested for a language that cannot be encoded in ISO-8859-1.

- `401` — Missing, malformed, unknown or revoked key.

- `403` — The key is valid but lacks the scope this operation needs.

- `404` — No such puzzle, or it is a draft you do not own.

- `429` — Per-minute rate limit exceeded for this key's tier. Headers: `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`.

## Languages

Call `GET /languages` for the current list. Chinese, Japanese, and Korean need criss-cross grids. Arabic and Hebrew display from right to left, but their data still starts at column 0. `.puz` works only for supported Latin-script languages. You may send `pt-BR`, but the API returns `pt`.

| Code | Language | Autofill | Direction | .puz | Min slot | Deployed |
|---|---|---|---|---|---|---|
| `en` | English · English | dense | LTR | yes | 3 | yes |
| `es` | Spanish · Español | dense | LTR | yes | 3 | yes |
| `fr` | French · Français | dense | LTR | yes | 3 | yes |
| `de` | German · Deutsch | dense | LTR | yes | 3 | yes |
| `it` | Italian · Italiano | dense | LTR | yes | 3 | yes |
| `pt` | Portuguese · Português | dense | LTR | yes | 3 | yes |
| `pl` | Polish · Polski | dense | LTR | no | 3 | yes |
| `nl` | Dutch · Nederlands | dense | LTR | yes | 3 | yes |
| `zh` | Chinese · 中文 | criss-cross only | LTR | no | 2 | yes |
| `ja` | Japanese · 日本語 | criss-cross only | LTR | no | 2 | yes |
| `ko` | Korean · 한국어 | criss-cross only | LTR | no | 2 | yes |
| `hi` | Hindi · हिन्दी | dense | LTR | no | 3 | not yet |
| `ar` | Arabic · العربية | dense | RTL | no | 3 | yes |
| `tr` | Turkish · Türkçe | dense | LTR | no | 3 | yes |
| `he` | Hebrew · עברית | dense | RTL | no | 3 | yes |
| `id` | Indonesian · Bahasa Indonesia | dense | LTR | yes | 3 | yes |
| `cs` | Czech · Čeština | dense | LTR | no | 3 | yes |
| `uk` | Ukrainian · Українська | dense | LTR | no | 3 | yes |
| `ro` | Romanian · Română | dense | LTR | no | 3 | yes |
| `ru` | Russian · Русский | dense | LTR | no | 3 | yes |
| `sv` | Swedish · Svenska | dense | LTR | yes | 3 | yes |
| `no` | Norwegian · Norsk | dense | LTR | yes | 3 | not yet |
| `da` | Danish · Dansk | dense | LTR | yes | 3 | not yet |
| `hr` | Croatian · Hrvatski | dense | LTR | no | 3 | yes |

## Command line and MCP

The `xword` package includes a TypeScript client, a command line tool, and an MCP server. It creates patterns and `.puz` files on your computer, so those actions do not use API limits. It is open source under MIT: https://github.com/texjer/xword.

```bash
npx xword status      # no install
npm i -g xword        # then: xword status
xword login           # prompts for the key, stores it mode 0600
```

```bash
xword languages --available                 # the 24 languages and their constraints
xword words "C_T" --lang en --min-score 40  # pattern search; _ is a wildcard
xword clues SUB --limit 3                   # corpus clues for an answer

xword pattern --size 15 --out grid.txt      # local, no key, no quota
xword fill grid.txt --stream --out filled.txt
xword improve filled.txt --out clean.txt    # swap the obscure entries out

xword puzzles create puzzle.json            # save it
xword puzzles publish k3n8q1zp              # mint the public + embed URLs
xword export k3n8q1zp --puz --out mine.puz  # Across Lite

xword pattern --size 11 | xword fill - --stream --json   # everything pipes
```

Use `--json` when another program will read the result. Use `--base <url>` for another server. Exit code 0 means success, 1 means an API error or empty fill, and 2 means the command was invalid. Set `CROSSWORD_API_KEY` or run `xword login` to save the key.

The MCP server lets supported AI tools call the API. For Claude Code:

```bash
claude mcp add crossword -e CROSSWORD_API_KEY=cw_live_… -- npx -y xword mcp
```

For Claude Desktop, Cursor, or a project `.mcp.json`:

```json
{
  "mcpServers": {
    "crossword": {
      "command": "npx",
      "args": ["-y", "xword", "mcp"],
      "env": { "CROSSWORD_API_KEY": "cw_live_…" }
    }
  }
}
```

## About the data licenses

The word lists and clue collection use several open data projects. Each project has its own license. Follow those licenses if you share the data. The API does not offer a bulk download.

- English words: Crossword Nexus Collaborative Word List (MIT) · ENABLE (public domain).
- Spanish: doozan/spanish_data, Wiktionary headwords and frequency ranks (CC BY-SA).
- 22 others, frequency component: hermitdave/FrequencyWords, OpenSubtitles 2018 counts (CC BY-SA 4.0).
- 22 others, dictionary component: Hunspell dictionaries via wooorm/dictionaries (licences per language: MIT, BSD, MPL, LGPL, GPL, AGPL), JMdict readings for Japanese, Shreeshrii Hindi Hunspell for Hindi. Chinese, Arabic and Indonesian are frequency data only.
- Clues: `source` says where each clue came from. `published` means it appeared in print. `dictionary` means it was adapted from a licensed definition. `original` means it was written for this service. See each source and license at /api/clues/sources. Clues from puzzles you publish may be added to the collection under the Terms of Use.

The full non-English data set is **not** covered by MIT. You own the puzzles you build. Publishing makes the page and embed frame public (see /terms).

## Changelog

- **1.0.0** (2026-09): first public version with 17 operations. Version 1 may gain new optional fields and endpoints. Breaking changes will use a new URL version.
