Quickstart
Every request needs an API key. Keys are issued by the WritersAPI team: email [email protected] 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 a city reopening its main bridge after six weeks of 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), up to 20,000 characters. 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". Sets the structure (parts and their order), not the tone. |
| language | string | none | Any language name or tag: "French", "pt-BR", "Tagalog". Default: the language of your prompt. |
| style | string | none | "neutral", "professional", "journalistic", "conversational", "friendly", "persuasive", "formal", "academic", "witty", "technical", "storytelling", "simple". Sets the tone; works with any template. |
| 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. "owners of small online shops". |
| 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; with a template and no schema, each part is its own key. |
| 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": "# Main bridge reopens after six weeks of repairs...",
"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
A template sets the structure of the piece: which parts it has and in what order, like the subject line and signature of an email or the dateline and media contact of a press release. Tone is separate, set with style. Templates never replace your prompt. GET /v1/templates returns this list as JSON.
| template | Structure | Words (medium) |
|---|---|---|
| article | headline, standfirst, lead, body, what happens next | 700 |
| blog-post | title, intro, sections, takeaway, call to action | 900 |
| listicle | headline, intro, items, conclusion | 900 |
| review | title, summary, what it is, in use, pros, cons, alternatives, verdict | 900 |
| press-release | release line, headline, subheadline, dateline, lead, body, quotes, about, media contact, end mark | 500 |
| copy | headline, subheadline, body, proof points, call to action | 200 |
| product-description | product name, opening, who it's for, key features, specifications | 250 |
| how-to | title, outcome, what you need, steps, tips and common mistakes | 800 |
| newsletter | subject line, preview text, opening, sections, call to action, sign-off | 500 |
| social-post | hook, body, call to action, hashtags | 120 |
| subject line, greeting, body, next step, sign-off, signature | 200 | |
| summary | one-line summary, key points, details | 200 |
| rewrite | text | as needed |
| seo-meta | titles, meta descriptions, url slug | as needed |
Parts in italics are included only when the brief has the material for them. 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.
- Providers that were overloaded in the last 5 minutes go after every other working model. Models that are currently broken go last and are re-tested every 30 minutes, so a recovered model comes back on its own.
Answers stream from the provider. If a model sends nothing within 15 seconds (60 with thinking, plus 15 with web search), or stops for 20 seconds mid-answer (45 with thinking), the next model takes over. A long article that keeps arriving is never cut off.
What happens when a model fails:
| outcome | Meaning | What the router does |
|---|---|---|
| overloaded | The provider is too busy, or queued the request without answering | Tries the next model at once, puts that provider last for 5 minutes and alerts the team. |
| unavailable | Timeout, network error, other 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 3 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. A newer stable release of a model already in the lineup is added on its own and tried just before the version it replaces, which stays as its fallback.
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 | Available | web search, JSON, thinking |
| gpt-6.1-sol | chatgpt | Writing | Available | web search, JSON, thinking |
| deepseek-v4-pro | deepseek | Writing | Available | JSON, thinking |
| gemini-4-argon | gemini | Writing | Available | web search, JSON, thinking |
| gpt-6-astra | chatgpt | Writing | Available | web search, JSON, thinking |
| deepseek-flash | deepseek | Writing | Available | JSON, thinking |
| gpt-6-luna | chatgpt | Writing | Available | web search, JSON, thinking |
| gemini-3.5-flash-lite | gemini | Writing | Available | web search, JSON, thinking |
| gemini-3.1-flash-lite | gemini | Writing | Available | web search, JSON, thinking |
| flux-2-pro | flux | Image | Available | n/a |
| nano-banana-2-lite | nano-banana | Image | Available | n/a |
| gpt-image-2 | chatgpt | Image | Available | n/a |
| flux-3 | flux | Image | Available | n/a |
| nano-banana-2.1 | nano-banana | Image | Available | n/a |
| gpt-image-mini | chatgpt | Image | Available | n/a |
| flux-2-klein | flux | Image | Available | 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.
- Per address: 600 requests a minute to /v1 from one IP, across all keys. Over it you get 429.
- Body size: 512 KB. Prompt: 100,000 characters; system: 20,000; image prompt: 4,000.
- 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 Toronto.",
"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"]))