# DocxAPI — Complete Documentation

> This single file is the full, machine-readable documentation for the whole
> platform. Canonical URL: **https://api.searchops.io/doc/docxapi.md**
>
> If you are an AI agent: everything needed to integrate is in this file. There
> is no hidden page and no SDK to install — it is two HTTP endpoints sharing one
> API key.

The platform turns **Markdown, HTML or JSON** into either of two outputs:

| You want | Endpoint | Returns |
|---|---|---|
| A Word document | `POST /v1/generate` | `.docx` as binary, base64 or a 24h link |
| WordPress content | `POST /v1/gutenberg/convert` | Native Gutenberg block markup |

Both read the same input formats, use the same API key, and draw from the same
monthly credit balance — one operation of either kind costs one credit. It is
built for automation tools (n8n, Make, Zapier) and for backend code.

- **Base URL:** `https://api.searchops.io`
- **Auth:** `x-api-key` header
- **Content type:** `application/json`
- **Version:** 1.2

---

## 1. Quickstart

### 1.1 Get an API key

1. Sign in at https://api.searchops.io/dashboard/login.html (Google sign-in).
2. Open **API keys** and create one.
3. Copy the key — it starts with `sk_live_` and is shown **only once**.

### 1.2 First request

```bash
curl -X POST https://api.searchops.io/v1/generate \
  -H "Content-Type: application/json" \
  -H "x-api-key: sk_live_YOUR_KEY" \
  -d '{
    "input_type": "markdown",
    "content": "# Hello\n\nThis is **bold** text.",
    "response_format": "file",
    "filename": "hello"
  }' \
  --output hello.docx
```

You now have `hello.docx`. That is the whole integration.

---

## 2. The only endpoint you need

### `POST /v1/generate`

**Headers**

| Header | Required | Value |
|---|---|---|
| `x-api-key` | yes | Your `sk_live_…` key |
| `Content-Type` | yes | `application/json` |

The key may also be sent in the body as `api_key`, but the header is preferred.

**Body**

