External Publishing API
Programmatically publish articles into any site in your GASEO project from external tools — Zapier, N8N, custom Python scripts, in-house writer pipelines.
https://gaseo.ai/api/v1Quick Start
Three steps from zero to a published article. You should be done within five minutes.
Get an API key
In the GASEO dashboard, open your project → API Keys (left toolbox) → + New API Key. Copy the full token the moment it appears — you cannot see it again.
List the sites you can publish to
curl https://gaseo.ai/api/v1/projects/{projectId}/sites \
-H "Authorization: Bearer ak_live_..." The response includes a sites[] array — each item has a siteId you'll use in step 3.
Publish an article
curl -X POST https://gaseo.ai/api/v1/sites/{siteId}/articles \
-H "Authorization: Bearer ak_live_..." \
-H "Content-Type: application/json" \
-d '{
"title": "My first API article",
"content": "## Hello\n\nMarkdown body here.",
"status": "published"
}' You'll get back an article.url — the live page. Done.
Authentication
Every request needs an Authorization: Bearer <token> header. Tokens are minted in the dashboard and look like ak_live_3dd2104b9144794e76ff437f655784bb (project-scoped) or ak_user_… (Personal Access Token, can access all your projects).
Key scope
- Project-scoped (
ak_live_): bound to one project. Can read/write only that project. - Personal Access Token (
ak_user_): bound to your account. Can access every project you own or are a member of. - Neither key can touch other tenants' projects or modify dashboard settings.
Revoking a key
Open the API Keys panel and click Revoke. Effective immediately — subsequent requests get 401 invalid_key.
GET List sites in a project
GET https://gaseo.ai/api/v1/projects/{projectId}/sites Discovery endpoint. Lists every site in the project so you can pick a siteId to publish to.
Path parameters
| Field | Type | Notes |
|---|---|---|
projectId | string | The project ID bound to your API key. Visible in the dashboard URL. |
Response
{
"success": true,
"projectId": "proj_abc123",
"projectName": "My Casino Project",
"sites": [
{
"siteId": "site_111",
"name": "Casino Royale",
"url": "https://casino-royale.gaseo.ai",
"primaryDomain": "casino-royale.com",
"testDomain": "casino-royale",
"language": "en",
"tags": ["casino", "review"],
"status": "completed",
"articleCount": 12
}
]
} Response fields
| Field | Type | Notes |
|---|---|---|
sites[].siteId | string | Use this in the POST endpoint URL. |
sites[].url | string | The runtime URL — every site has one. |
sites[].primaryDomain | string | null | Verified custom domain, if any. |
sites[].language | string | null | BCP-47 language tag of the site's content. |
sites[].tags | string[] | Free-form site tags for batch routing. |
sites[].status | string | Generation status: generating / completed / failed. |
sites[].articleCount | number | Count of draft+published articles. |
POST Create an article
POST https://gaseo.ai/api/v1/sites/{siteId}/articles Publishes (or schedules, or saves as draft) a single article. Returns the article ID, slug, and live URL.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
title | string | yes | Article title. ≤ 300 chars. |
content | string | yes | Markdown body. ≤ 200,000 chars. Inline <script>, event handlers, and javascript: URLs are stripped on ingest. |
status | enum | yes | One of draft · published · scheduled. See status semantics. |
slug | string | — | Custom URL slug. Defaults to slugified title. Auto-suffixed on collision; the actual slug used is returned in the response. |
excerpt | string | — | Listing summary. ≤ 500 chars. Auto-derived from content if absent. |
metaDescription | string | — | HTML <meta description>. ≤ 200 chars. |
targetKeyword | string | — | SEO target. ≤ 120 chars. Used internally to avoid keyword cannibalisation. |
tags | string[] | — | ≤ 20 entries. Free-form. |
featuredImage.url | string | — | Public URL to an image (JPEG / PNG / WebP / GIF / AVIF, ≤ 10 MB). SVG is rejected. |
featuredImage.alt | string | — | Alt text. ≤ 200 chars. |
featuredImage.rehost | boolean | — | Default true. See image rehosting. |
inlineImages | array | — | ≤ 20 entries of { url, alt?, position? }. Each image is rehosted to our CDN and woven into the markdown body — see the Images section. |
ctaUrl | string | — | CTA target. Falls back to site's default. |
publishedAt | ISO 8601 | conditional | Required when status=scheduled; optional otherwise. |
externalId | string | — | Echoed in the response. Use for your own dedup / tracking. |
Response (201 Created)
{
"success": true,
"article": {
"articleId": "65fc...",
"siteId": "site_111",
"title": "Top 10 Slot Games of 2026",
"slug": "top-10-slot-games-2026",
"status": "published",
"url": "https://casino-royale.gaseo.ai/blog/top-10-slot-games-2026",
"publicUrl": "https://casino-royale.com/blog/top-10-slot-games-2026",
"publishedAt": "2026-04-29T10:00:00.000Z",
"scheduledPublishAt": null,
"createdAt": "2026-04-29T10:00:01.234Z",
"externalId": "client-uuid-12345",
"featuredImage": {
"url": "https://cdn.gaseo.ai/articles/site_111/.../hero.jpg",
"alt": "Slot reels spinning",
"rehosted": true
}
}
}PATCH Update an article
PATCH https://gaseo.ai/api/v1/sites/{siteId}/articles/{articleId} Edits one or more fields. Only fields present in the body are touched — omitted fields keep their current values. The full updated article is returned.
Editable fields
Same shape and rules as create. Common fields: title, content, slug, excerpt, metaDescription, targetKeyword, tags, ctaUrl, status (+ publishedAt), featuredImage.
Common patterns
# Publish an existing draft
PATCH .../articles/65fc...
{ "status": "published" }
# Reschedule a scheduled post
PATCH .../articles/65fc...
{ "status": "scheduled", "publishedAt": "2026-05-15T08:00:00Z" }
# Replace featured image (rehosted to CDN; response includes featuredImageRehost)
PATCH .../articles/65fc...
{ "featuredImage": { "url": "https://...", "alt": "New hero" } }
# Weave images into the body (rehosted to CDN)
PATCH .../articles/65fc...
{ "inlineImages": [ { "url": "https://...", "alt": "Lobby", "position": 2 } ] }
# Change slug (auto-suffixed on collision)
PATCH .../articles/65fc...
{ "slug": "better-slug" } Notes
- Changing
slugfollows the same auto-suffix-on-collision rule as POST. - Status transitions follow the same semantics as create.
- You cannot move an article between sites by changing
siteId— it's path-locked. - When you replace
featuredImage, the response includes afeaturedImageRehostobject (rehosted,passthrough?,error?,errorCode?) so you can verify the permanent CDN URL took.
GET List articles on a site
GET https://gaseo.ai/api/v1/sites/{siteId}/articles Returns a summary list of articles. Use this to see what you've already published and to find article IDs for the GET / PATCH endpoints.
Query parameters
| Param | Type | Default | Notes |
|---|---|---|---|
status | enum | — | Filter by draft · published · scheduled · archived. Omit to list all. |
limit | number | 50 | Page size. Max 100. |
offset | number | 0 | Pagination offset. Sorted by createdAt desc. |
Response (200)
{
"success": true,
"siteId": "site_111",
"total": 42,
"limit": 50,
"offset": 0,
"articles": [
{
"articleId": "65fc...",
"siteId": "site_111",
"title": "Top 10 Slots",
"slug": "top-10-slots",
"status": "published",
"excerpt": "Short summary...",
"publishedAt": "2026-04-29T10:00:00.000Z",
"scheduledPublishAt": null,
"createdAt": "2026-04-29T10:00:00.000Z",
"updatedAt": "2026-04-29T10:00:00.000Z",
"url": "https://casino-royale.gaseo.ai/blog/top-10-slots",
"publicUrl": "https://casino-royale.com/blog/top-10-slots",
"tags": ["slot", "review"]
}
]
} The summary list does NOT include the full markdown body — use GET single article to fetch content.
GET Get one article
GET https://gaseo.ai/api/v1/sites/{siteId}/articles/{articleId} Returns the full article including the markdown content, meta description, featured image, and tags.
Response (200)
{
"success": true,
"article": {
"articleId": "65fc...",
"siteId": "site_111",
"title": "Top 10 Slots",
"slug": "top-10-slots",
"status": "published",
"content": "## Intro\n\nFull markdown body here.",
"excerpt": "Short summary...",
"metaDescription": "SEO meta description ≤ 200 chars",
"targetKeyword": "best slot games 2026",
"ctaUrl": "https://play.example.com/?ref=xyz",
"publishedAt": "2026-04-29T10:00:00.000Z",
"scheduledPublishAt": null,
"createdAt": "2026-04-29T10:00:00.000Z",
"updatedAt": "2026-04-29T10:00:00.000Z",
"url": "https://casino-royale.gaseo.ai/blog/top-10-slots",
"publicUrl": "https://casino-royale.com/blog/top-10-slots",
"tags": ["slot", "review"],
"featuredImage": {
"url": "https://cdn.gaseo.ai/articles/.../hero.jpg",
"alt": "Slot reels"
}
}
}POST Upload image
POST https://gaseo.ai/api/v1/sites/{siteId}/images Upload an image and get back a permanent CDN URL. Use it as featuredImage.url, in inlineImages, or directly inside markdown — article create/update recognises our CDN URLs and uses them as-is (no refetch). This is the right tool when the image source is WAF / hotlink protected and a server-side fetch from us would get 403.
Three input modes
- multipart/form-data with a
filefield — binary upload, preferred. - JSON
{ "base64": … }— raw base64 or adata:URI. - JSON
{ "url": … }— we fetch the source server-side and rehost in one call. Fails with a classifiederrorCodeinstead of silently falling back.
# a. multipart (binary upload — preferred for WAF-blocked sources)
curl -X POST .../sites/site_111/images \
-H "Authorization: Bearer ak_live_..." \
-F "file=@./hero.jpg"
# b. JSON base64 (data: URI prefix tolerated)
{ "base64": "/9j/4AAQSkZJRg..." }
# c. JSON url (server-side fetch + rehost in one call)
{ "url": "https://images.pexels.com/photos/12345.jpg" } Response (201)
{
"success": true,
"image": {
"url": "https://cdn.gaseo.ai/articles/site_111/uploads/1765432800-b7f2d1.jpg",
"key": "articles/site_111/uploads/1765432800-b7f2d1.jpg",
"contentType": "image/jpeg",
"bytes": 482133,
"source": "file"
}
} Notes
- Allowed formats: jpeg, png, webp, gif, avif — detected from the actual bytes; declared filenames / content types are not trusted. SVG is rejected. Max 10 MB.
- Counts against the
writerate-limit bucket. - Uploaded files are not tied to one article — reuse the URL across as many articles as you like. They are never deleted by
DELETE article.
DELETE Delete article
DELETE https://gaseo.ai/api/v1/sites/{siteId}/articles/{articleId} Permanently removes the article — no undo. Built for cleaning up failed or test drafts so they don't pile up.
Response (200)
{
"success": true,
"deleted": true,
"articleId": "65fc...",
"siteId": "site_111",
"imagesDeleted": 3
} Notes
- Per-article CDN images (rehosted under the article's own folder) are deleted best-effort alongside it.
- Standalone uploads from the image upload endpoint are kept — they may be shared by other articles.
- To hide an article without destroying it, PATCH
statusto"draft"instead.
Status semantics
The status field is required and accepts three values. The combination of status and publishedAt determines when the article becomes publicly visible.
status + publishedAt | Stored as | When visible |
|---|---|---|
"draft" | draft | Never (until you flip it via dashboard). |
"published" (no publishedAt) | published | Immediately. publishedAt = now. |
"published" + past publishedAt | published | Immediately, with the historical date for backfills. |
"published" + future publishedAt | draft (auto-promoted to scheduled) | At the supplied publishedAt. |
"scheduled" + future publishedAt | draft | At the supplied publishedAt (worker flips it live). |
"scheduled" + missing or past publishedAt | 400 validation_failed | |
Image rehosting
When you supply featuredImage.url with rehost: true (the default), we fetch the source URL and re-upload the bytes to our CDN. The response returns the new permanent URL.
Why rehost?
- Your source URL might disappear (Pexels removes images, user deletes the file, etc.).
- Some sources block hot-linking with referer checks.
- CDN-served images are faster and respect Core Web Vitals.
What if rehost fails?
If the source returns non-2xx, has the wrong content type, or exceeds 10 MB, we fall back to keeping the original URL and set featuredImage.rehosted: false with an error message plus a machine-readable errorCode (see the failure codes table below). The article is still created. If the source is WAF-protected (fetch_blocked), upload the bytes directly via POST /sites/{siteId}/images instead.
Skip rehosting
Set featuredImage.rehost: false if you've already hosted the image on a CDN you trust.
Accepted formats
image/jpeg · image/png · image/webp · image/gif · image/avif. Max 10 MB. SVG is rejected — it can carry scripts and would XSS anything that renders it inline.
CDN URL passthrough
URLs that already live on our CDN (returned by the image upload endpoint) are used as-is — no refetch, no duplicate copy — and marked passthrough: true. Pre-uploading images makes article create/update fast and immune to source-side WAF blocks.
Rehost failure codes
Wherever we fetch an image URL on your behalf (featured image, inline images, upload-by-url), failures carry a machine-readable errorCode so you know whether to retry or fall back to a direct upload:
errorCode | When |
|---|---|
fetch_blocked | Source returned 401/403 — WAF / hotlink protection. Upload the bytes directly via POST /sites/{siteId}/images instead. |
fetch_rate_limited | Source returned 429. Back off and retry later. |
fetch_not_found | Source returned 404/410. Fix the URL. |
fetch_timeout | No response within 15 s. Retry; if persistent, upload directly. |
fetch_dns_error | Hostname did not resolve. |
fetch_ssrf_blocked | URL points at a private/internal address. Never allowed. |
unsupported_content_type | Not a jpeg/png/webp/gif/avif. |
image_too_large | Over 10 MB. Compress or resize first. |
r2_upload_failed | Our storage hiccuped. Safe to retry. |
Inline images
Pass inlineImages: [{ url, alt?, position? }] (max 20) on create or update to have body images rehosted to our CDN and woven into the markdown. Per image: if content already references the url, every occurrence is replaced in-place with the CDN url; otherwise it is inserted after paragraph index position (0 = top), or appended at the end. Each image reports placement, rehosted, and errorCode in the response. Plain markdown images not listed in inlineImages are stored as-is and keep pointing at their source URLs.
Errors
All errors share this envelope:
{
"success": false,
"error": {
"code": "validation_failed",
"message": "status is required (string)",
"field": "status"
}
} | HTTP | code | When |
|---|---|---|
| 400 | validation_failed | Missing or invalid field. field names the bad one. |
| 401 | invalid_authorization | Missing or malformed Authorization header. |
| 401 | invalid_key | Token not recognised, revoked, or expired. |
| 403 | forbidden_project | Token is valid but for a different project. |
| 403 | forbidden_site | The site exists but belongs to another project. |
| 404 | project_not_found | No project with this projectId. |
| 404 | site_not_found | No site with this siteId. |
| 400 | image_invalid | Upload payload is not a valid jpeg/png/webp/gif/avif, or exceeds 10 MB. |
| 422 | image_fetch_failed | Upload-by-url source could not be fetched. Includes errorCode + sourceUrl. |
| 429 | rate_limit_exceeded | Rate limit exceeded. Includes retryAfterSeconds and a Retry-After header. |
| 500 | internal_error | Something blew up on our side. Safe to retry. |
Rate limits
Limits are per API key in fixed 60-second windows: 600 requests/min for reads (GET) and 120 requests/min for writes (POST / PATCH / DELETE, including image uploads). Every response carries the current state:
X-RateLimit-Limit: 120 # requests allowed per 60s window
X-RateLimit-Remaining: 87 # requests left in the current window
X-RateLimit-Reset: 1765432800 # unix seconds when the window resets
# on 429:
Retry-After: 42 # seconds to wait before retrying On 429, honor Retry-After — don't blind-retry. Batch clients should watch X-RateLimit-Remaining and pace themselves.
Code examples
Node.js (fetch)
const res = await fetch('https://gaseo.ai/api/v1/sites/site_111/articles', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.GASEO_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: 'My Article',
content: '## Hello\n\nWorld',
status: 'published'
})
});
const data = await res.json();
if (!data.success) throw new Error(data.error.message);
console.log(`Published: ${data.article.url}`); Python (requests)
import os, requests
res = requests.post(
'https://gaseo.ai/api/v1/sites/site_111/articles',
headers={'Authorization': f"Bearer {os.environ['GASEO_API_KEY']}"},
json={
'title': 'My Article',
'content': '## Hello\n\nWorld',
'status': 'published',
},
timeout=30,
)
res.raise_for_status()
data = res.json()
print('Published:', data['article']['url']) N8N (HTTP Request node)
- Add an HTTP Request node.
- Method:
POST - URL:
https://gaseo.ai/api/v1/sites/{{ $json.siteId }}/articles - Authentication: Header Auth → name
Authorization→ valueBearer ak_live_... - Body Content Type: JSON, then drop in the fields from the create endpoint.
Zapier (Webhooks by Zapier)
- Action: Webhooks by Zapier → Custom Request.
- Method:
POST - URL: the full
/sites/{siteId}/articlesendpoint. - Headers:
Authorization: Bearer ak_live_...andContent-Type: application/json. - Data: the JSON body. Map fields from previous steps.
MCP · Connect via AI assistants
Skip the curl entirely. Plug GASEO into Claude Desktop, Cursor, or Cline and ask the AI to write & publish articles for you.
We ship a Model Context Protocol server as the gaseo-mcp npm package. Once configured, your AI assistant gets 8 tools — list_projects, list_sites, list_articles, get_article, create_article, update_article, upload_image, delete_article — that wrap this API.
What it looks like
You: "List my GASEO sites then publish a 500-word article about why MMORPGs are due a comeback to the first one."
Claude: (calls list_sites → drafts markdown → calls create_article with status=published) → "Done. Live at https://..."
Easiest setup — let your AI do it
If you already use an AI assistant with file access (Claude Desktop, Cursor, Cline, ChatGPT with code interpreter, …) the fastest path is to ask it to install gaseo-mcp for you. The dashboard's "+ New API Key" flow shows an "AI install prompt" tab — copy that, paste into your AI chat, and the AI handles the config file edits (correct path per OS, JSON merge, backup, etc.).
Works equally well for first-time install and for token rotation — the prompt tells the AI to replace any existing gaseo entry. No need to know where your MCP config lives.
Manual setup — Claude Desktop
Edit ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows):
{
"mcpServers": {
"gaseo": {
"command": "npx",
"args": ["-y", "gaseo-mcp"],
"env": {
"GASEO_API_KEY": "ak_live_..."
}
}
}
} Restart Claude Desktop. You'll see "gaseo" in the MCP indicator bar.
Cursor
~/.cursor/mcp.json (global) or <project>/.cursor/mcp.json (per-project):
{
"mcpServers": {
"gaseo": {
"command": "npx",
"args": ["-y", "gaseo-mcp"],
"env": { "GASEO_API_KEY": "ak_live_..." }
}
}
} Cline (VS Code extension)
~/.config/cline/cline_mcp_settings.json — same JSON shape as Cursor.
Tools
| Tool | Purpose |
|---|---|
list_projects | Discovery — projects this token can reach. PAT mode returns every project you can access; project-scoped returns the single bound project. |
list_sites | List sites in a project. |
list_articles | Paginated article list with status filter. |
get_article | Full markdown body + metadata for one article. |
create_article | Publish, draft, or schedule a new article. |
update_article | Edit any field on an existing article. |
upload_image | Upload an image (base64 or url) → permanent CDN URL. |
delete_article | Permanently delete an article and its per-article images. |
Two token types
The server auto-detects which kind of token you configured at boot and adapts its tool hints accordingly.
Project-scoped — ak_live_…
Bound to one project. Set GASEO_PROJECT_ID in env and the AI can call list_sites with no arguments. The AI never has to think about which project to use. Mint these from the per-project API Keys panel.
Personal Access Token — ak_user_…
Bound to your account. Covers every project you own or are a member of. The server tells the AI to call list_projects first to discover what is reachable, then pass projectId to per-project tools. One token for your whole account — create in Account → Personal API Keys.
Optional env vars
| Variable | Default | Purpose |
|---|---|---|
GASEO_API_KEY | — | Required. Your bearer token. |
GASEO_PROJECT_ID | — | Default project for list_sites with project-scoped tokens. Ignored for Personal Access Tokens — use list_projects instead. |
GASEO_API_URL | https://gaseo.ai/api/v1 | Override for staging / self-hosted setups. |
Security
- The npm package is open and contains no credentials — anyone can
npm installbut they can't do anything without a key from you. - Tokens stay in your local MCP config file. The package never phones home.
- Revoke from the dashboard at any time → MCP calls instantly start returning
401 invalid_key.
Changelog
API and MCP changes that affect external integrations.
2026-06-10
- New endpoint:
POST /sites/{siteId}/images— upload via multipart, base64, or server-side url fetch; returns a permanent CDN URL. - New endpoint:
DELETE /sites/{siteId}/articles/{articleId}— permanent delete + per-article image cleanup. - New field:
inlineImageson article create/update — rehost up to 20 images and weave them into the markdown body. - Rate limits introduced on all v1 endpoints, with
X-RateLimit-*headers and 429 +Retry-After. - Image fetch failures now return classified
errorCodes (fetch_blocked,fetch_rate_limited,fetch_timeout, …) instead of an opaquefetch 403. - CDN URL passthrough: pre-uploaded image URLs are used as-is on create/update; PATCH responses now include
featuredImageRehost. - MCP server
[email protected]: newupload_image+delete_articletools,inlineImagessupport, and clean empty responses forprompts/list/resources/listprobes.
Support
Stuck? Found a bug? Need a feature (PATCH / DELETE article, bulk import, webhooks)?
- Email the GASEO team with the API key prefix and the request ID from your error.
- Don't share the full token over email — only the prefix (first 12 chars:
ak_live_xxxx). - Include the timestamp of the failing request — we keep logs to triage.