API Reference

Base URL: https://content-apiapi-production.up.railway.app/v1  ·  Auth: x-api-key: capi_xxx or Authorization: Bearer capi_xxx ·  Not a developer? Read the User Guide instead.

Quick Start

# 1. Generate content (returns immediately with a job_id)
curl -X POST https://content-apiapi-production.up.railway.app/v1/content/generate \
  -H "x-api-key: capi_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "caption",
    "topic": "AI in HR: opportunity or threat?"
  }'

# Response (202 Accepted)
# {"job_id": "550e8400-e29b-41d4-a716-446655440000", "estimated_seconds": 10}

# 2. Poll for result — repeat every 3-5s until status is terminal
curl https://content-apiapi-production.up.railway.app/v1/content/550e8400-e29b-41d4-a716-446655440000 \
  -H "x-api-key: capi_your_key"

# Response (200 OK, when completed)
# {
#   "job_id": "550e8400-e29b-41d4-a716-446655440000",
#   "status": "completed",
#   "content": "...",
#   "quality_score": 9,
#   "review_report": {"verdict": "APPROVE", "voice_score": 9}
# }

Installation & Integration

Playground (Browser — No Install)

Visit /playground, sign in, and generate in the browser. No code needed. Best for testing the API before integrating.

REST API (Any HTTP Client)

Works with curl, Python, Node.js, Go — anything that speaks HTTP. Examples shown throughout this reference use curl.

MCP (Claude Desktop / VS Code / Cursor)

Add to ~/.claude/claude_desktop_config.json (Claude Desktop) or .vscode/mcp.json (VS Code):

{
  "mcpServers": {
    "content-api": {
      "type": "streamable-http",
      "url": "https://content-apiapi-production.up.railway.app/mcp",
      "headers": {
        "x-api-key": "capi_your_key"
      }
    }
  }
}

Restart Claude Desktop. Type write a caption about... — the write_content MCP tool runs.

MCP Tools:

ToolPurpose
write_contentGenerate content end-to-end; polls until done (3-min cap), returns final draft + score.
list_voicesList your custom voice profiles (metadata only).
get_content_statusCheck status of a running job by job_id.

CLI (npm Global)

# Install (v0.1.0 published to npm 2026-05-17)
npm install -g @content-agent/cli

# 1. Save your API key — stored at ~/.content-agent.json (mode 0600)
content-agent auth set capi_your_key

# 2. Generate content — topic is positional, --type defaults to blog
content-agent write "AI productivity tips" --type caption

# 3. Generate with more options
content-agent write "AI in healthcare" --type article --tier pro --out ai-health.md
content-agent write "Launch v2.0" --type email --mode A --brief "B2B, friendly tone"

# 4. Check a job by ID
content-agent status <job_id>
content-agent status <job_id> --content   # also prints the content

# 5. View monthly quota
content-agent usage

# 6. Show identity, plan, and usage
content-agent auth whoami

# 7. Override API base URL (self-hosted deployments)
content-agent auth url https://my-instance.example.com
Binary name: content-agent (not content-api).
Available commands: auth (set / whoami / url), write <topic>, status <job-id>, usage. There is no list-voices command — manage voices via GET /v1/voices or the dashboard.
Auth priority: env CONTENT_AGENT_API_KEY beats the config file (useful for CI). API URL override: env CONTENT_AGENT_API_URL or content-agent auth url <url>.
Polling: write polls every 3s for up to 120s. On timeout, use content-agent status <job_id> to check manually.