| Field | Type | Default | Notes |
|---|---|---|---|
| `content` | string | — | **Required.** The source document. Max **500 KB** (UTF-8). |
| `input_type` | `markdown` \| `html` \| `json` | `markdown` | Alias: `input` |
| `response_format` | `base64` \| `url` \| `file` | `base64` | Alias: `format` |
| `filename` | string | `document.docx` | 1–120 chars. No `/`, `\` or control characters. `.docx` is appended if missing. Unicode is supported. |
| `options` | object | — | **Accepted but currently ignored.** See §7. |

**Unknown fields are silently ignored, not rejected.** A typo like `file_name`
instead of `filename` does not fail the request — the field is dropped and the
default is used, so you get `document.docx` with no error. Check field names
against this table when a value seems to have no effect.

---

## 3. Response formats

Pick the one that matches how your tool consumes data.

### 3.1 `file` — raw binary (best for n8n, Make, Zapier)

Returns the `.docx` bytes directly.

```
Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document
Content-Disposition: attachment; filename="report.docx"; filename*=UTF-8''report.docx
X-Generation-Time-Ms: 480
```

Use this when the next step uploads the file somewhere (Google Drive, S3,
email attachment). No decoding needed.

### 3.2 `base64` — JSON envelope (default)

```json
{
  "status": "success",
  "format": "base64",
  "data": "UEsDBBQABgAIAAAAIQD…",
  "pages": null,
  "generation_time_ms": 480
}
```

Use this when your platform wants to build the binary itself, or when you need
to inspect the response before writing a file.

### 3.3 `url` — temporary download link

```json
{
  "status": "success",
  "format": "url",
  "data": "https://api.searchops.io/v1/download/550e8400-e29b-41d4-a716-446655440000",
  "pages": null,
  "generation_time_ms": 480
}
```

The link is valid for **24 hours**, then the file is deleted. Use it to hand a
download to a human without proxying the bytes through your own system.

> **`pages` is always `null`.** Page count cannot be determined reliably without
> rendering the document, so the API returns `null` rather than a wrong number.
> Do not build logic on this field.

---

## 4. Input type: Markdown

The default. Standard Markdown — headings, bold/italic, lists, tables, quotes,
code, links, horizontal rules.

```json
{
  "input_type": "markdown",
  "content": "# Quarterly Report\n\n## Summary\n\nRevenue grew **18%**.\n\n- North: 42k\n- South: 31k\n\n| Region | Total |\n|--------|-------|\n| North  | 42000 |\n| South  | 31000 |",
  "response_format": "file",
  "filename": "quarterly-report"
}
```

**Watch out for `\n` in JSON.** Newlines must be escaped as `\n` inside a JSON
string. In n8n and most no-code tools, use an expression that passes the raw
text and let the tool handle escaping.

---

## 5. Input type: HTML

Send `input_type: "html"`. Good when your content already exists as HTML — a
CMS field, an email body, a rich-text editor.

```json
{
  "input_type": "html",
  "content": "<h1>Invoice 2024-01</h1><p>Amount due: <strong>$1,250.00</strong></p><table><tr><th>Item</th><th>Price</th></tr><tr><td>Consulting</td><td>$1,250.00</td></tr></table>",
  "response_format": "file",
  "filename": "invoice-2024-01"
}
```

Supported: headings, paragraphs, `strong`/`em`, `ul`/`ol`, `table`, `blockquote`,
`a`, `hr`, `code`/`pre`.

Not supported: CSS styling, `<style>` blocks, JavaScript, external images.
Layout comes from the document template, not from your HTML.

---

## 6. Input type: JSON

Send `input_type: "json"` with `content` as a **JSON string** (stringify the
object first — `content` is always a string).

There are two shapes. Both work.

### 6.1 Block format (recommended)

Explicit and predictable. Every element is a block.

```json
{
  "document": {
    "title": "Project Status",
    "blocks": [
      { "type": "heading", "level": 2, "text": "Overview" },
      { "type": "paragraph", "text": "The migration finished ahead of schedule." },
      { "type": "list", "ordered": false, "items": ["API deployed", "DNS switched", "Monitoring active"] },
      { "type": "table",
        "headers": ["Milestone", "Status"],
        "rows": [["Migration", "Done"], ["Load test", "Pending"]] },
      { "type": "quote", "text": "Zero downtime during the cutover." },
      { "type": "page_break" },
      { "type": "heading", "level": 2, "text": "Appendix" },
      { "type": "markdown", "content": "See the **full log** in the attachment." }
    ]
  }
}
```

**Block types**

| `type` | Fields | Notes |
|---|---|---|
| `heading` | `level` (1–6, default 2), `text` | |
| `paragraph` | `text` | |
| `markdown` | `content` | Raw Markdown, for anything not covered by a block |
| `list` | `items` (array), `ordered` (bool) | |
| `table` | `headers` (array), `rows` (array of arrays) | Missing cells become empty |
| `quote` | `text` | Multi-line supported |
| `page_break` | — | Forces a new page |

An unknown `type` returns `500 PANDOC_ERROR` with `Invalid JSON content.`

### 6.2 Section format (legacy)

Simpler, still supported.

```json
{
  "title": "Sales Report",
  "subtitle": "January 2024",
  "sections": [
    { "heading": "Summary", "content": "Revenue grew 18% year over year." },
    { "heading": "Highlights", "items": ["New enterprise account", "Churn down 2pp"] },
    { "heading": "By region",
      "table": [
        { "Region": "North", "Total": 42000 },
        { "Region": "South", "Total": 31000 }
      ] }
  ]
}
```

Table rows are objects; the keys of the **first** row become the columns.
Pipe characters are escaped and newlines become `<br>` automatically.

---

## 7. Formatting options (not active yet)

The API accepts an `options` object and validates it, but **it does not change
the document yet**:

```json
"options": {
  "font": "Roboto", "font_size": 12,
  "header_text": "…", "footer_text": "…",
  "page_size": "A4",
  "margin_top": "2cm", "margin_bottom": "2cm",
  "margin_left": "2cm", "margin_right": "2cm"
}
```

When you send it, the API tells you it was ignored:

- JSON responses gain `"warnings": ["Formatting options are not supported yet."]`
- `file` responses gain the header `X-DocxAPI-Warning`

Styling currently comes from a fixed template (Roboto, A4). Do not depend on
`options` until this note is removed.

---

## 8. Using it in n8n

This is the most common setup: build content → convert → store the file.

### 8.1 HTTP Request node

| Field | Value |
|---|---|
| Method | `POST` |
| URL | `https://api.searchops.io/v1/generate` |
| Send Headers | on → `x-api-key` = your key |
| Send Body | on (JSON / Body Parameters) |
| Body: `input` | `markdown` |
| Body: `content` | your expression |
| Body: `format` | `file` |
| Options → Response → Response Format | **File** |

