# Erased — full LLM context > Strip (and detect) AI watermarks from images. Web UI + REST API. Bilingual EN/ZH. Erased is a SaaS hosted at https://erased.ink. It removes: - **Visible AI watermarks** — overlay marks like Gemini's sparkle icon. - **AI-marker metadata** — C2PA Content Credentials, PNG text chunks (e.g. `parameters`), EXIF/XMP AI tags. - **Invisible statistical signals** — SynthID, TreeRing, StableSignature embedded in pixel data. A separate **`detect`** mode is read-only: it scans an image and returns a JSON report of every AI trace it can identify, but never writes a modified file. The web UI is at `/{en|zh}/app`. The same REST API powers the UI and external integrations. Documentation pages mirror this file at `/{en|zh}/docs/...`. --- ## Quickstart There are four entry points to send an image to Erased. Pick whichever fits your stack. ### ① multipart (recommended, one HTTP call) ```bash curl -X POST https://erased.ink/api/v1/jobs \ -F "image=@photo.png" \ -F "mode=visible" # → { "id":"job_xxx", "status":"succeeded", "mode":"visible", "result_url":"https://...", "processing_ms":412 } ``` Limits: image ≤ 10 MB. The Worker uploads to R2 on your behalf, then submits the job — same response shape as the JSON path. ### ② JSON + inline base64 (≤1 MB decoded) ```bash curl -X POST https://erased.ink/api/v1/jobs \ -H 'content-type: application/json' \ -d '{ "image_base64":"iVBORw0KGgo...", "content_type":"image/png", "mode":"visible" }' ``` Decoded size cap is **1 MB** (~1.34 MB of base64 text). For larger files use multipart or presigned upload. ### ③ GET /api/v1/clean (URL input, image output) Single sync call whose **response body is the processed image**. Works in `` and CDN-cacheable. ```bash # curl: save the cleaned image curl 'https://erased.ink/api/v1/clean?image=https%3A%2F%2Fexample.com%2Fphoto.png&mode=visible' -o clean.png # HTML / Markdown embed ``` - Modes: `visible`, `metadata`, `invisible`, `all` (the async ones may time out on the client). `detect` is rejected (returns JSON, not an image). - Auth: **anonymous / Free only**. Pro Bearer tokens are rejected with 403 + `pro-not-supported` — Pro users should go through `POST /jobs` instead. - Errors: 4xx/5xx responses carry a placeholder PNG body; the real error type is in the `X-Error-Type` header. ### ④ Presigned upload (large files / batch) For files >10MB-tail or batch ingestion, request a presigned R2 PUT URL, upload directly to R2 (bypasses the Worker), then submit the job by `object_key`: ```bash # 1. Ask for a presigned upload URL curl -X POST https://erased.ink/api/v1/uploads \ -H 'content-type: application/json' \ -d '{"filename":"photo.png","content_type":"image/png","size":4823109}' # → { "upload_url":"https://...","object_key":"in/2026-05-24/job_xxx.png","expires_at":"..." } # 2. PUT the file to R2 directly curl -X PUT "" --data-binary @photo.png -H 'content-type: image/png' # 3. Submit a job by object_key curl -X POST https://erased.ink/api/v1/jobs \ -H 'content-type: application/json' \ -d '{"object_key":"in/2026-05-24/job_xxx.png","mode":"visible"}' ``` For **sync** modes (`visible`, `metadata`, `detect`) the response already contains the result. For **async** modes (`invisible`, `all`) the response contains a job `id` and a `poll_url` — poll `GET /api/v1/jobs/:id` every `poll_after_ms` until `status` is `succeeded` or `failed`. Result URLs are presigned R2 GET URLs that auto-expire after 24 hours. --- ## API reference **Base URL:** `https://erased.ink/api/v1` All successful responses are `application/json`. Errors are `application/problem+json` (RFC 7807) — see the Error codes section. ### POST /uploads Issue a short-lived presigned R2 PUT URL. **Request body** ```json { "filename": "photo.png", "content_type": "image/png", "size": 4823109 } ``` | Field | Type | Notes | |---|---|---| | `filename` | string | Used for headers only; the server assigns the `object_key`. | | `content_type` | string | One of `image/png`, `image/jpeg`, `image/webp`, `image/avif`, `image/heic`, `image/heif`, `image/jxl`. | | `size` | integer | Max 10485760 (10 MB). | **Response 200** ```json { "upload_url": "https://...", "object_key": "in/2026-05-24/job_xxx.png", "expires_at": "2026-05-24T10:15:00Z" } ``` You must PUT the file to `upload_url` before `expires_at`. ### POST /jobs Create a processing job. Three body shapes; pick by Content-Type. **Shape A — `multipart/form-data`** (recommended) | Field | Required | Notes | |---|---|---| | `image` | one-of with `object_key` | File upload, ≤ 10 MB, content-type must be one of the SUPPORTED set. | | `object_key` | one-of with `image` | Reuse a prior presigned upload. | | `mode` | yes | `visible` / `metadata` / `invisible` / `all` / `detect` | | `options` | optional | JSON string passed through to the processing backend | **Shape B — `application/json` with inline base64** ```json { "image_base64": "iVBORw0KGgo...", "content_type": "image/png", "mode": "visible", "options": {} } ``` | Field | Required | Notes | |---|---|---| | `image_base64` | one-of with `object_key` | Raw bytes base64-encoded (no `data:` prefix), decoded size ≤ 1 MB. | | `content_type` | yes (with `image_base64`) | Must be in SUPPORTED set. | | `object_key` | one-of | As above. | | `mode` | yes | as above | | `options` | optional | as above | **Shape C — `application/json` with `object_key`** (paired with `POST /uploads`) ```json { "object_key": "in/2026-05-24/job_xxx.png", "mode": "visible" } ``` Same response shapes regardless of body — sync/async/detect determined by the `mode` (see below). **Response — sync (`visible`, `metadata`)** ```json { "id": "job_xxx", "status": "succeeded", "mode": "visible", "result_url": "https://...", "result_expires_at": "2026-05-25T10:00:00Z", "processing_ms": 412 } ``` **Response — async (`invisible`, `all`)** ```json { "id": "job_xxx", "status": "queued", "mode": "invisible", "poll_url": "/api/v1/jobs/job_xxx", "poll_after_ms": 2000 } ``` **Response — `detect`** (read-only, no `result_url`) ```json { "id": "job_xxx", "status": "succeeded", "mode": "detect", "processing_ms": 487, "report": { "visible": { "detected": true, "confidence": 0.87, "region": [1820, 120, 80, 80], "label": "Gemini sparkle" }, "metadata": { "c2pa": { "present": true, "manifest": { "claim_generator": "Gemini ..." } }, "exif_ai_tags": { "Software": "Gemini" }, "png_text_chunks": null, "xmp_digital_source_type": null }, "invisible_external": { "synthid": { "status": "unsupported_locally", "verify_url": "https://support.google.com/gemini/answer/16722517" } }, "overall_assessment": "Likely AI-generated by Gemini. C2PA Content Credentials present." } } ``` `detect` never writes an output object; the original image is unchanged. ### GET /jobs/:id Poll an async job (and a valid read for sync jobs while their record is still in KV, ~30 min TTL). **Response — still processing** ```json { "id": "job_xxx", "status": "processing", "mode": "invisible", "poll_after_ms": 2000 } ``` **Response — succeeded** Same shape as the sync success response. **Response — failed** ```json { "id": "job_xxx", "status": "failed", "mode": "invisible", "error_code": "pipeline_error", "error_message": "..." } ``` ### GET /clean Synchronous URL-input endpoint whose response body is the processed image (not JSON). Designed for HTML/Markdown embeds and CDN caching. ``` GET /api/v1/clean?image=&mode=visible ``` | Query | Required | Notes | |---|---|---| | `image` | yes | URL-encoded `https://` URL. Private IP ranges blocked. | | `mode` | no (default `visible`) | `visible` / `metadata` / `invisible` / `all`. `detect` rejected. | | `options` | no | URL-encoded JSON | **Success response:** ``` 200 OK Content-Type: image/png Cache-Control: public, max-age=86400 ``` **Error response:** 4xx/5xx + a placeholder PNG body (so `` doesn't show a broken icon). The real error type is in the `X-Error-Type` header. **Constraints:** - Image input ≤ 10 MB (HEAD-checked before fetch). - Pro Bearer tokens are **rejected** with 403 + `X-Error-Type: https://erased.ink/errors/pro-not-supported` — Pro users should go through `POST /jobs` instead. Anonymous and Free callers only. - Async modes (`invisible`, `all`) will likely time out client-side; consider `POST /jobs` for those. ### Rate limits | Quota group | Modes | Anonymous | Free | Pro | |---|---|---|---|---| | `light` | visible, metadata | 30 / h | 100 / h | 5000 / month | | `heavy` | invisible, all | 5 / h | 15 / h | 500 / month | | `uploads` | POST /uploads (presigned flow only — multipart/base64/clean do NOT consume this bucket) | 60 / h | 200 / h | unlimited | | `detect` | detect | 100 / h | 500 / h | unlimited | `heavy` (invisible/all) also has a **per-day cap of 12/day** for Anonymous and Free, on top of the hourly limit — Anonymous/Free keyed by ip+fp, logged-in users by account id (IP/VPN-proof); denial's retry window runs to the next UTC midnight. Pro is governed by its monthly quota. Exceeding the limit returns `429` with a `Retry-After` header and an RFC 7807 body. **Failed jobs automatically refund** the quota charge. --- ## Error codes All errors use [RFC 7807](https://datatracker.ietf.org/doc/html/rfc7807) (`application/problem+json`): ```json { "type": "https://erased.ink/errors/rate-limited", "title": "Too Many Requests", "status": 429, "detail": "Free tier limit reached: 30/hour for visible. Retry after the indicated interval.", "retry_after": 3600 } ``` | Status | `type` URI | When | |---|---|---| | 400 | `https://erased.ink/errors/validation` | Body fails schema validation. | | 400 | `https://erased.ink/errors/unsupported-format` | `content_type` not in the supported list. | | 413 | `https://erased.ink/errors/too-large` | `size` > 10 MB. | | 401 | `https://erased.ink/errors/unauthorized` | API key missing or invalid (Pro endpoints). | | 402 | `https://erased.ink/errors/upgrade-required` | Feature requires a paid plan. | | 402 | `https://erased.ink/errors/insufficient-credits` | Credit Pack balance insufficient for the requested mode. | | 404 | `https://erased.ink/errors/not-found` | Job id unknown or expired (>30 min). | | 405 | `https://erased.ink/errors/method-not-allowed` | Wrong HTTP method on a known route. | | 429 | `https://erased.ink/errors/rate-limited` | Hourly or monthly quota hit. `retry_after` is in seconds. | | 500 | `https://erased.ink/errors/server-error` | Unhandled internal error. | | 502 | `https://erased.ink/errors/upstream` | An upstream service returned an error. | | 504 | `https://erased.ink/errors/upstream-timeout` | An upstream service exceeded its deadline. | | 400 | `https://erased.ink/errors/url-not-fetchable` | /clean: URL not https / private IP / DNS fail / 4xx. | | 413 | `https://erased.ink/errors/url-too-large` | /clean: HEAD Content-Length > 10 MB. | | 403 | `https://erased.ink/errors/pro-not-supported` | /clean: caller authenticated as Pro; use POST /jobs instead. | --- ## Examples ### Detect mode (anonymous, no auth needed) ```bash # 1. Presign curl -X POST https://erased.ink/api/v1/uploads \ -H 'content-type: application/json' \ -d '{"filename":"test.png","content_type":"image/png","size":12345}' # → {"upload_url":"...","object_key":"in/...","expires_at":"..."} # 2. Upload curl -X PUT "" --data-binary @test.png \ -H 'content-type: image/png' # 3. Detect curl -X POST https://erased.ink/api/v1/jobs \ -H 'content-type: application/json' \ -d '{"object_key":"in/...","mode":"detect"}' # → {"id":"...","status":"succeeded","mode":"detect","report":{...}} ``` ### Pro API key for heavy modes ```bash curl -X POST https://erased.ink/api/v1/jobs \ -H 'authorization: Bearer uk_...' \ -H 'content-type: application/json' \ -d '{"object_key":"in/...","mode":"invisible"}' # → {"id":"...","status":"queued","poll_url":"/api/v1/jobs/...","poll_after_ms":2000} # Poll curl -H 'authorization: Bearer uk_...' \ https://erased.ink/api/v1/jobs/ # → eventually {"id":"...","status":"succeeded","mode":"invisible","result_url":"...","processing_ms":78421} ``` ### JavaScript (browser) ```js async function eraseVisible(file) { const presign = await fetch("/api/v1/uploads", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ filename: file.name, content_type: file.type, size: file.size, }), }).then((r) => r.json()); await fetch(presign.upload_url, { method: "PUT", body: file, headers: { "content-type": file.type }, }); const job = await fetch("/api/v1/jobs", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ object_key: presign.object_key, mode: "visible" }), }).then((r) => r.json()); return job.result_url; } ``` ### Python (httpx, async-polling for invisible) ```python import httpx, pathlib, time API = "https://erased.ink/api/v1" KEY = "uk_..." # Pro path = pathlib.Path("photo.png") headers = {"authorization": f"Bearer {KEY}"} # 1. Presign presign = httpx.post(f"{API}/uploads", headers=headers, json={ "filename": path.name, "content_type": "image/png", "size": path.stat().st_size, }).json() # 2. Upload httpx.put( presign["upload_url"], content=path.read_bytes(), headers={"content-type": "image/png"}, ) # 3. Submit invisible job (async) job = httpx.post(f"{API}/jobs", headers=headers, json={ "object_key": presign["object_key"], "mode": "invisible", }).json() # 4. Poll while job["status"] in ("queued", "processing"): time.sleep(job.get("poll_after_ms", 2000) / 1000) job = httpx.get(f"{API}/jobs/{job['id']}", headers=headers).json() if job["status"] == "succeeded": print(job["result_url"]) else: print("failed:", job.get("error_code"), job.get("error_message")) ``` --- ## Pricing | Tier | Light (visible/metadata/detect) | Heavy (invisible/all) | API key | Notes | |---|---|---|---|---| | **Anonymous** | 30 / h | 5 / h | no | No sign-in. Detect 100/h. | | **Free** (Google or email magic link) | 100 / h | 15 / h | no | Detect bucket 500/h. | | **Pro $9/mo** | 5000 / month | 500 / month | yes | Unlimited detect + 100 buffer credits/month. | **Credit Packs** (top-ups on any tier; 12-month expiry): | Pack | Price | Credits | Effective rate | |---|---|---|---| | Starter | $5 | 100 | base | | Standard | $20 | 500 | -20% | | Pro | $50 | 1500 | -33% | **Cost per job:** 1 credit per light job, 10 credits per heavy job, **detect is always free** (separate quota). Failed requests **automatically refund** their credit charge. Plans and packs are managed at https://erased.ink/en/pricing and https://erased.ink/en/account/billing. Sign in via Google OAuth or an email magic link at https://erased.ink/en/sign-in. --- ## AI compliance Erased is a general-purpose image utility. Removing technical markers does **not** remove any underlying legal obligation to disclose AI involvement. Users are responsible for compliance in their jurisdiction. **EU — AI Act Article 50.** Deployers and publishers in the EU may be required to disclose AI involvement in published content, regardless of whether technical markers (SynthID, C2PA, visible watermarks) are present in the file. **China — Generative AI Service Management Measures (生成式人工智能服务管理暂行办法).** Providers and users of generative AI services must label synthetic content published within Chinese jurisdiction; this obligation is independent of whether a watermark is technically present. **United States.** Several states (e.g. California AB 2655 / AB 2839) have introduced AI-disclosure rules, particularly in election and commercial contexts. Scope varies by state and date. **Erased's position.** Legitimate uses include backing up your own AI-generated works, academic research on watermarking and provenance, media forensics, journalistic verification, and operating on content you have explicit rights to modify. Erased does not condone use to evade legal disclosure requirements, defraud platform copyright systems, or misrepresent the origin of content you did not create. The compliance notice page at https://erased.ink/en/docs/ai-compliance is informational only and is not legal advice.