Glossary — Plan vs Tier (Don't Mix Them Up)

Two concepts share the word "pro" — here is the canonical cheat-sheet:

Plan (billing)
What the user pays for: Free, Starter, Pro, or Enterprise. Stored in user.plan. Controls monthly quota + voice profile limits.
Tier (pipeline)
AI investment level passed in the tier request field: basic, upgrade, or pro. Controls model selection, checklist depth, and thinking mode. The system auto-picks based on type if you omit it.
Status (job top-level)
One of: pending, running, completed, rejected, failed. Only the first three count toward monthly quota.
Verdict (inside review_report)
One of: APPROVE, REQUEST_CHANGES, REJECT. Independent from status — a job with status=completed and verdict=REJECT still consumed quota.
Free tier override
When user.plan=free, the server forces tier=basic and caps max_words=300 regardless of what you request.

Authentication

Get an API Key

1. Go to /register and enter your email.
2. Click the verification link in the email.
3. Your API key is shown once — save it immediately.

Key format: capi_ + 32 random bytes (base64url).

Pass It to Requests

# Option 1: x-api-key header
curl https://... -H "x-api-key: capi_your_key"

# Option 2: Authorization bearer token
curl https://... -H "Authorization: Bearer capi_your_key"

Managing Multiple Keys

Create additional keys to rotate or split by application:

POST /v1/auth/keys

curl -X POST https://content-apiapi-production.up.railway.app/v1/auth/keys \
  -H "x-api-key: capi_your_key" \
  -H "Content-Type: application/json"

# Response 201
# {"id": "key-uuid", "key": "capi_new_key"}

GET /v1/auth/keys

curl https://content-apiapi-production.up.railway.app/v1/auth/keys \
  -H "x-api-key: capi_your_key"

# Response 200
# {"keys": [{"id": "key-uuid", "created_at": "...", "last_used": "..."}]}

DELETE /v1/auth/keys/:id

curl -X DELETE https://content-apiapi-production.up.railway.app/v1/auth/keys/key-uuid \
  -H "x-api-key: capi_your_key"

# Response 204 (no content)

Content Generation

POST /v1/content/generate

Create a content generation job. Returns immediately with a job_id.

curl -X POST https://content-apiapi-production.up.railway.app/v1/content/generate \
  -H "x-api-key: capi_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "blog",
    "topic": "Why async communication works",
    "brief": "Audience: remote team leaders. Goal: convince them to adopt async. Tone: practical.",
    "tier": "pro",
    "voice_mode": "A",
    "voice_id": null,
    "webhook": null
  }'

# Response 202 Accepted
# {
#   "job_id": "550e8400-e29b-41d4-a716-446655440000",
#   "estimated_seconds": 60
# }
FieldTypeRequiredDescription
typestringblog · article · landing · marketing · email · caption
topicstringWhat to write about (3–500 chars)
tierstringbasic · upgrade · pro · null (auto-detect by type)
voice_modestringA (story) · B (principle) · C (analysis) — default A
voice_iduuidCustom voice profile ID (omit for default)
briefstringAdditional context: audience, goal, CTA (max 5000 chars)
webhookstring (URL)Receive result via webhook instead of polling

Estimated generation times: caption ~10s · marketing ~20s · email ~30s · blog ~60s · article ~90s · landing ~75s

Free plan override: When user.plan=free, the server forces tier=basic and caps max_words at 300 universally, regardless of content type. Upgrade to Starter or Pro to remove the cap.

Errors: 400 validation · 401 bad key · 403 tier restriction (free tier: caption + marketing only; free tier can't create voices) · 429 monthly quota exhausted · 503 queue temporarily unavailable

GET /v1/content/:job_id

Poll job status. Repeat every 3–5s until status is completed, rejected, or failed.

curl https://content-apiapi-production.up.railway.app/v1/content/550e8400-e29b-41d4-a716-446655440000 \
  -H "x-api-key: capi_your_key"

# Response 200
# {
#   "job_id": "550e8400-e29b-41d4-a716-446655440000",
#   "status": "completed",
#   "content": "Full generated content text here...",
#   "quality_score": 9,
#   "voice_version": "abc123def456",
#   "tier": "pro",
#   "voice_mode": "A",
#   "review_report": {
#     "verdict": "APPROVE",
#     "voice_score": 9,
#     "issues": [],
#     "patches": []
#   },
#   "generated_at": "2026-05-18T16:30:00.000Z"
# }
FieldDescription
statuspending · running · completed · rejected · failed
contentGenerated text (null if not completed)
quality_score0–10 (null if rejected/failed)
voice_versionSHA-1 of the voice JSON at time of generation (audit trail)
verdictAPPROVE — passed review, delivered if score ≥ 6 · REQUEST_CHANGES — issues found, system auto-patches and delivers with advisory · REJECT — content blocked regardless of score
issuesList of flags raised by reviewer (if any)

Status meanings: pending — in queue · running — generating · completed — ready · rejected — failed quality gate, does NOT count toward quota · failed — system error, does NOT count toward quota, retry.

Errors: 401 bad key · 404 job not found

Voice Profiles

POST /v1/voices

Create a custom voice profile from writing samples. Requires a paid plan (Starter or Pro).

curl -X POST https://content-apiapi-production.up.railway.app/v1/voices \
  -H "x-api-key: capi_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "My Newsletter Voice",
    "samples": [
      "Sample post 1: I started my career...",
      "Sample post 2: Remote work changed everything...",
      "Sample post 3: Here is why async matters..."
    ],
    "hard_rules": ["no_emoji", "no_emdash", "contractions"],
    "mode_default": "A"
  }'