Setting the node's response format to **File** is what makes the `.docx` arrive
as binary data, ready for the next node.

### 8.2 Importable workflow

Paste this into n8n (**Import from clipboard**). It converts Markdown and
uploads the result to Google Drive. Replace the API key, drive ID and folder.

```json
{
  "nodes": [
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.searchops.io/v1/generate",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [{ "name": "x-api-key", "value": "sk_live_YOUR_KEY" }]
        },
        "sendBody": true,
        "bodyParameters": {
          "parameters": [
            { "name": "input", "value": "markdown" },
            { "name": "content", "value": "=# {{ $json.title }}\n\n{{ $json.body }}" },
            { "name": "format", "value": "file" }
          ]
        },
        "options": { "response": { "response": { "responseFormat": "file" } } }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.4,
      "position": [2064, 1744],
      "id": "2c162942-2d5f-408d-aa5d-b1847c4abb61",
      "name": "MD to Docx"
    },
    {
      "parameters": {
        "name": "=Title",
        "driveId": { "__rl": true, "value": "=id-drive", "mode": "id" },
        "folderId": { "__rl": true, "value": "=url-folder", "mode": "url" },
        "options": {}
      },
      "type": "n8n-nodes-base.googleDrive",
      "typeVersion": 3,
      "position": [2272, 1744],
      "id": "75e1a243-6c4a-4647-83b4-af50c7f0922c",
      "name": "Upload to Drive",
      "onError": "continueErrorOutput"
    }
  ],
  "connections": {
    "MD to Docx": { "main": [[{ "node": "Upload to Drive", "type": "main", "index": 0 }]] }
  },
  "pinData": {}
}
```

### 8.3 n8n tips

- Use the short aliases `input` and `format` — they are the field names the
  n8n Body Parameters UI produces most naturally.
- Name the file with the `filename` field instead of renaming it later.
- The Google Drive node takes the binary from the previous node automatically.
- To send an AI-generated document, wire the LLM node output into `content`.

---

## 9. Using it outside n8n

### 9.1 cURL

```bash
curl -X POST https://api.searchops.io/v1/generate \
  -H "Content-Type: application/json" \
  -H "x-api-key: $DOCXAPI_KEY" \
  -d '{"input_type":"markdown","content":"# Report\n\nBody text.","response_format":"file","filename":"report"}' \
  --output report.docx
```

### 9.2 JavaScript / Node.js

```js
const res = await fetch('https://api.searchops.io/v1/generate', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'x-api-key': process.env.DOCXAPI_KEY,
  },
  body: JSON.stringify({
    input_type: 'markdown',
    content: '# Report\n\nBody text.',
    response_format: 'file',
    filename: 'report',
  }),
});

if (!res.ok) throw new Error((await res.json()).error.message);
await require('node:fs/promises').writeFile('report.docx', Buffer.from(await res.arrayBuffer()));
```

### 9.3 Python

```python
import os, requests

r = requests.post(
    "https://api.searchops.io/v1/generate",
    headers={"x-api-key": os.environ["DOCXAPI_KEY"]},
    json={
        "input_type": "markdown",
        "content": "# Report\n\nBody text.",
        "response_format": "file",
        "filename": "report",
    },
    timeout=60,
)
r.raise_for_status()
open("report.docx", "wb").write(r.content)
```

### 9.4 Make / Zapier

