# UpParser Blog API — instructions for AI agents

Use this API to upload images and create, read, update, publish, unpublish, or delete blog articles. No browser automation or login is needed.

Production base URL: `https://upparser.com`

Human-readable documentation: `/docs/blog-api` (redirects to the English documentation page).

Plain Markdown documentation: `/docs/blog-api.md`

OpenAPI 3.1 specification: `/api/blog/openapi.json`

## Authentication

Every management request requires this header:

```http
Authorization: Bearer YOUR_BLOG_API_KEY
```

The owner supplies one key with full blog permissions. Obtain it through the owner's secure environment or secret mechanism. Never include it in articles, public links, documentation, source control, or query parameters. Never send it to the media domain or a third-party URL. Public article pages and documentation do not require the key.

Set `UPPARSER_BLOG_API_KEY` in your environment before running the examples below. Do not replace it with the owner's document-parsing API key; the two credentials are independent.

## Quick start

1. Optionally upload a featured image with `POST /api/v1/blog/assets`.
2. Choose one English slug for the entire article, for example `pdf-to-excel-guide`.
3. Send `PUT /api/v1/blog/posts/pdf-to-excel-guide` with the complete content for one language.
4. Use `status: "published"` to publish immediately, or `"draft"` to keep it private.
5. Add translations by sending the same URL with a different `locale`.

The public URLs are `/en/pdf-to-excel-guide`, `/zh/pdf-to-excel-guide`, etc. The language-specific indexes are `/en/blog`, `/zh/blog`, etc. Missing translations return 404; the site does not publish automatic translations or copy the English body under other languages.

## Upload an image

```bash
curl --fail-with-body https://upparser.com/api/v1/blog/assets \
  -H "Authorization: Bearer $UPPARSER_BLOG_API_KEY" \
  -F 'file=@cover.png'
```

Send `multipart/form-data` with exactly one `file` field. Let your HTTP client set the multipart boundary. Maximum file size: 10 MiB. Supported inputs: static JPEG, PNG, WebP and AVIF. SVG, GIF, animated and invalid images are rejected. Actual image data is decoded; the filename is not trusted. The image is resized to fit within 2400 × 2400 pixels, orientation is corrected, metadata is removed and output is compressed to WebP.

Example response (HTTP 201):

```json
{
  "key": "CONTENT_SHA256.webp",
  "url": "https://media.upparser.com/CONTENT_SHA256.webp",
  "width": 1600,
  "height": 900,
  "bytes": 84210,
  "contentType": "image/webp"
}
```

Use `url` in `featuredImage.url` or Markdown image syntax. Repeated identical optimized images share a content-addressed URL. Uploading alone does not attach an image to an article. Production images are public immediately; do not upload confidential material. Updating an image means uploading the new file and updating the article URL. The API does not delete image assets; article deletion leaves images available for other articles.

## Create or replace a language version

`PUT /api/v1/blog/posts/{slug}` creates an article if needed, or replaces the specified language version. Other language versions remain unchanged. Repeating the same PUT does not create duplicate articles.

Slugs: 1–120 characters, lowercase ASCII letters, digits and single hyphens between words; no leading/trailing hyphen. Existing website route names such as `blog`, `tools`, `pricing`, `docs`, `login` and language codes are reserved. Slugs cannot be renamed with this endpoint: a different slug creates a different article.

```bash
curl --fail-with-body -X PUT \
  https://upparser.com/api/v1/blog/posts/pdf-to-excel-guide \
  -H "Authorization: Bearer $UPPARSER_BLOG_API_KEY" \
  -H 'Content-Type: application/json' \
  --data-binary @article.json
```

Example `article.json`:

```json
{
  "locale": "en",
  "title": "How to turn a PDF into an Excel spreadsheet",
  "excerpt": "A practical guide to extracting structured data from PDFs.",
  "markdown": "## Start with your document\n\nUpload a PDF to begin.\n\n<CTA title=\"Try it with your own PDF\" text=\"Turn a document into structured data.\" buttonText=\"Get started\" href=\"https://app.upparser.com\" />\n\n<YouTube id=\"dQw4w9WgXcQ\" title=\"Video walkthrough\" />",
  "status": "published",
  "author": "UpParser",
  "featuredImage": {
    "url": "https://media.upparser.com/CONTENT_SHA256.webp",
    "alt": "An invoice alongside its extracted spreadsheet"
  },
  "tags": ["PDF", "Automation"],
  "seoTitle": "PDF to Excel: a practical guide",
  "seoDescription": "Learn how to extract PDF data into a spreadsheet with UpParser."
}
```

| Field | Rules |
| --- | --- |
| `locale` | Required: `en`, `zh`, `es`, `de`, `fr` |
| `title` | Required, 1–200 characters |
| `markdown` | Required, 1–150,000 characters |
| `excerpt` | Up to 600 characters; default empty |
| `status` | `draft` or `published`; default `draft` |
| `author` | 1–100 characters; default `UpParser` |
| `featuredImage` | `{ "url": "HTTPS_OR_SITE_RELATIVE_URL", "alt": "description" }` or `null`; default `null` |
| `tags` | Up to 12 strings, each 1–50 characters; default empty |
| `seoTitle` | Up to 200 characters; falls back to article title |
| `seoDescription` | Up to 600 characters; falls back to excerpt |

Unknown fields are rejected. The total JSON request body must be under 800,000 bytes. This is a complete replacement, not a partial update: omitted optional fields reset to their defaults. When editing an existing article, GET the language version first, modify it, and submit only the writable fields above. Do not submit server-managed `slug`, timestamps or `publishedAt` to PUT. If multiple writers edit the same translation, the last successful write wins.

Response (HTTP 200):

