Skip to main content

Add Headlines API

How to add headlines via API?

Written by Vitalik May

Create one or more headlines for your SEObot website programmatically, with optional custom context per headline. Headlines created via this endpoint flow into the same article-generation pipeline as headlines added manually in the UI.

The endpoint is batch-friendly with partial success: valid headlines are inserted, problematic ones are reported per item. You don't have to fix the whole batch to make progress on the good ones.

Related API

Endpoint

POST https://app.seobotai.com/api/v1/headlines
Content-Type: application/json

Authentication

Pass your website API key as key in the request body. Each website has its own UUID key, auto-generated when the website was onboarded. You can find it on the CMS → Connect (Api) screen.

The key identifies the target website — you don't pass host separately.

Request body

{   
"key": "your-website-api-key",
"headlines": [
{
"headline": "Best CRMs for Small Business in 2026",
"context": "Focus on tools under $50/user/month. Mention HubSpot, Pipedrive, Zoho.",
"slug": "best-crms-for-small-business-2026"
},
{
"headline": "How to Run a Lead Scoring Campaign"
}
]
}

Fields

Field

Type

Required

Description

key

string

yes

Per-website API key (UUID).

headlines

array

yes

1 to 100 items per request.

headlines[].headline

string

yes

1 to 100 characters, trimmed. The article title.

headlines[].context

string

no

0 to 5000 characters. Custom brief / instructions that guides article generation (audience, tone, must-mention products, sources, etc.).

headlines[].slug

string

no

URL slug. If omitted, a slug is derived deterministically from headline. If provided, must match ^[a-z0-9]+(?:-[a-z0-9]+)*$ (lowercase alphanumerics joined by single dashes), 1-100 chars. Pass an explicit slug if you need stable, controlled URLs (e.g., to match an external CMS) or non-English headlines that wouldn't slugify cleanly.

Limits

  • 100 headlines per request

  • 100 characters per headline

  • 5000 characters per context

  • 100 characters per slug

  • 5000 headlines total per website (counted against non-deleted headlines)

Slug behavior

  • If you pass slug, it's used as-is and never changed afterwards.

  • If you omit slug, we generate one from the headline immediately and return it. We may improve it shortly after (e.g., remove filler words, transliterate non-English) - but only before article generation starts. Once article generation begins, the slug is final and won't change.

  • If you need a guaranteed stable URL from the moment of insert, pass slug explicitly.

Response

There are two response shapes, distinguished by the top-level success flag.

A. Request couldn't be processed at all (success: false)

Returned for auth/shape errors that prevent the request from being evaluated. No items are processed.

{
"success": false,
"code": "INVALID_KEY",
"error": "Wrong API key"
}

code

Meaning

Extra fields

How to handle

MISSING_KEY

key field missing or empty.

Add key to the request.

INVALID_KEY

key doesn't match any website.

Check the API key on the Connect screen. Don't retry blindly.

EMPTY_HEADLINES

headlines array missing or empty.

Include at least one headline.

TOO_MANY_HEADLINES

More than 100 items in one request.

limit, received

Split your input into chunks of ≤ 100 and call sequentially.

WEBSITE_NOT_FOUND

Key resolved a host but the website record is missing (rare; stale key).

Contact support.

INTERNAL_ERROR

Unexpected server-side failure (e.g., transient downstream service error).

Safe to retry with backoff. If persistent, contact support — the call is logged on our side.

B. Request was processed (success: true)

Returned whenever the request was valid enough to evaluate the items — even if zero were actually inserted (all duplicates / all invalid). Each input headline gets exactly one entry in results, in input order. summary gives quick aggregate counts.

{
"success": true,
"summary": { "total": 5, "inserted": 2, "skipped": 3 },
"results": [
{ "index": 0, "headline": "Best CRMs for Small Business in 2026", "status": "inserted", "id": "65f1c2a4e7b9d1a3f4e2c5b8" },
{ "index": 1, "headline": "best crms for small business in 2026", "status": "skipped", "code": "DUPLICATE_IN_BATCH", "error": "Earlier item in this request has the same headline", "conflictsWith": { "index": 0, "headline": "Best CRMs for Small Business in 2026" } },
{ "index": 2, "headline": "Best CRMs for Small Business in 2024", "status": "skipped", "code": "DUPLICATE_YEAR_VARIANT", "error": "A headline that only differs by year already exists", "conflictsWith": { "headline": "Best CRMs for Small Business in 2026" } },
{ "index": 3, "headline": "Lead Scoring Best Practices", "status": "inserted", "id": "65f1c2a4e7b9d1a3f4e2c5b9" },
{ "index": 4, "headline": "", "status": "skipped", "code": "EMPTY_HEADLINE", "error": "Headline is empty" }
]
}

After insertion, headlines flow into the standard SEObot article-generation pipeline automatically. The custom context you provided is used during generation, so the resulting article reflects your instructions.

summary

Field

Type

Description

total

number

Same as headlines.length in the request.

inserted

number

How many were actually written.

skipped

number

How many were rejected (duplicates, invalid, over limit).

results[] — every entry