Use a generic HTTP module:
method `POST`, URL `https://api.searchops.io/v1/generate`,
header `x-api-key`, JSON body as above, and set the module to treat the
response as a **binary file** when using `format: "file"`.

---

## 10. Errors

Every error uses the same envelope:

```json
{ "error": { "code": "INVALID_API_KEY", "message": "Invalid or inactive API key." } }
```

| HTTP | `code` | What it means / what to do |
|---|---|---|
| 400 | `VALIDATION_ERROR` | Body failed schema validation — usually an unknown field or a wrong enum value |
| 401 | `INVALID_API_KEY` | Missing, wrong, inactive or deleted key |
| 402 | `CREDITS_EXHAUSTED` | Monthly quota used up. Upgrade or buy credits |
| 403 | `DOMAIN_NOT_ALLOWED` | The key restricts origins and the request `Origin`/`Referer` is not on the list |
| 413 | `CONTENT_TOO_LARGE` | `content` is over 500 KB |
| 413 | `OUTPUT_TOO_LARGE` | The generated `.docx` exceeded the output limit |
| 429 | `RATE_LIMIT_EXCEEDED` | Over 60 requests/minute for this key. Back off and retry |
| 500 | `PANDOC_ERROR` | Conversion failed — usually malformed JSON content or invalid markup |
| 503 | `GENERATION_BUSY` | Conversion queue is full. Retry with backoff |
| 503 | `USAGE_SERVICE_ERROR` | Quota service temporarily unavailable. Retry |
| 422 | `CONVERSION_ERROR` | Gutenberg: content could not be converted to blocks. **Not charged** |
| 400 | `INVALID_BLOCK_MARKUP` | Gutenberg parse: malformed block markup |
| 504 | `GENERATION_TIMEOUT` | Conversion took longer than 30s. Reduce document size |

**Retry policy.** `429`, `503` and `504` are transient — retry with exponential
backoff. `4xx` other than `429` will not succeed on retry without a change.

Requests carry an `X-Request-Id` response header. Include it when reporting a
problem.

---

## 11. Plans, quota and limits

| Plan | Documents / month | Overage | API keys |
|---|---|---|---|
| Free | 20 | — | 1 |
| Starter | 200 | $0.05 | 1 |
| Pro | 1,000 | $0.03 | 3 |
| Agency | 3,000 | $0.02 | Unlimited |

- **Quota counts successful generations only.** Failed requests are logged but
  not charged.
- The month is the **calendar month in UTC**.
- **Free plan documents carry a watermark** — a `Generated with DocxAPI` line
  appended to the end of the document.
- Paid plans keep working past the quota and are billed for overage.
- Extra credits can be purchased in blocks of 50 (min 50, max 10,000) and are
  consumed after the plan quota.

**Technical limits**

| Limit | Value |
|---|---|
| Max `content` size | 500 KB |
| Conversion timeout | 30 s |
| Rate limit | 60 requests / minute per API key |
| Download link lifetime | 24 hours |
| Max request body (proxy) | 10 MB |

---

## 11b. WordPress: Markdown/HTML/JSON → Gutenberg blocks

### The problem

Pushing automated content into WordPress fails in predictable ways:

- **Raw HTML** in the `content` field becomes a single *Classic* block. It
  renders, but in the editor it is one opaque lump — nothing is individually
  editable.
- **Markdown** is not parsed by WordPress at all. You get literal `##` and `**`
  in the published post.
- **Hand-written block markup** usually trips the editor's validator:
  *"This block contains unexpected or invalid content."*

### `POST /v1/gutenberg/convert`

```bash
curl -X POST https://api.searchops.io/v1/gutenberg/convert \
  -H "Content-Type: application/json" \
  -H "x-api-key: sk_live_YOUR_KEY" \
  -d '{
    "input_format": "markdown",
    "content": "## How it works\n\nText with **bold**.\n\n- one\n- two"
  }'
```

**Body**

