EchoWriting API Reference
Rewrite text in your own voice - programmatically.
The EchoWriting API gives you direct access to the same rewrite engine, voice presets, and history that powers echowriting.ai. Send raw text in, get text rewritten in your chosen voice out.
- Base URL:
https://api.echowriting.ai - Authentication:
Authorization: Bearer ewr_live_... - Format: All endpoints are
POST. All bodies and responses are JSON. UTF-8 throughout. - API version:
v1(stable).
Quickstart
export EWR_KEY="ewr_live_AbCd1234..."
curl -X POST https://api.echowriting.ai/v1/rewrite \
-H "Authorization: Bearer $EWR_KEY" \
-H "Content-Type: application/json" \
-d '{
"preset_id": "casual_dm",
"input_text": "We are pleased to inform you that your order has shipped."
}'
Response:
{
"id": "08c3a1b2-...",
"status": "ready",
"thread_id": "f3a17e09-...",
"version_no": 1,
"preset_id": null,
"preset_name_snapshot": "Casual DM",
"input_text": "We are pleased to inform you that your order has shipped.",
"output_text": "good news - your order's on the way",
"error": null,
"opts": null,
"truncated": false,
"billed": true,
"source": "api",
"created_at": "2026-05-03T14:22:31.118Z",
"updated_at": "2026-05-03T14:22:35.402Z"
}
Authentication
Every request must include:
Authorization: Bearer ewr_live_AbCd...WxYz
Content-Type: application/json
Keys are minted from your account at echowriting.ai. Sign in on a paid plan (Pro or Plus), open API key settings, name your key, and click Create key. The full secret is shown once - copy it immediately. You can rotate it later if you lose it (same id, fresh secret), but you cannot retrieve the original.
Key shape
ewr_live_ + 32 url-safe characters, e.g. ewr_live_AbCd1234EfGh5678IjKl9012MnOp34. 192 bits of cryptographic entropy. Treat it like a password - never commit it to source, never embed it in client-side code.
Authentication errors
| HTTP | code | When |
|---|---|---|
| 401 | unauthenticated |
Missing Authorization header, or it doesn't start with Bearer . |
| 401 | invalid_api_key |
Key shape is malformed, key doesn't exist, or key has been deleted. |
Plans, quota, and pricing
API requests share the same weekly rewrite quota as your web account. There is no separate API tier - Pro gets 50 rewrites/week total (web + API combined), Plus gets 110.
| Plan | Weekly rewrites | Input cap (chars) | API access | Custom presets |
|---|---|---|---|---|
| Free | 10 forever | 500 | No | No |
| Pro | 50 / week | 3,000 | Yes | Unlimited |
| Plus | 110 / week | 3,000 | Yes | Unlimited |
Quota resets on your billing-period rollover (Stripe-driven; the date is shown on the web account page). Failed requests are never billed - if the server returns status: "failed" or a 5xx error, your quota is automatically refunded.
What counts as a "rewrite"?
- The first rewrite within a thread (v1) counts.
- The first regenerate within a thread is free (one freebie per thread).
- Subsequent regenerates count.
- Cached idempotent replays (within 24h) do not count - same idempotency_key returns the existing job without re-billing.
Quota exhaustion
When you hit your weekly cap, the API returns:
{
"error": {
"code": "quota_exhausted",
"message": "Rewrite quota exhausted for this billing period",
"remaining": 0,
"plan_slug": "pro"
}
}
Wait for the next period rollover, or upgrade.
Endpoints
Rewrite
The core endpoint. Submit text + a preset_id (a voice), get the rewritten text back.
You can call it two ways:
| Mode | Endpoint | Behavior |
|---|---|---|
| Synchronous | POST /v1/rewrite |
Blocks until the rewrite is ready or failed. Best for scripts, CLI tools, server-to-server calls. |
| Asynchronous | POST /v1/rewrite/jobs |
Returns immediately with {id, status: "pending"}. Poll POST /v1/rewrite/jobs/get for completion. Best for web apps and long-running clients. |
Both modes share the same request body and produce the same job shape. The async path is also the recovery path for a dropped sync connection - see Recovering a dropped sync call.
POST /v1/rewrite (sync)
Submit a rewrite and wait for it to finish.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
preset_id |
string | yes | UUID of one of your custom presets, or a global slug (casual_dm, reddit_voice, x_voice, professional_voice). |
input_text |
string | yes | The text to rewrite. Pro/Plus cap: 3,000 chars. Trim long inputs before sending. |
thread_id |
string | no | UUID. Append a regenerate to an existing thread. The first regenerate per thread is free; subsequent regenerates are billed. |
opts |
object | no | Per-call overrides. See opts shape. |
references |
string[] | no | Up to 5 supporting snippets (each <=3,000 chars) folded into the source text before the rewrite. Counts toward your input character cap. |
idempotency_key |
string | no | [A-Za-z0-9_-]{1,128}. If you replay with the same key, you get the existing job back; no new bill. See Idempotency. |
opts shape
{
"tone_shift": "more terse",
"length_target": "~300 words",
"format_hint": "keep bullet structure"
}
| Field | Type | Notes |
|---|---|---|
tone_shift |
string | Free-form per-call hint, e.g. "more terse", "warmer". |
length_target |
string | Advisory length, e.g. "~300 words", "two paragraphs". |
format_hint |
string | Structural hint, e.g. "keep bullet structure", "single paragraph". |
All three fields are optional and independent.
Example request:
curl -X POST https://api.echowriting.ai/v1/rewrite \
-H "Authorization: Bearer $EWR_KEY" \
-H "Content-Type: application/json" \
-d '{
"preset_id": "casual_dm",
"input_text": "We are pleased to inform you that your order has shipped.",
"opts": {
"length_target": "one sentence"
}
}'
Response: 200 OK (even when the job ends in status: "failed" - the HTTP layer succeeded; the job state is on the body).
The response also carries X-Echo-Job-Id: <id> in the headers. The server flushes this header before starting the LLM work, so even if your connection drops mid-call, your HTTP client may have already received the recovery id. See Recovering a dropped sync call.
{
"id": "08c3a1b2-...",
"status": "ready",
"thread_id": "f3a17e09-...",
"version_no": 1,
"preset_id": null,
"preset_name_snapshot": "Casual DM",
"input_text": "We are pleased to inform you that your order has shipped.",
"output_text": "good news - your order's on the way",
"error": null,
"opts": {
"length_target": "one sentence"
},
"truncated": false,
"billed": true,
"source": "api",
"created_at": "2026-05-03T14:22:31.118Z",
"updated_at": "2026-05-03T14:22:35.402Z"
}
Job shape
This shape is returned by all four rewrite endpoints (sync, async-after-it-finishes, jobs/get, jobs/list).
| Field | Type | Notes |
|---|---|---|
id |
string | UUID. Unique per submission. Use this with jobs/get. |
status |
string | pending | running | ready | failed. |
thread_id |
string | UUID of the thread this version belongs to. |
version_no |
number | 1-indexed version within the thread. |
preset_id |
string|null | UUID of the user preset used, or null if a global slug was used. |
preset_name_snapshot |
string | The preset's display name at submit time. Survives subsequent renames or deletes of the preset. |
input_text |
string | The exact text submitted. |
output_text |
string|null | The rewritten text. null until status === "ready". |
error |
object|null | {code, message} when status === "failed". null otherwise. |
opts |
object|null | Echo of the post-sanitization opts you submitted (tone_shift, length_target, format_hint). null if you sent no opts. |
references |
string[]|null | Echo of the sanitized references you submitted (deduped, capped at 5 x 3,000 chars). null if none. |
truncated |
boolean | true if the model hit max_tokens before finishing. Increase length_target or split the input. |
billed |
boolean | true if a quota slot was consumed. false for the free retry path. |
source |
string | api for jobs submitted through this API. (web jobs are visible only when you opt into source: "all" in /list.) |
created_at |
string | ISO-8601 timestamp. |
updated_at |
string | ISO-8601 timestamp. Bumped on every state transition (pending → running → ready/failed). |
Errors:
| HTTP | code | When |
|---|---|---|
| 400 | validation_error |
Missing/malformed preset_id, input_text, thread_id, or idempotency_key. Carries field. |
| 401 | unauthenticated |
Missing or malformed Authorization header. |
| 401 | invalid_api_key |
Key not found or revoked. |
| 402 | quota_exhausted |
Out of weekly rewrites. Carries remaining, plan_slug. |
| 402 | input_too_long |
input_text exceeds your plan's input_char_cap. Carries char_count, char_cap. |
| 404 | preset_not_found |
preset_id doesn't exist or is not owned by you. |
| 404 | thread_not_found |
thread_id doesn't exist or is not owned by you. |
| 409 | preset_not_ready |
Preset is building or failed. Wait for ready. |
| 429 | rate_limited |
Carries Retry-After header. |
| 502 | humanizer_failed |
The internal humanizer pipeline failed three retries. Carries upstream_status. |
| 500 | internal_error |
Server-side bug. Quota is auto-refunded. |
POST /v1/rewrite/jobs (async)
Submit a rewrite and return immediately. Same request body as the sync path.
Response: 202 Accepted
{
"id": "08c3a1b2-...",
"status": "pending"
}
Then poll POST /v1/rewrite/jobs/get until status is ready or failed. Recommended polling cadence: every 1.5 - 3 seconds.
Idempotent hits. If your idempotency_key matches an existing job, the response is 200 OK with the full existing job shape (not 202).
Errors: identical to POST /v1/rewrite.
POST /v1/rewrite/jobs/get
Fetch the current state of a job by id.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string | yes | UUID. |
Example request:
curl -X POST https://api.echowriting.ai/v1/rewrite/jobs/get \
-H "Authorization: Bearer $EWR_KEY" \
-H "Content-Type: application/json" \
-d '{"id": "08c3a1b2-..."}'
Response: 200 OK with the job shape.
Errors:
| HTTP | code | When |
|---|---|---|
| 400 | validation_error |
id missing or not a UUID. |
| 401 | unauthenticated / invalid_api_key |
Auth failed. |
| 404 | job_not_found |
Job doesn't exist or is not owned by you. |
| 429 | rate_limited |
Polling too aggressively. |
POST /v1/rewrite/jobs/list
Paginated list of your jobs, newest first.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
status |
string | no | Filter by pending | running | ready | failed. |
thread_id |
string | no | UUID. Filter to one thread's jobs. |
source |
string | no | api (default) | web | all. API consumers see only API-originated jobs by default. |
before |
string | no | ISO-8601 cursor on created_at. Pass the created_at of the last job from the previous page. |
limit |
integer | no | 1-200. Default 50. |
Example request:
curl -X POST https://api.echowriting.ai/v1/rewrite/jobs/list \
-H "Authorization: Bearer $EWR_KEY" \
-H "Content-Type: application/json" \
-d '{"status": "ready", "limit": 50}'
Response: 200 OK
{
"jobs": [
{ "id": "08c3...", "status": "ready", "...": "..." },
{ "id": "0b7e...", "status": "ready", "...": "..." }
],
"next_cursor": "2026-05-02T10:18:55.000Z"
}
next_cursor is the ISO timestamp to pass as before to fetch the next page. null when no more pages.
Errors:
| HTTP | code | When |
|---|---|---|
| 400 | validation_error |
Bad before (not ISO 8601), bad thread_id, etc. |
| 401 | unauthenticated / invalid_api_key |
Auth failed. |
| 429 | rate_limited |
Too many calls. |
Threads
Every rewrite belongs to a thread. A thread groups one or more versions (re-runs of the same input). The first rewrite is v1; each regenerate via thread_id is v2, v3, etc. The web app shows threads in a sidebar; the API mirrors that history.
POST /v1/threads/list
Paginated list of your threads, most-recently-active first.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
before |
string | no | ISO-8601 cursor on updated_at. Pass the updated_at of the last thread shown. |
limit |
integer | no | 1-200. Default 50. |
Response: 200 OK
{
"threads": [
{
"id": "f3a17e09-...",
"title": "We are pleased to inform you that your order has shipped.",
"updated_at": "2026-05-03T14:22:35.402Z"
}
],
"next_cursor": "2026-05-02T10:18:55.000Z"
}
The title is auto-derived from the first version's input (first non-empty line, <=60 chars on a word boundary). Frozen - does not change as you regenerate.
POST /v1/threads/search
Full-text search across your thread history. One row per matching thread.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
q |
string | yes | Search query, <=200 chars. Postgres websearch_to_tsquery syntax. |
limit |
integer | no | 1-100. Default 30. |
offset |
integer | no | 0-1000. Default 0. |
Response: 200 OK
{
"threads": [
{
"thread_id": "f3a17e09-...",
"title": "We are pleased to inform you that your order has shipped.",
"preset_name_snapshot": "Casual DM",
"created_at": "2026-05-03T14:22:31.118Z",
"updated_at": "2026-05-03T14:22:35.402Z",
"rank": 0.0987,
"snippet": "We are pleased to inform you that your <mark>order</mark> has shipped."
}
],
"limit": 30,
"offset": 0,
"next_offset": null
}
snippet includes <mark> tags around matched terms. Failed/pending versions are excluded.
POST /v1/threads/get
Fetch a thread plus every version (v1..vN).
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string | yes | UUID. |
Response: 200 OK
{
"id": "f3a17e09-...",
"title": "We are pleased to inform you that your order has shipped.",
"free_retry_used": true,
"created_at": "2026-05-03T14:22:31.118Z",
"updated_at": "2026-05-03T14:25:18.000Z",
"versions": [
{
"id": "08c3...",
"version_no": 1,
"preset_id": null,
"preset_name_snapshot": "Casual DM",
"input_text": "We are pleased to inform you that your order has shipped.",
"output_text": "good news - your order's on the way",
"opts": null,
"truncated": false,
"billed": true,
"status": "ready",
"source": "api",
"error": null,
"created_at": "2026-05-03T14:22:31.118Z"
}
]
}
free_retry_used indicates whether the thread's one-time free regenerate has been consumed.
POST /v1/threads/delete
Hard-delete a thread. All its versions cascade.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string | yes | UUID. |
Response: 200 OK
{ "deleted": true, "id": "f3a17e09-..." }
Deleted threads are unrecoverable. The associated usage_events (billing audit) survive.
Presets
A preset is a voice profile. You can either use one of the read-only global presets (casual_dm, reddit_voice, x_voice, professional_voice) or create your own custom voice from 3-5 writing samples.
Custom preset creation is a paid feature. Free users can only use global presets.
A new custom preset takes 5-30 seconds to build. Submit /v1/presets/create, then poll /v1/presets/get until status: "ready".
POST /v1/presets/list
Returns all presets visible to you: global presets plus your own custom ones.
Request body: {}
Response: 200 OK
{
"presets": [
{
"id": "casual_dm",
"name": "Casual DM",
"status": "ready",
"source": "global",
"display_order": 1,
"created_at": "2026-04-01T00:00:00Z",
"updated_at": "2026-04-01T00:00:00Z"
},
{
"id": "<user-preset-uuid>",
"name": "My Voice",
"status": "ready",
"source": "user",
"error": null,
"created_at": "2026-05-01T09:00:00Z",
"updated_at": "2026-05-01T09:01:13Z"
}
]
}
Globals appear first (curated display_order), then user presets newest-first.
POST /v1/presets/get
Fetch a single preset's details.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string | yes | UUID or global slug. |
Response: 200 OK
{
"id": "<preset-uuid>",
"name": "My Voice",
"status": "ready",
"source": "user",
"style_card_md": "# Voice profile\n\n- ...",
"error": null,
"created_at": "2026-05-01T09:00:00Z",
"updated_at": "2026-05-01T09:01:13Z"
}
style_card_md is the human-readable Markdown summary of the trained voice (only present when status === "ready"). error is non-null when status === "failed".
POST /v1/presets/create
Train a new custom preset from 3-5 writing samples.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | Display name for the preset. |
samples |
string[] | yes | 3-5 writing samples. Each <=3,000 chars (trimmed server-side). |
brief |
string | no | Optional style description, <=500 chars. |
Response: 202 Accepted
{ "id": "<new-preset-uuid>", "status": "building" }
The build runs asynchronously. Poll /v1/presets/get until status === "ready" (5-30 s typical) or failed.
Errors:
| HTTP | code | When |
|---|---|---|
| 400 | validation_error |
Bad name, samples count outside 3-5, brief too long, samples become empty after sanitize. |
| 402 | preset_creation_blocked |
Free plan; upgrade required. Carries plan_slug. |
POST /v1/presets/update
Rename a preset, or replace its samples / brief (which triggers a rebuild). Either operation can be done independently.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string | yes | UUID. Global slugs are rejected with 403. |
name |
string | one of name/samples/brief required | New display name. |
samples |
string[] | one of name/samples/brief required | 3-5 writing samples. Triggers rebuild. |
brief |
string | null | one of name/samples/brief required | New style brief. null clears the existing brief. Triggers rebuild. |
Response:
- Pure rename:
200 OK { "id": "...", "status": "ok" } - Sample/brief change (triggers rebuild):
202 Accepted { "id": "...", "status": "building" }
Errors:
| HTTP | code | When |
|---|---|---|
| 400 | validation_error |
No fields provided, or fields fail validation. |
| 402 | preset_creation_blocked |
Free plan and the request would trigger a rebuild. |
| 403 | global_preset_immutable |
id is a global slug. |
| 404 | preset_not_found |
Not owned or doesn't exist. |
| 409 | preset_building |
A build is already in flight for this preset; wait and retry. |
POST /v1/presets/rebuild
Re-run the build pipeline against the existing samples (e.g. after a model upgrade, or to recover from a failed row).
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string | yes | UUID. |
Response: 202 Accepted with { id, status: "building" }.
Errors: as for /v1/presets/update.
POST /v1/presets/delete
Hard-delete a custom preset.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string | yes | UUID. |
Response: 200 OK { "deleted": true, "id": "<uuid>" }.
rewrite_versions.preset_id for past rewrites becomes NULL (history preserved via preset_name_snapshot). Global presets are non-deletable - the API returns 403 global_preset_immutable.
Idempotency
All mutating rewrite endpoints accept an optional idempotency_key body field. If the same key is replayed within 24h, the original job is returned without re-running the LLM, without re-billing, and without inserting a new row.
- Keys must match
[A-Za-z0-9_-]{1,128}. - The key is scoped to your account - no collisions between users.
- Two parallel requests with the same key race-resolve safely: one wins the insert, the other is refunded automatically and returns the winner's job.
- Replays after 24h still return the cached job (the underlying unique index isn't time-bounded), but new replays beyond that window aren't guaranteed to dedupe - a future cleanup may invalidate them.
Recommended pattern for any client retrying on a network error:
const key = crypto.randomUUID();
const submit = () =>
fetch('https://api.echowriting.ai/v1/rewrite', {
method: 'POST',
headers: { Authorization: `Bearer ${EWR_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ preset_id, input_text, idempotency_key: key }),
});
// Retry up to 3 times - same key, no double-billing.
for (let i = 0; i < 3; i++) {
try {
return await submit();
} catch (e) {
if (i === 2) throw e;
await sleep(1000 * (i + 1));
}
}
Rate limits
Limits are per user, shared across all of your API keys.
| Bucket | Limit | Applies to |
|---|---|---|
| Submit | 60 / minute | POST /v1/rewrite, /v1/rewrite/jobs, /v1/presets/{create,update,rebuild,delete}, /v1/threads/delete |
| Read | 600 / minute | /v1/rewrite/jobs/{get,list}, /v1/threads/{list,search,get}, /v1/presets/{list,get} |
Every response includes the standard headers:
| Header | Meaning |
|---|---|
RateLimit-Limit |
The configured limit for the bucket. |
RateLimit-Remaining |
Calls left in the current window. |
RateLimit-Reset |
Seconds until the window resets. |
Retry-After |
(On 429s only) seconds to wait before retrying. |
These headers are exposed via CORS for browser-based SDKs.
When you hit a limit:
{
"error": {
"code": "rate_limited",
"message": "Too many requests"
}
}
with 429 Too Many Requests. Read the Retry-After header and wait.
Errors
Every error response shares one envelope:
{
"error": {
"code": "snake_case_constant",
"message": "human readable description",
"...": "extra fields specific to the code"
}
}
Code reference
| HTTP | code | Meaning | Extra fields |
|---|---|---|---|
| 400 | validation_error |
Body shape problems. | field |
| 401 | unauthenticated |
Missing/malformed Authorization header. |
- |
| 401 | invalid_api_key |
Key not found, revoked, or shape doesn't match ewr_live_*. |
- |
| 402 | quota_exhausted |
Weekly rewrite quota used up. | remaining, plan_slug |
| 402 | input_too_long |
input_text exceeds plan cap. |
char_count, char_cap, plan_slug |
| 402 | preset_creation_blocked |
Free user trying to create or rebuild a custom preset. | plan_slug |
| 402 | api_keys_paid_only |
(Web-only - irrelevant to the API.) | plan_slug |
| 403 | global_preset_immutable |
Attempted to mutate a global preset slug. | - |
| 404 | job_not_found |
Job id doesn't exist or is not owned by you. | - |
| 404 | thread_not_found |
Thread id doesn't exist or is not owned by you. | - |
| 404 | preset_not_found |
Preset id/slug doesn't exist or is not owned by you. | - |
| 409 | preset_not_ready |
Trying to rewrite against a preset that is building or failed. |
- |
| 409 | preset_building |
Trying to update/rebuild a preset that is already mid-build. | - |
| 429 | rate_limited |
Rate limit hit. Carries Retry-After header. |
- |
| 502 | humanizer_failed |
Internal humanizer pipeline failed three retries. Quota auto-refunded. | upstream_status |
| 500 | internal_error |
Server-side bug. Quota auto-refunded. | - |
Failures inside async jobs
If a job fails inside the LLM pipeline (humanizer down, model timeout, etc.), the HTTP layer still returns 200 OK. The failure is on the job, not the request. Always check status, not the HTTP code:
{
"id": "08c3...",
"status": "failed",
"error": {
"code": "humanizer_failed",
"message": "humanizer upstream 503: ..."
},
"...": "..."
}
Your weekly quota is automatically refunded for failed jobs. You don't pay for failures.
failed job error codes
| code | Meaning |
|---|---|
humanizer_failed |
Humanizer upstream failed three retries. Carries upstream_status in message. |
aborted |
The request was aborted (rare; usually a sweeper-finalized stuck job). |
stuck_running |
Background sweeper finalized a job that was running >10 min without finishing. |
internal_error |
Unclassified server-side error. |
Recovering a dropped sync call
The sync endpoint sets X-Echo-Job-Id: <id> in the response headers and flushes them before the LLM work begins. If your connection drops mid-call, your HTTP client may already have the recovery id.
// Pseudo-code
const res = await fetch("https://api.echowriting.ai/v1/rewrite", { ... });
const jobId = res.headers.get("X-Echo-Job-Id");
try {
return await res.json();
} catch (timeoutOrAbort) {
// Connection dropped mid-LLM. Use the saved id to recover.
for (let i = 0; i < 60; i++) {
await sleep(2000);
const job = await fetchJob(jobId);
if (job.status === "ready" || job.status === "failed") return job;
}
throw new Error("recovery polling timed out");
}
If your HTTP client doesn't expose response headers for a timed-out request, prefer the async pattern (POST /v1/rewrite/jobs) from the start - you always get the id back in the response body.
Versioning
/v1 is stable. Backwards-compatible changes ship in place:
- New optional fields on requests and responses.
- New endpoints under
/v1/. - New optional headers.
Any breaking change creates /v2/; /v1 remains supported until announced sunset (minimum 12 months of dual-running).
Changelog
2026-05-03 - initial release
- All
/v1/rewrite/*,/v1/threads/*,/v1/presets/*endpoints. - Bearer-token auth via
Authorizationheader. - Idempotency keys with 24h dedupe window.
X-Echo-Job-Idrecovery header on sync rewrites.