Quickstart
Every request needs an API key. Keys are issued by the WritersAPI team: email hello@writersapi.com with your site's name and whether you want hosted keys or to bring your own. Then:
curl https://writersapi.com/v1/write \
-H "Authorization: Bearer $WRITERSAPI_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "Write about the reopening of the Lekki toll bridge after repairs.",
"template": "article",
"style": "journalistic",
"length": "medium"
}'The answer is in data.content. Which model wrote it, whether a fallback was needed and what it cost are returned alongside.
Authentication
Send your key in the Authorization header as a bearer token. Keys start with wapi_.
Authorization: Bearer wapi_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXWrite text
POST/v1/write
Your prompt is passed to the model exactly as sent. Everything else is optional: templates and options add instructions, and your own system text is added last so it wins over them.
| Field | Type | Default | What it does |
|---|---|---|---|
| prompt | string | required | What to write, or the text to work on. Up to 100,000 characters. |
| system | string | none | Your site's own instructions (voice, rules, house style). Applied after templates and options, so it takes precedence. |
| model | string | "auto" | "auto", a family ("deepseek", "gemini", "chatgpt", "flux", "nano-banana"), or an exact model id from Models. |
| fallback | boolean | true | Try other models when the first fails (up to 4). false: exactly one model is tried. |
| preference | string | "balanced" | "balanced" follows our priority order, "quality" tries flagship models first, "cost" the cheapest first. |
| web_search | boolean | false | Ground the answer in live web results. Only models that can search are used, and their sources come back in citations. |
| template | string | none | One of the templates, e.g. "press-release". |
| language | string | none | Any language name or tag: "French", "pt-BR", "Igbo". Default: the language of your prompt. |
| style | string | none | "neutral", "professional", "journalistic", "conversational", "friendly", "persuasive", "formal", "academic", "witty", "technical", "storytelling", "simple" |
| length | string | none | "short", "medium", "long". Scales the template's usual length. |
| words | integer | none | Target word count (20 to 10,000). Overrides length. |
| audience | string | none | Who it is for, e.g. "small business owners in Kenya". |
| keywords | string[] | none | Up to 20 keywords to work in naturally. |
| format | string | "markdown" | "markdown", "html", "text", "json". json returns the parsed object in data.json too. |
| json_schema | object | none | A JSON Schema the answer must follow, with format "json". |
| temperature | number | none | 0 to 2. Ignored by models that don't take it (thinking mode). |
| max_tokens | integer | 8192 | Output cap, 16 to 64,000. 32,000 by default with thinking on, since thinking counts toward it. |
| thinking | boolean | false | Let models that support it reason before answering. Slower and costs more; better for complex briefs. |
Response
{
"success": true,
"data": {
"id": "req_8213",
"content": "# Lekki toll bridge reopens...",
"format": "markdown",
"truncated": false,
"citations": [],
"model": { "id": "gpt-6.1-sol", "family": "chatgpt", "provider": "openrouter" },
"requested_model": "auto",
"fallback_used": true,
"upgraded_from": null,
"attempts": [
{ "model": "gemini-3.8-flash", "outcome": "unavailable", "latency_ms": 30012 },
{ "model": "gpt-6.1-sol", "outcome": "ok", "latency_ms": 6240 }
],
"usage": {
"input_tokens": 412,
"output_tokens": 903,
"cost_usd": 0.010341,
"provider_cost_usd": 0.009849,
"billing": "hosted"
}
}
}truncatedis true when the answer hitmax_tokens; raise it and retry.upgraded_fromnames the model you asked for when it was retired and its successor answered instead.cost_usdis what this request costs you: 0 with your own keys.
Templates and options
Templates add structure and editorial rules for common jobs. They never replace your prompt. GET /v1/templates returns this list as JSON.
| template | For | Words (medium) |
|---|---|---|
| article | A news or feature article with a headline, a strong lead and clear sections. | 700 |
| blog-post | An engaging blog post with a hook, scannable sections and a takeaway. | 900 |
| listicle | A numbered list article: an intro, numbered items with explanations, a wrap-up. | 900 |
| review | A balanced product or service review with pros, cons and a verdict. | 900 |
| press-release | A standard press release: headline, dateline, lead, quotes and boilerplate. | 500 |
| copy | Short persuasive copy for ads, landing pages and calls to action. | 200 |
| product-description | A product page description: benefits first, then features and specifications. | 250 |
| how-to | Step-by-step instructions with what you need and tips. | 800 |
| newsletter | An email newsletter with a subject line, a short intro and sections. | 500 |
| social-post | A social media post, sized for the platform named in the prompt. | 120 |
| A clear, purposeful email with a subject line. | 200 | |
| summary | A faithful summary of the text supplied in the prompt. | 200 |
| rewrite | Rewrites the supplied text in a new style while keeping its meaning. | as needed |
| seo-meta | An SEO title (up to 60 characters) and meta description (up to 155). | as needed |
Without web search, every template tells the model not to invent facts, quotes or figures, and to leave a placeholder in square brackets where your brief lacks a detail.
Generate images
POST/v1/images
| Field | Type | Default | What it does |
|---|---|---|---|
| prompt | string | required | What to draw. Up to 4,000 characters. |
| model | string | "auto" | "auto", "flux", "nano-banana", "chatgpt", or an exact model id. |
| fallback | boolean | true | Try other image models when the first fails. |
| preference | string | "balanced" | "balanced", "quality" or "cost". |
| aspect_ratio | string | "1:1" | "1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3" |
| style | string | none | "photo", "editorial", "illustration", "flat-vector", "3d", "watercolor", "line-art", "cinematic" |
| output_format | string | "png" | "png", "jpeg" or "webp". FLUX honours it; other models return their own format, named in mime_type. |
{
"success": true,
"data": {
"id": "req_8214",
"image": { "b64_json": "iVBORw0KGgo...", "mime_type": "image/png" },
"aspect_ratio": "16:9",
"model": { "id": "flux-2-pro", "family": "flux", "provider": "bfl" },
"fallback_used": false,
"attempts": [{ "model": "flux-2-pro", "outcome": "ok", "latency_ms": 9120 }],
"usage": { "input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0315, "provider_cost_usd": 0.03, "billing": "hosted" }
}
}One image per request, returned as base64. Save it to your own storage; nothing is kept on our side.
Routing and fallback
For every request the router builds an ordered list of models, then tries them until one answers:
- Only models for the task, enabled, and able to do what you asked (web search, for example) are considered.
- A model you named goes first. If it was retired, its successor (or its family's best working model) takes its place.
- Then the rest of the same family, then every other model, ordered by your preference.
- Models that are currently broken go last and are re-tested every 30 minutes, so a recovered model comes back on its own.
What happens when a model fails:
| outcome | Meaning | What the router does |
|---|---|---|
| unavailable | Timeout, network error, provider 5xx, unreadable answer | Tries the next model. Three in a row mark the model broken and alert the team. |
| rate-limit | The provider is throttling | Tries the next model. The model keeps its standing. |
| model-gone | Retired, renamed or removed by the provider | Marks it broken at once, alerts the team, tries the next model. |
| auth | The provider rejected the key | Skips that provider for the request and alerts the team. |
| billing | The provider account is out of credit | Same as auth. |
| rejected | The model refused or couldn't process this request | Tries the next model. Says nothing about the model's health. |
Every six hours, WritersAPI also asks each provider which models it still offers. A model missing from two checks in a row is retired and its traffic moves to its successor, before any request fails on it.
Models
GET/v1/models
The live lineup with status, features and hosted prices. No key needed. Use a model's id as model to pin it, or a family name to always get that family's best working model.
| Model id | Family | Task | Status | Features |
|---|---|---|---|---|
| gemini-3.8-flash | gemini | Writing | With your own key | web search, JSON, thinking |
| gpt-6.1-sol | chatgpt | Writing | With your own key | web search, JSON, thinking |
| deepseek-v4-pro | deepseek | Writing | With your own key | JSON, thinking |
| gemini-4-argon | gemini | Writing | With your own key | web search, JSON, thinking |
| gpt-6-astra | chatgpt | Writing | With your own key | web search, JSON, thinking |
| deepseek-flash | deepseek | Writing | With your own key | JSON, thinking |
| gpt-6-luna | chatgpt | Writing | With your own key | web search, JSON, thinking |
| gemini-3.1-flash-lite | gemini | Writing | With your own key | web search, JSON, thinking |
| flux-2-pro | flux | Image | With your own key | n/a |
| nano-banana-2-lite | nano-banana | Image | With your own key | n/a |
| gpt-image-2 | chatgpt | Image | With your own key | n/a |
| flux-3 | flux | Image | With your own key | n/a |
| nano-banana-2.1 | nano-banana | Image | With your own key | n/a |
| gpt-image-mini | chatgpt | Image | With your own key | n/a |
| flux-2-klein | flux | Image | With your own key | n/a |
Usage
GET/v1/usage
This calendar month's totals (UTC) for the key you send: requests, errors, tokens, images, cost and budget.
{
"success": true,
"data": {
"key": "Example News", "billing": "hosted", "month": "2026-10",
"requests": 1840, "errors": 3, "input_tokens": 905112, "output_tokens": 1630442,
"images": 212, "cost_usd": 24.18, "budget_usd": 100, "rate_per_minute": 60
}
}Errors
Errors use the same envelope with success: false, a stable code and a readable message. Failed routing also returns the attempts.
| Status | code | Meaning |
|---|---|---|
| 400 | invalid_request, invalid_json, unknown_model, unsupported | Fix the request. The message names the field. |
| 401 | missing_key, invalid_key | No key, a wrong key, or a disabled key. |
| 402 | budget_exceeded | The key's monthly budget is used up. |
| 413 | too_large | The body is over 512 KB. |
| 415 | unsupported_media_type | Send Content-Type: application/json. |
| 422 | rejected | Every model refused the request. Check the prompt. |
| 429 | rate_limited | Slow down; wait for the Retry-After seconds. |
| 503 | all_models_failed, no_model_available, unavailable | Nothing could answer right now. Retry after the Retry-After seconds. |
{
"success": false,
"code": "all_models_failed",
"message": "No model could complete this request right now. Retry shortly.",
"attempts": [
{ "model": "gemini-3.8-flash", "outcome": "unavailable", "latency_ms": 30010 },
{ "model": "gpt-6.1-sol", "outcome": "rate-limit", "latency_ms": 412 }
]
}Limits
- Requests per minute: set per key, 60 by default. Over it you get 429 with Retry-After.
- Body size: 512 KB. Prompt: 100,000 characters.
- Time: a writing request may take up to 6 minutes across all fallbacks (4 per model); an image request up to 4. Set your HTTP client's timeout above that.
Billing
Hosted keys pay the provider's cost plus 5%; keys with their own provider keys pay nothing to us. Only the model that answered is billed. See pricing.
Code examples
const res = await fetch("https://writersapi.com/v1/write", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WRITERSAPI_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
prompt: "Five ways small shops can cut energy bills this winter.",
template: "listicle",
language: "English",
format: "html",
}),
signal: AbortSignal.timeout(7 * 60 * 1000),
});
const body = await res.json();
if (!body.success) throw new Error(`${body.code}: ${body.message}`);
console.log(body.data.model.id, body.data.content);$ch = curl_init("https://writersapi.com/v1/write");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 420,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("WRITERSAPI_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"prompt" => "Announce our new office in Accra.",
"template" => "press-release",
]),
]);
$body = json_decode(curl_exec($ch), true);
echo $body["success"] ? $body["data"]["content"] : $body["message"];import os, requests
r = requests.post(
"https://writersapi.com/v1/images",
headers={"Authorization": f"Bearer {os.environ['WRITERSAPI_KEY']}"},
json={"prompt": "A quiet harbour at dawn", "aspect_ratio": "16:9", "style": "editorial"},
timeout=300,
)
body = r.json()
if body["success"]:
import base64
open("harbour.png", "wb").write(base64.b64decode(body["data"]["image"]["b64_json"]))