| Field | Type | Default | Notes |
|---|---|---|---|
| `content` | string | — | **Required.** Max 500 KB. |
| `input_format` | `markdown` \| `html` \| `json` | `markdown` | Aliases: `input_type`, `input` |
| `profile` | `portable-article` | `portable-article` | Core blocks only |
| `options.h1_policy` | `demote` \| `keep` \| `remove` | `demote` | Post title is already the page H1 |
| `options.unknown_node_policy` | `html` \| `drop` \| `strict` | `html` | Content with no block mapping |
| `options.return_ast` | boolean | `false` | Include the canonical tree |

**Response**

```json
{
  "success": true,
  "gutenberg": "<!-- wp:heading -->\n<h2 class=\"wp-block-heading\">How it works</h2>\n<!-- /wp:heading -->",
  "blocks": ["core/heading", "core/list", "core/list-item", "core/paragraph"],
  "validation": { "syntax": "valid", "round_trip": "valid", "target": "not_checked" },
  "warnings": [],
  "losses": [],
  "media": [],
  "serializer": { "profile": "portable-article", "catalog": "2026-08-04", "api": "1.0.0" },
  "generation_time_ms": 34
}
```

Put `gutenberg` straight into the `content` field of the WordPress REST API.

### The JSON shape differs from the DOCX endpoint

⚠️ **Do not reuse a DOCX JSON payload here.** The two endpoints currently accept
different structures:

| Endpoint | Shape | Example node |
|---|---|---|
| `/v1/generate` (DOCX) | `document.blocks[]` | `{ "type": "heading", "level": 2, "text": "T" }` |
| `/v1/gutenberg/convert` | `children[]` | `{ "type": "heading", "level": 2, "children": [{ "type": "text", "value": "T" }] }` |

The Gutenberg endpoint uses the canonical tree, where inline content is an array
of typed nodes rather than a plain string — that is what carries bold, links and
line breaks without embedding markup in a string. Sending a DOCX-shaped payload
returns `400 VALIDATION_ERROR` naming the offending path.

Markdown and HTML inputs behave identically on both endpoints; only JSON differs.

### Blocks produced

`core/paragraph`, `core/heading`, `core/list` + `core/list-item`, `core/quote`,
`core/code`, `core/preformatted`, `core/table`, `core/image`, `core/separator`,
`core/details`, `core/html`.

Images are referenced by their original URL — uploading to the media library is
not part of this release, so there is no `id` attribute or `wp-image-N` class.

### Warnings and losses

Nothing is dropped silently. Each entry has a stable `code` and a `path`:

```json
{ "code": "HEADING_DEMOTED", "severity": "warning", "path": "/children/0",
  "message": "H1 demoted to H2: the post title already provides the page H1.",
  "action": "degraded" }
```

`warnings` = came through in a different shape. `losses` = did not come through.
Common codes: `HEADING_DEMOTED`, `SANITIZE_UNSAFE_URL`,
`SANITIZE_FORBIDDEN_ELEMENT`, `PARSE_RAW_HTML`, `LOSS_TABLE_ALIGNMENT`,
`LOSS_TABLE_SPANS`, `LOSS_BLOCK_IN_LIST_ITEM`, `IMAGE_INLINE_DEGRADED`.

### Validation

Every conversion is parsed back and re-serialised before responding. Markup that
fails the round trip returns `422 CONVERSION_ERROR` and is **not charged** —
conversion happens before the credit is reserved.

### `POST /v1/gutenberg/parse`

Takes block markup, returns its structure. For diagnosing an already-broken
post. Requires an API key, **does not consume credits**. Malformed markup gives
`400 INVALID_BLOCK_MARKUP` with the specific reason (unclosed block, mismatched
delimiters, invalid attribute JSON).

### Publishing to WordPress

Conversion and publishing are separate on purpose — this API never receives your
WordPress credentials and never connects to your site.

```bash
curl -X POST https://your-site.com/wp-json/wp/v2/posts \
  -H "Content-Type: application/json" \
  -u "user:application-password" \
  -d '{"title":"My post","content":"<!-- wp:paragraph -->\n<p>Text</p>\n<!-- /wp:paragraph -->","status":"draft"}'
```

Use an **Application Password** (WordPress → Users → Profile), which requires
HTTPS on the site. The featured image is `featured_media` (an attachment ID),
not a block inside the content. `categories` and `tags` take term IDs.

