API Reference

EchoWriting API Reference

Rewrite text in your own voice - programmatically.

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 Authorization header.
  • Idempotency keys with 24h dedupe window.
  • X-Echo-Job-Id recovery header on sync rewrites.