# Response 201
# {
#   "id": "550e8400-e29b-41d4-a716-446655440000",
#   "name": "My Newsletter Voice",
#   "voice_version": "abc123def456",
#   "created_at": "2026-05-18T10:00:00.000Z"
# }
FieldTypeRequiredDescription
namestringVoice profile name (1–100 chars)
samplesstring[]Writing samples (1–10 items; 3–5 recommended)
hard_rulesstring[]Style constraints: no_emoji, no_emdash, contractions, oxford_comma, etc. Default: ["no_emoji", "no_emdash"]
mode_defaultstringDefault voice mode: A · B · C — default A

Errors: 400 invalid input · 401 bad key · 403 free plan can't create voices, or voice limit reached (Starter: 1, Pro: 5)

GET /v1/voices

List all custom voice profiles (metadata only). Raw voice JSON (samples + hard rules) is never returned. Free-plan users receive an empty voices array — this is 200 OK, not 403. Voice creation is blocked on POST, not GET.

curl https://content-apiapi-production.up.railway.app/v1/voices \
  -H "x-api-key: capi_your_key"

# Response 200
# {
#   "voices": [
#     {
#       "id": "550e8400-e29b-41d4-a716-446655440000",
#       "name": "My Newsletter Voice",
#       "voice_version": "abc123def456",
#       "created_at": "2026-05-18T10:00:00.000Z"
#     }
#   ]
# }

Errors: 401 bad key

GET /v1/voices/:id

Get metadata for a single voice. Same shape as the list entries — no raw voice JSON exposed.

curl https://content-apiapi-production.up.railway.app/v1/voices/550e8400-e29b-41d4-a716-446655440000 \
  -H "x-api-key: capi_your_key"

# Response 200
# {
#   "id": "550e8400-e29b-41d4-a716-446655440000",
#   "name": "My Newsletter Voice",
#   "voice_version": "abc123def456",
#   "created_at": "2026-05-18T10:00:00.000Z"
# }

Errors: 401 bad key · 404 voice not found

PUT /v1/voices/:id

Update a voice profile (name, samples, hard rules, etc.). Auto-bumps voice_version. Existing jobs keep the version active at their creation time.

curl -X PUT https://content-apiapi-production.up.railway.app/v1/voices/550e8400-e29b-41d4-a716-446655440000 \
  -H "x-api-key: capi_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "My Newsletter Voice — Updated",
    "samples": ["Sample 1...", "Sample 2...", "Sample 3..."],
    "hard_rules": ["no_emoji"]
  }'

# Response 200
# {
#   "id": "550e8400-e29b-41d4-a716-446655440000",
#   "name": "My Newsletter Voice — Updated",
#   "voice_version": "new_sha1_version",
#   "created_at": "2026-05-18T10:00:00.000Z"
# }

Errors: 401 bad key · 404 voice not found

Auth Endpoints

POST /v1/auth/register

Create a new account.

curl -X POST https://content-apiapi-production.up.railway.app/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com"}'

# Response 201
# {"message": "Verification email sent"}

Errors: 400 invalid email · 409 email already registered

POST /v1/auth/login

Request a magic login link (sent to verified emails only).

curl -X POST https://content-apiapi-production.up.railway.app/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com"}'

# Response 200
# {"message": "Login link sent to verified email"}

Errors: 400 invalid email · 403 email not verified · 429 throttled (5min between sends)

GET /v1/auth/verify?token=TOKEN

Verify email and issue API key (called by clicking the email link).

curl https://content-apiapi-production.up.railway.app/v1/auth/verify?token=abc123... \
  -H "Content-Type: application/json"

# Response 200
# {
#   "key": "capi_xxx",
#   "tier": "free",
#   "message": "Save your key now — you won't see it again"
# }

Errors: 400 invalid or expired token · 404 token not found

GET /v1/auth/me

Get current user info (plan, usage, email verified).

curl https://content-apiapi-production.up.railway.app/v1/auth/me \
  -H "x-api-key: capi_your_key"

# Response 200
# {
#   "email": "user@example.com",
#   "tier": "starter",
#   "email_verified": true,
#   "subscription_valid": true,
#   "subscription_expires_at": "2026-06-18T00:00:00Z"
# }

Errors: 401 missing/invalid API key

GET /v1/auth/usage

Get monthly quota details.

curl https://content-apiapi-production.up.railway.app/v1/auth/usage \
  -H "x-api-key: capi_your_key"

# Response 200
# {
#   "tier": "starter",
#   "monthly_limit": 100,
#   "used_this_month": 23,
#   "remaining": 77,
#   "reset_at": "2026-06-01T00:00:00Z",
#   "billing_cycle_started": "2026-05-18T00:00:00Z"
# }

Errors: 401 missing/invalid API key

POST /v1/auth/resend-verification

Resend verification email.

curl -X POST https://content-apiapi-production.up.railway.app/v1/auth/resend-verification \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com"}'

# Response 200
# {"message": "Verification email sent"}