```json
{
  "post": { "slug": "pdf-to-excel-guide", "locale": "en", "title": "...", "markdown": "...", "status": "published", "createdAt": "...", "updatedAt": "...", "publishedAt": "..." },
  "url": "https://upparser.com/en/pdf-to-excel-guide",
  "cacheRefresh": "requested"
}
```

`url` is `null` for drafts. Timestamps are assigned by the server. The first publication date is preserved when editing or republishing. The same slug has independently managed language versions.

`POST /api/v1/blog/posts` is an alternative: send the same JSON plus a `slug` field. It also creates or replaces one language version.

## Read and list articles

All these endpoints require the same Bearer key and return drafts as well as published content. Responses are not publicly cached.

- `GET /api/v1/blog/posts?limit=25` returns `{ "posts": [...], "nextCursor": "slug-or-null" }`. List items contain language metadata, not full Markdown. Default limit 25, maximum 100. Follow `?cursor=NEXT_CURSOR&limit=25` until `nextCursor` is null. Results are in ascending slug order.
- `GET /api/v1/blog/posts/{slug}` returns `{ "post": { "slug": "...", "translations": { "en": {...}, "zh": {...} }, ... } }` with metadata for existing languages.
- `GET /api/v1/blog/posts/{slug}?locale=en` returns `{ "post": {...} }` with the complete English version including `markdown`.

## Markdown and rich content

Standard Markdown and GitHub-style tables, lists, task lists, links, images, blockquotes and fenced code blocks are supported. Supply a code-fence language such as `python`, `javascript`, `json` or `bash` for highlighting.

```python
print("This is displayed as highlighted code, not executed.")
```

For interactive presentation, use the built-in components below. Put each component in its own block, with a blank line before and after. Attributes must be literal single- or double-quoted strings. Do not place a component inside a code fence unless you want to show its source.

### Call to action

```markdown
<CTA
  title="Turn documents into useful data"
  text="Try UpParser with your own PDF."
  buttonText="Get started"
  href="https://app.upparser.com"
/>
```

`title`, `buttonText` and `href` are required; `text` is optional. Links must use HTTPS or a site-relative path starting with one `/`.

### YouTube video

```markdown
<YouTube id="dQw4w9WgXcQ" title="Video walkthrough" />
```

Use the 11-character video ID, not a full YouTube URL. `title` is required for accessibility. Videos use a responsive, lazy-loaded privacy-enhanced YouTube embed.

Only these two component names and their documented attributes are interpreted. JavaScript expressions, imports, arbitrary JSX, scripts and raw HTML/iframes are not executed or rendered. Invalid components are omitted. Use standard Markdown for other content. Links and images only allow HTTPS or site-relative URLs (links may also use local `#anchor` fragments).

## Unpublish or delete

To unpublish, PUT the complete language version again with `status: "draft"`. The public page returns 404 after cached copies refresh. Editing a published language with `status: "published"` replaces its live content; there is no separate approval stage or version history.

`DELETE /api/v1/blog/posts/{slug}?locale=zh` permanently deletes only the Chinese version.

`DELETE /api/v1/blog/posts/{slug}` permanently deletes the article in every language. Repeating DELETE is safe. Images are retained. Ask the owner before deleting content unless deletion is already part of their request.

## SEO and caching

Published pages include server-rendered content, canonical URLs, available-language hreflang links, social metadata and BlogPosting structured data. Published language versions appear in `/sitemap.xml`. Drafts are excluded from public article pages, lists and the sitemap.

Blog pages and database reads use a 60-second ISR/data-cache revalidation interval. Writes request invalidation on the instance handling the request. On multiple Cloud Run instances, another instance can briefly serve older content; revalidation is traffic-driven and 60 seconds is not a strict global publication deadline. Existing browser/client-router or downstream caches may also retain a prior response. Images have content-hashed URLs with one-year immutable caching.

`cacheRefresh: "requested"` means invalidation was requested, not that every edge/browser has refreshed. `"failed"` means the article was saved but invalidation failed; retry the PUT or allow normal revalidation. There is no whole-site HTML CDN cache configured by this feature. Keep authenticated API requests outside any CDN cache rules.

## Errors

Errors use `{ "error": "code" }`; validation errors also include `issues` with field paths and messages.

| Status | Meaning |
| --- | --- |
| 400 | `invalidRequest`, `invalidLimit`, `invalidCursor`, `fileRequired`, `invalidImage`, `invalidMultipart` |
| 401 | `unauthorized`: missing or invalid blog key |
| 404 | `notFound`: missing article/language |
| 413 | `size` or `imageTooLarge`: request exceeds limits |
| 503 | `blogNotConfigured`, `blogMediaNotConfigured`, or `serviceUnavailable` |

Correct 400 errors before retrying. Do not repeatedly retry a 401 with the same credential. Transient 503 responses can be retried with backoff. A PUT is safe to retry after a network failure because the slug and locale identify the same version.


## Search discovery and indexing notifications

Article translations are independent. Publish translated titles, excerpts, bodies, SEO titles, SEO descriptions, and image alt text in the requested language. Missing language versions return 404; they are never advertised as published translations.

`/sitemap.xml` is a sitemap **index**. Follow its child sitemaps to find every canonical public URL. Blog shards include only published translations, truthful modification timestamps, image URLs, and reciprocal `hreflang` links. Publication changes invalidate the blog discovery cache. `/llms.txt` links to per-language reading indexes.

Write/delete responses include `indexNow`: `disabled`, `unchanged`, `submitted`, or `failed`. The site owner can enable production IndexNow notifications. `submitted` acknowledges receipt only, not indexing. `failed` does not undo a successful content write; the site operator can retry the logged public URLs. Treat `cacheRefresh: "failed"` separately from persistence: use an idempotent PUT to request a fresh invalidation.