### n8n

Two HTTP Request nodes: convert, then publish. The second reads
`$json.gutenberg` from the first. Unlike the DOCX flow, the response format
stays **JSON** — there is no binary here.

---

## 12. Other endpoints

### `GET /v1/download/:id`

Downloads a file produced with `response_format: "url"`. The `:id` is a UUID.
Returns `404` if the file expired (24h) or never existed. No authentication —
the UUID is the capability, so treat the URL as a secret.

### `GET /health`

No authentication. Useful for monitoring.

```json
{
  "status": "ok",
  "pandoc_available": true,
  "version": "3.1.3",
  "generation": { "active": 0, "queued": 0, "max_concurrency": 2, "max_queue": 50 }
}
```

### Dashboard-only endpoints

These use a Supabase JWT (`Authorization: Bearer …`), not an API key, and exist
for the dashboard UI: `GET /v1/auth/me`, `PATCH /v1/auth/profile`,
`GET|POST /v1/keys`, `PATCH|DELETE /v1/keys/:id`, `POST /v1/keys/:id/rotate`,
`GET /v1/usage`, `GET /v1/usage/recent`, `POST /v1/billing/checkout`,
`POST /v1/billing/buy-credits`, `POST /v1/billing/portal`.
You do not need them to generate documents.

---

## 13. Security notes

- **Keys are secrets.** They are stored hashed (SHA-256); a lost key cannot be
  recovered, only rotated or deleted.
- **Rotate** a key from the dashboard (`POST /v1/keys/:id/rotate`) — this issues
  a new secret and invalidates the old one.
- **Never put a key in browser-side code.** Call DocxAPI from a server, an n8n
  instance, or a serverless function.
- **Domain restrictions** are optional per key. When set, requests carrying an
  `Origin`/`Referer` outside the list are rejected with `DOMAIN_NOT_ALLOWED`.
  Requests without those headers (curl, n8n, server-to-server) are allowed —
  the restriction protects browser usage, not server usage.
- Content is written to a temporary file, converted, and deleted. Files for
  `response_format: "url"` persist 24h; everything else is deleted immediately.

---

## 14. Troubleshooting

**A field I sent had no effect.**
Unknown fields are dropped silently rather than rejected, so a typo like
`file_name` instead of `filename`, or `type` instead of `input_type`, produces
a successful response that ignores your value. Compare the exact spelling with
§2 — the aliases `input` and `format` are valid, anything else is not.

**My newlines are gone.**
`content` is a JSON string: real line breaks must be `\n`. Some tools double-
escape into `\\n`, which renders literally.

**The `.docx` arrives as text or 0 bytes in n8n.**
The node's *Response Format* must be **File**, not JSON or String — separate
from the `format: "file"` body field. Both are needed.

**My JSON document is empty.**
`content` must be a **string**. If you pass an object, most clients serialise
it into something the API cannot read. Stringify it first.

**`CREDITS_EXHAUSTED` but I just started the month.**
Quota is per calendar month in UTC. If you are far from UTC, the reset happens
at a different local hour than you expect.

**Formatting options do nothing.**
That is expected today — see §7. Check the `warnings` field.

---

## 15. Quick reference

```
POST https://api.searchops.io/v1/generate            -> .docx
  header  x-api-key: sk_live_…
  body    { content, input_type?, response_format?, filename?, options? }

POST https://api.searchops.io/v1/gutenberg/convert   -> WordPress blocks
  header  x-api-key: sk_live_…
  body    { content, input_format?, profile?, options? }
  returns { gutenberg, blocks[], validation, warnings[], losses[] }

POST /v1/gutenberg/parse   inspect existing markup (no credit)

input_type       markdown | html | json          default markdown   (alias: input)
response_format  base64 | url | file             default base64     (alias: format)
filename         1–120 chars, .docx appended     default document.docx
content          required, ≤ 500 KB

GET  /v1/download/:id     24h link from response_format=url
GET  /health              status + conversion queue
```

---

*DocxAPI 1.1 — https://api.searchops.io · Docs: https://api.searchops.io/doc/*