Errors: 400 invalid email · 403 email already verified · 429 throttled (wait 5min)

Webhooks

POST /v1/webhooks/test

Verify your webhook endpoint is reachable before relying on it.

curl -X POST https://content-apiapi-production.up.railway.app/v1/webhooks/test \
  -H "x-api-key: capi_your_key" \
  -H "Content-Type: application/json" \
  -d '{"webhook": "https://yoursite.com/webhooks/content"}'

# Response 200
# {"message": "Webhook is reachable"}

Errors: 400 invalid URL · 503 endpoint unreachable

Webhook Delivery

When you set webhook on a generate request, the server POSTs to that URL when the job completes or is rejected:

POST <your webhook URL>
Content-Type: application/json
X-Content-Signature: sha256=<hex digest>

{
  "job_id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "completed",
  "content": "Generated content text...",
  "quality_score": 9,
  "review_report": {
    "verdict": "APPROVE",
    "voice_score": 9,
    "issues": [],
    "patches": []
  }
}

Signature Verification (Node.js)

Each delivery includes X-Content-Signature: sha256=..., an HMAC-SHA256 of the raw request body. Verify in constant time before trusting the payload:

import { createHmac, timingSafeEqual } from "crypto";

function verifyWebhook(rawBody, signatureHeader, signingSecret) {
  // signatureHeader = "sha256=abc123..."
  const provided = signatureHeader.replace("sha256=", "");
  const expected = createHmac("sha256", signingSecret)
    .update(rawBody)
    .digest("hex");
  return timingSafeEqual(
    Buffer.from(provided),
    Buffer.from(expected)
  );
}

Retry policy: Max 3 attempts · backoff 1s, 2s, 4s · 10s timeout per attempt. Triggers on network error or non-2xx response. After all failures, content is still saved — poll GET /v1/content/:id.

Billing

GET /v1/billing/status

Get subscription status.

curl https://content-apiapi-production.up.railway.app/v1/billing/status \
  -H "x-api-key: capi_your_key"

# Response 200
# {
#   "tier": "starter",
#   "subscription_active": true,
#   "subscription_expires_at": "2026-06-18T00:00:00Z",
#   "monthly_limit": 100,
#   "used_this_month": 23,
#   "remaining": 77
# }

Errors: 401 missing/invalid API key

POST /v1/billing/checkout

Generate a payment link (PayOS).

curl -X POST https://content-apiapi-production.up.railway.app/v1/billing/checkout \
  -H "x-api-key: capi_your_key" \
  -H "Content-Type: application/json" \
  -d '{"tier": "starter"}'

# Response 200
# {
#   "checkout_url": "https://payment.payos.vn/...",
#   "order_id": "order-uuid"
# }
FieldTypeDescription
tierstringstarter · pro

Errors: 401 bad key · 400 already on that plan

Rate Limits & Quotas

Monthly limits reset on the 1st of each month at 00:00 UTC.

PlanMonthly LimitContent TypesCustom Voices
Free10caption, marketing0
Starter100all1
Pro500all5
EnterpriseCustomallUnlimited

Quota counting: Only pending, running, and completed jobs count. rejected and failed jobs do not consume quota. Check usage anytime via GET /v1/auth/usage.

Error Codes

CodeMeaningExample
400Validation errorInvalid JSON, topic too short, invalid UUID
401Missing or invalid API keyMissing x-api-key header
403Forbidden — plan restrictionFree plan tried to create voice; free plan tried blog type
404Resource not foundJob ID or voice ID doesn't exist
409ConflictEmail already registered
429Rate limited — quota exhaustedMonthly limit reached; resend throttled
503Server error — queue unavailableTemporary system issue, retry later

Pipeline Overview

Tier Auto-Routing

If you omit tier, the system auto-routes based on type:

Content typeAuto-routed tier
captionbasic
marketing, emailupgrade
blog, article, landingpro

Override only when you have a specific reason. Free plan users are always forced to basic regardless.

Quality Score Gate

A job reaches the caller only if both conditions are true:

If score < 6, verdict becomes REJECT even if content looks acceptable. If verdict is REQUEST_CHANGES, the system attempts an auto-patch and delivers with an advisory. If verdict is REJECT, content is blocked.

Voice Version Snapshots

voice_version in a completed job is the SHA-1 hash of the voice JSON (name + samples + hard rules + mode_default) at the time the job ran. This creates an audit trail — old jobs keep their original version even if you later update the voice.

ResourceLink
User Guide (non-technical)/docs
Playground (browser)/playground
Pricing/pricing
Dashboard (account, usage, voices)/dashboard
CLI (npm)npm install -g @content-agent/cli

Need help? Email support or check the User Guide FAQ.