Examples
Complete Node.js and Python clients for the ReWords.AI API, and every response you will meet — OCR, a queued, processing, succeeded, partly failed and failed edit, and each error.
A complete client
Both scripts upload a local file, wait for the job with the polling rule, honour
Retry-After, resend after a network error or 5xx with the same Idempotency-Key (so a job is never created or
charged twice), save every result that succeeded, and show the text found in the image when from does not match.
import fs from 'node:fs/promises';
const BASE = 'https://api.rewords.ai/v1';
const KEY = process.env.REWORDS_API_KEY;
const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
async function call(method, path, body, headers = {}) {
for (let attempt = 1; ; attempt++) {
let res;
try {
res = await fetch(BASE + path, {
method,
headers: { Authorization: `Bearer ${KEY}`, ...(body ? { 'Content-Type': 'application/json' } : {}), ...headers },
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(90_000),
});
} catch (e) { // network error or timeout: safe to resend,
if (attempt >= 5) throw e; // the Idempotency-Key prevents a second job
await sleep(2 ** attempt);
continue;
}
if (res.status === 429 || res.status === 503) { // rate_limited or queue_full: wait and retry
await sleep(Number(res.headers.get('Retry-After') ?? 30));
continue;
}
if (res.status >= 500 && attempt < 5) { await sleep(2 ** attempt); continue; }
const data = await res.json().catch(() => ({ error: { code: 'http_' + res.status, message: res.statusText } }));
if (!res.ok) throw Object.assign(new Error(data.error.message), { status: res.status, error: data.error });
return data;
}
}
async function editImage(file, replacements, idempotencyKey) {
const image_base64 = (await fs.readFile(file)).toString('base64');
let job = await call('POST', '/edits', { image_base64, replacements }, { 'Idempotency-Key': idempotencyKey });
while (job.status === 'queued' || job.status === 'processing') {
const waitingBehindOthers = job.status === 'queued' && job.queue_position > 0;
await sleep(waitingBehindOthers ? 30 : 5);
job = await call('GET', `/edits/${job.id}`);
}
if (job.status === 'failed') throw Object.assign(new Error(job.error.message), { error: job.error });
for (const [name, r] of Object.entries(job.results)) {
if (r.status !== 'succeeded') { console.log(`${name}: ${r.status} (${r.reason})`); continue; }
const file = await fetch(r.url);
if (!file.ok) throw new Error(`Downloading ${name} failed: HTTP ${file.status}`);
await fs.writeFile(`${job.id}_${name}.jpg`, Buffer.from(await file.arrayBuffer()));
console.log(`${name}: saved ${job.id}_${name}.jpg`);
}
console.log(`charged ${job.charged}, ${job.balance} edits left`);
return job;
}
try {
await editImage(process.argv[2], [{ from: 'SALE', to: 'HOT DEAL' }], `banner-${Date.now()}`);
} catch (e) {
if (e.error?.code === 'text_not_found') console.error('Not found. Text in the image:', e.error.details.texts.map((t) => t.text));
else if (e.status) console.error(`HTTP ${e.status}`, e.error.code, e.message);
else if (e.error) console.error('job failed:', e.error.code, e.message);
else console.error('network error:', e.message);
process.exitCode = 1;
}import base64, os, sys, time
from typing import Optional
import requests
BASE = "https://api.rewords.ai/v1"
KEY = os.environ["REWORDS_API_KEY"]
class ReWordsError(Exception):
"""status is the HTTP status for a rejected request, or None for a job that failed while processing."""
def __init__(self, status: Optional[int], error: dict):
super().__init__(error["message"])
self.status, self.code, self.details = status, error["code"], error.get("details", {})
def call(method, path, body=None, headers=None):
attempt = 0
while True:
attempt += 1
try:
res = requests.request(method, BASE + path, json=body, timeout=90,
headers={"Authorization": f"Bearer {KEY}", **(headers or {})})
except requests.RequestException: # network error or timeout: safe to resend,
if attempt >= 5: # the Idempotency-Key prevents a second job
raise
time.sleep(2 ** attempt)
continue
if res.status_code in (429, 503): # rate_limited or queue_full: wait and retry
time.sleep(int(res.headers.get("Retry-After", 30)))
continue
if res.status_code >= 500 and attempt < 5:
time.sleep(2 ** attempt)
continue
try:
data = res.json()
except ValueError:
data = {"error": {"code": f"http_{res.status_code}", "message": res.reason}}
if not res.ok:
raise ReWordsError(res.status_code, data["error"])
return data
def edit_image(path, replacements, idempotency_key):
with open(path, "rb") as f:
image_base64 = base64.b64encode(f.read()).decode()
job = call("POST", "/edits", {"image_base64": image_base64, "replacements": replacements},
{"Idempotency-Key": idempotency_key})
while job["status"] in ("queued", "processing"):
waiting_behind_others = job["status"] == "queued" and job.get("queue_position", 0) > 0
time.sleep(30 if waiting_behind_others else 5)
job = call("GET", f"/edits/{job['id']}")
if job["status"] == "failed":
raise ReWordsError(None, job["error"])
for name, r in job["results"].items():
if r["status"] != "succeeded":
print(f"{name}: {r['status']} ({r['reason']})")
continue
file = requests.get(r["url"], timeout=60)
file.raise_for_status()
with open(f"{job['id']}_{name}.jpg", "wb") as out:
out.write(file.content)
print(f"{name}: saved {job['id']}_{name}.jpg")
print(f"charged {job['charged']}, {job['balance']} edits left")
return job
if __name__ == "__main__":
try:
edit_image(sys.argv[1], [{"from": "SALE", "to": "HOT DEAL"}], f"banner-{int(time.time())}")
except ReWordsError as e:
if e.code == "text_not_found":
print("Not found. Text in the image:", [t["text"] for t in e.details["texts"]])
else:
print(f"HTTP {e.status}" if e.status else "job failed:", e.code, e)
sys.exit(1)
except requests.RequestException as e:
print("network error:", e)
sys.exit(1)REWORDS_API_KEY=rw_live_your_key node edit-image.mjs banner.png
REWORDS_API_KEY=rw_live_your_key python edit_image.py banner.pngA: saved edit_68805a1cefad_A.jpg
B: saved edit_68805a1cefad_B.jpg
C: saved edit_68805a1cefad_C.jpg
charged 1, 59 edits leftEvery response, step by step
OCR — POST /ocr → 200
{
"image_id": "img_118ba6012ee7",
"width": 2560,
"height": 1302,
"texts": [
{ "id": "t1", "text": "SALE", "box": [120, 80, 300, 60] },
{ "id": "t2", "text": "50% OFF", "box": [140, 160, 260, 50] },
{ "id": "t3", "text": "Summer Collection", "box": [100, 700, 800, 80] }
],
"charged_ocr": 1
}The same image again within 24 hours returns a new image_id and "charged_ocr": 0. An image with no text returns
"texts": [] and is still charged.
Submit — POST /edits → 202
curl https://api.rewords.ai/v1/edits \
-H "Authorization: Bearer $REWORDS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"image_id": "img_118ba6012ee7",
"replacements": [{"from": "SALE", "to": "HOT DEAL", "box": [120, 80, 300, 60]},
{"from": "50% OFF", "to": "70% OFF"}]}'{
"id": "edit_68805a1cefad",
"status": "queued",
"queue_position": 0,
"estimated_seconds": 120,
"replacements": [
{ "from": "SALE", "to": "HOT DEAL", "box": [120, 80, 300, 60] },
{ "from": "50% OFF", "to": "70% OFF", "box": [140, 160, 260, 50] }
],
"balance": 59,
"created_at": "2026-11-04T13:55:33.240Z"
}replacements shows the box we found for 50% OFF. balance already has this job's edit reserved.
Poll — GET /edits/{id} → 200, while running
{
"id": "edit_68805a1cefad",
"status": "processing",
"replacements": [
{ "from": "SALE", "to": "HOT DEAL", "box": [120, 80, 300, 60] },
{ "from": "50% OFF", "to": "70% OFF", "box": [140, 160, 260, 50] }
],
"balance": 59,
"created_at": "2026-11-04T13:55:33.240Z"
}Succeeded
{
"id": "edit_68805a1cefad",
"status": "succeeded",
"results": {
"A": { "status": "succeeded", "url": "https://img.rewords.ai/api/edit_68805a1cefad_a.jpg" },
"B": { "status": "succeeded", "url": "https://img.rewords.ai/api/edit_68805a1cefad_b.jpg" },
"C": { "status": "succeeded", "url": "https://img.rewords.ai/api/edit_68805a1cefad_c.jpg" }
},
"replacements": [
{ "from": "SALE", "to": "HOT DEAL", "box": [120, 80, 300, 60] },
{ "from": "50% OFF", "to": "70% OFF", "box": [140, 160, 260, 50] }
],
"charged": 1,
"balance": 59,
"created_at": "2026-11-04T13:55:33.240Z",
"completed_at": "2026-11-04T13:55:38.350Z"
}Succeeded, but one result failed — still charged
Only one of A and B needs to succeed. Here A failed, so use B:
{
"id": "edit_9e88b77efd21",
"status": "succeeded",
"results": {
"A": { "status": "failed", "reason": "ai_generation_failed" },
"B": { "status": "succeeded", "url": "https://img.rewords.ai/api/edit_9e88b77efd21_b.jpg" },
"C": { "status": "skipped", "reason": "aspect_ratio_unsupported" }
},
"replacements": [{ "from": "SALE", "to": "HOT DEAL", "box": [120, 80, 300, 60] }],
"charged": 1,
"balance": 58,
"created_at": "2026-11-04T13:55:38.693Z",
"completed_at": "2026-11-04T13:55:43.766Z"
}Failed — not charged
{
"id": "edit_369c949cd4ed",
"status": "failed",
"results": {
"A": { "status": "failed", "reason": "ai_generation_failed" },
"B": { "status": "failed", "reason": "ai_generation_failed" },
"C": { "status": "skipped", "reason": "ai_generation_failed" }
},
"replacements": [{ "from": "SALE", "to": "HOT DEAL", "box": [120, 80, 300, 60] }],
"charged": 0,
"balance": 58,
"error": { "code": "ai_generation_failed", "message": "The AI model could not produce a usable image.", "details": {} },
"created_at": "2026-11-04T13:55:38.691Z",
"completed_at": "2026-11-04T13:55:43.693Z"
}To try again, submit with a new Idempotency-Key — the old one returns this failed job.
Errors when you submit
None of these create a job or charge anything.
422 text_not_found — a from without box matched nothing. details.texts is what OCR found, so you can pick a box:
{
"error": {
"code": "text_not_found",
"message": "No text matched \"WINTER\".",
"details": {
"unmatched": ["WINTER"],
"texts": [
{ "id": "t1", "text": "SALE", "box": [120, 80, 300, 60] },
{ "id": "t2", "text": "50% OFF", "box": [140, 160, 260, 50] },
{ "id": "t3", "text": "Summer Collection", "box": [100, 700, 800, 80] }
]
}
}
}400 invalid_image — not a JPEG, PNG or WebP, too large, or the URL could not be downloaded:
{ "error": { "code": "invalid_image", "message": "Unsupported format. Send JPEG, PNG or WebP.", "details": {} } }400 invalid_request — for example two image inputs at once:
{ "error": { "code": "invalid_request", "message": "Send exactly one image input; got image_id, image_base64.", "details": {} } }402 insufficient_balance (or insufficient_ocr_balance on POST /ocr):
{ "error": { "code": "insufficient_balance", "message": "No edits left. Buy an edit pack.", "details": {} } }429 rate_limited and 503 queue_full come with a Retry-After header in whole seconds:
HTTP/1.1 429 Too Many Requests
Retry-After: 12
Content-Type: application/json
{ "error": { "code": "rate_limited", "message": "Too many requests. Retry in 12 seconds.", "details": {} } }404 not_found — the edit id is wrong or belongs to another account:
{ "error": { "code": "not_found", "message": "No edit with this id.", "details": {} } }Account — GET /account → 200
{
"edits_remaining": 58,
"ocr_remaining": 68,
"packs": [
{ "kind": "edits", "remaining": 58, "expires_at": "2027-11-04T12:05:36.775Z" },
{ "kind": "ocr", "remaining": 68, "expires_at": "2027-11-04T12:05:36.775Z" }
],
"limits": { "requests_per_minute": 30, "read_requests_per_minute": 240, "concurrent_jobs": 10, "max_queued_jobs": 30 }
}