Field

Type

Description

index

number

Zero-based position in the request's headlines array.

headline

string

The normalized headline (trimmed, quotes stripped, whitespace collapsed). For EMPTY_HEADLINE this is the raw input.

status

"inserted" | "skipped"

Per-item outcome.

results[] — when status: "inserted"

Field

Type

Description

id

string

Unique identifier of the new headline. Use it as a stable reference to the article being generated.

results[] — when status: "skipped"

Field

Type

Description

code

string

One of the skip codes below. Branch on this, not on error.

error

string

Human-readable explanation. May change; don't parse it.

conflictsWith

object

Present for DUPLICATE_* codes. Shape: { headline: string, index?: number }. index is only present for DUPLICATE_IN_BATCH (pointing to the earlier offender in this same request).

Skip codes

code

Meaning

EMPTY_HEADLINE

The headline was empty after trim / normalization.

HEADLINE_TOO_LONG

Headline exceeded 100 characters after trim. The error field includes the actual length.

CONTEXT_TOO_LONG

Context exceeded 5000 characters after trim. The error field includes the actual length.

INVALID_SLUG

Caller-provided slug failed the format check, OR no slug was provided and the headline could not be slugified (e.g., only punctuation / non-alphanumeric characters). Pass an explicit slug to fix.

DUPLICATE_IN_BATCH

Another earlier item in the same request had the same headline or slug. See conflictsWith.index for the offender's position.

DUPLICATE_SLUG

A headline with the same URL slug already exists for this website (strictest collision).

DUPLICATE_TEXT

A headline with the same text (case-insensitive) already exists.

DUPLICATE_YEAR_VARIANT

A headline that differs only by year (e.g. "2024" vs "2026") already exists.

LIMIT_EXCEEDED

The website has hit the 5000-headline cap; this item didn't fit. Items that fit before the cap was reached are still inserted.

INSERT_FAILED

Database write failed at the very end. Rare. Safe to retry — failed items are not in the database.

Caller pattern

const res = await fetch('https://app.seobotai.com/api/v1/headlines', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, headlines }),
}).then(r => r.json());

if (!res.success) {
// Couldn't process at all — branch on top-level code
switch (res.code) {
case 'INVALID_KEY':
throw new Error('Bad API key — check Connect screen');
case 'TOO_MANY_HEADLINES':
/* chunk and retry */
break;
default:
throw new Error(`${res.code}: ${res.error}`);
}
return;
}

// Processed — examine per-item results
console.log(`Inserted ${res.summary.inserted} of ${res.summary.total}`);

for (const r of res.results) {
if (r.status === 'inserted') {
console.log(`✓ [${r.index}] ${r.headline} → ${r.id}`);
} else {
console.warn(`✗ [${r.index}] ${r.headline} — ${r.code}: ${r.error}`);
}
}

// Want to retry only the recoverable ones?
const retryable = res.results
.filter(r => r.status === 'skipped' && r.code === 'INSERT_FAILED')
.map(r => headlines[r.index]);

Behavior notes

  • Partial success. Inserted items are committed even if other items in the same batch are skipped. There's no rollback across items.

  • Idempotency. Re-sending the same headline returns a DUPLICATE_* skip for that item; previously inserted items are unaffected. Safe to retry.

  • Ordering. results is sorted by index in input order regardless of internal processing order.

  • Whitespace and quotes. Each headline is trimmed; surrounding "..." or '...' are stripped; internal whitespace is collapsed. The stored value (and the headline field in results) is the normalized form.

  • No host in the request. It's derived from the API key.

  • Quota. API-created headlines count against your monthly article quota exactly like headlines added in the UI.

Examples

Minimal - one headline, no context

curl -X POST https://app.seobotai.com/api/v1/headlines \
-H "Content-Type: application/json" \
-d '{ "key": "YOUR_API_KEY", "headlines": [ { "headline": "How to Train an LLM on Your Docs" } ] }'

Bulk - mixed valid/invalid

curl -X POST https://app.seobotai.com/api/v1/headlines \
-H "Content-Type: application/json" \
-d '{
"key": "YOUR_API_KEY",
"headlines": [
{ "headline": "Best CRMs for Small Business in 2026", "context": "Focus on under-$50/user/month tools." },
{ "headline": "Lead Scoring Best Practices", "context": "B2B SaaS angle." },
{ "headline": "Cold Email Subject Lines That Convert" }
]
}'

Response (illustrative):

{   
"success": true,
"summary": { "total": 3, "inserted": 2, "skipped": 1 },
"results": [
{ "index": 0, "headline": "Best CRMs for Small Business in 2026", "status": "inserted", "id": "65f1c2..." },
{ "index": 1, "headline": "Lead Scoring Best Practices", "status": "skipped", "code": "DUPLICATE_TEXT", "error": "A headline with the same text (case-insensitive) already exists", "conflictsWith": { "headline": "Lead Scoring Best Practices" } },
{ "index": 2, "headline": "Cold Email Subject Lines That Convert", "status": "inserted", "id": "65f1c3..." }
]
}
Did this answer your question?