← Watermarks Remover / API
Your token

Watermarks Remover API — drive the invisible-character scanner from your own code

Watermarks Remover finds and removes hidden Unicode in text — zero-width spaces, bidirectional overrides, tag characters, private-use codepoints and homoglyph spaces. This page documents the HTTP API; the FAQ and the reference of what it detects are on the main page.

Everything the page does is available over HTTP. The character scan and the style score run in the browser and are not part of this API — what you call here is the model lane, so send your own findings with the request.

Base URL and envelope

Every call goes to https://api.skillsafe.ai/v1/app-api and carries Authorization: Bearer <token>. Successful responses wrap the payload in data; failures carry error.

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "INSUFFICIENT_CREDITS", "message": "...", "details": { ... } } }

Errors

StatusCodeWhat it means
400VALIDATION_ERRORThe body was not valid JSON, or a required field was the wrong type.
401UNAUTHORIZEDMissing or expired token. Mint a new one - see step 1.
402INSUFFICIENT_CREDITSNot enough credits. If reason is sponsor_exhausted the publisher's daily allowance ran out and a guest should sign in; otherwise top up.
403FORBIDDENA guest called an endpoint that requires an account - the document converter, for one.
404NOT_FOUNDUnknown job id, or a collection this release does not declare.
413INVALID_REQUESTBody over 1 MB, or an uploaded document over 8 MB.
429RATE_LIMITEDToo many calls. Back off and retry.
503UNAVAILABLEThe model lane is temporarily unavailable. Holds are refunded.

1. Get a token

Open /tokens.html in a browser signed in to SkillSafe, reveal the token and copy it. That page reads the very token this app uses, so you never have to open a storage inspector. A guest token is minted automatically and is enough to run both lanes while the publisher's daily sponsorship lasts; a personal token is needed once that allowance is used up, and always for the document converter.

Paste the token into the YOUR_TOKEN placeholder in the samples below, or read it from wherever your project keeps its secrets. Do not commit it.

2. Who am I

Confirms the token works and reports the balance.

curl -sS 'https://api.skillsafe.ai/v1/app-api/me' \
  -H 'Authorization: Bearer YOUR_TOKEN'

Returns {"subject_type": "user" | "guest", "subject_id": "...", "credits": 0}.

3. Price a run before making it

Free, no job created, and guests may call it. hold_credits is what gets reserved, never what you are charged — settlement recomputes from actual usage and refunds the rest. sponsor_enabled: true means the publisher is paying and the run is free to you.

curl -sS -X POST 'https://api.skillsafe.ai/v1/app-api/estimate' \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{
  "task": "explain",
  "source": {
    "name": "quarterly-update.md",
    "format": "md",
    "fidelity": "exact"
  },
  "findings": {
    "length": 1246,
    "suspicious_total": 13,
    "kinds": [
      {
        "kind": "zwj_family",
        "label": "Zero-width",
        "count": 4,
        "distinct": 3,
        "confidence": "probable"
      }
    ],
    "hits": [
      {
        "codepoint": "U+200B",
        "name": "ZERO WIDTH SPACE",
        "category": "Cf",
        "kind": "zwj_family",
        "confidence": "probable",
        "count": 2,
        "sample_offsets": [
          128,
          253
        ]
      },
      {
        "codepoint": "U+202E",
        "name": "RIGHT-TO-LEFT OVERRIDE",
        "category": "Cf",
        "kind": "bidi",
        "confidence": "probable",
        "count": 1,
        "sample_offsets": [
          1064
        ]
      }
    ]
  },
  "style": {
    "score": 0.69,
    "level": "MEDIUM",
    "status": "ok",
    "word_count": 214,
    "sentence_count": 14,
    "burstiness_cv": 0.281,
    "lexical_diversity": 0.723,
    "ai_ngram_density": 1.87,
    "findings": [
      "AI cadence phrase '\''in today'\''s fast-paced world/landscape'\'' (1x)"
    ]
  },
  "text": "In today'\''s fast-paced world, organisations must delve into ...",
  "clean": {
    "removed_count": 11,
    "replaced_count": 2
  }
}'

Returns {"hold_credits", "min_credits", "model", "model_alias", "markup_bps", "sponsor_enabled"}. Holds differ per lane, so re-estimate when you switch task.

4. Run and poll

POST /run returns a job id immediately. Send an Idempotency-Key header so a retry after a dropped connection returns the original job instead of billing twice — a hash of the input plus an attempt counter works well.

curl -sS -X POST 'https://api.skillsafe.ai/v1/app-api/run' \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{
  "task": "explain",
  "source": {
    "name": "quarterly-update.md",
    "format": "md",
    "fidelity": "exact"
  },
  "findings": {
    "length": 1246,
    "suspicious_total": 13,
    "kinds": [
      {
        "kind": "zwj_family",
        "label": "Zero-width",
        "count": 4,
        "distinct": 3,
        "confidence": "probable"
      }
    ],
    "hits": [
      {
        "codepoint": "U+200B",
        "name": "ZERO WIDTH SPACE",
        "category": "Cf",
        "kind": "zwj_family",
        "confidence": "probable",
        "count": 2,
        "sample_offsets": [
          128,
          253
        ]
      },
      {
        "codepoint": "U+202E",
        "name": "RIGHT-TO-LEFT OVERRIDE",
        "category": "Cf",
        "kind": "bidi",
        "confidence": "probable",
        "count": 1,
        "sample_offsets": [
          1064
        ]
      }
    ]
  },
  "style": {
    "score": 0.69,
    "level": "MEDIUM",
    "status": "ok",
    "word_count": 214,
    "sentence_count": 14,
    "burstiness_cv": 0.281,
    "lexical_diversity": 0.723,
    "ai_ngram_density": 1.87,
    "findings": [
      "AI cadence phrase '\''in today'\''s fast-paced world/landscape'\'' (1x)"
    ]
  },
  "text": "In today'\''s fast-paced world, organisations must delve into ...",
  "clean": {
    "removed_count": 11,
    "replaced_count": 2
  }
}'

Then poll until the job is terminal:

curl -sS 'https://api.skillsafe.ai/v1/app-api/jobs/job_0123456789' \
  -H 'Authorization: Bearer YOUR_TOKEN'

status is queued, running, succeeded or failed. price_credits is the hold; charged_credits is the real cost. Build your pricing display on the second one.

5. Stream instead

POST /run-stream takes the same body and returns Server-Sent Events: a job event, then delta events carrying {"text"}, then done or error.

Not usable on this app's model right now. On gemma-fast the stream emits the job event and then error: "Run timed out" with no delta events at all, while the identical body through /run succeeds in about thirty seconds and settles normally. The same request streams correctly with a "$model": "gpt-terra" override, so the fault is in the Workers AI streaming path, not in the request. Verified live on 2026-08-17. The page therefore uses /run plus polling, and you should too until this is fixed.

curl -sS -X POST 'https://api.skillsafe.ai/v1/app-api/run-stream' \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{
  "task": "rewrite",
  "source": {
    "name": "quarterly-update.md",
    "format": "md",
    "fidelity": "exact"
  },
  "findings": {
    "length": 1246,
    "suspicious_total": 13,
    "kinds": [],
    "hits": []
  },
  "style": {
    "score": 0.69,
    "level": "MEDIUM",
    "status": "ok",
    "word_count": 214,
    "sentence_count": 14,
    "burstiness_cv": 0.281,
    "lexical_diversity": 0.723,
    "ai_ngram_density": 1.87,
    "findings": []
  },
  "strength": "humanize",
  "blocks": [
    {
      "id": 1,
      "text": "In today'\''s fast-paced world, organisations must delve into ..."
    },
    {
      "id": 4,
      "text": "In conclusion, navigating the intricacies of this rich tapestry ..."
    }
  ],
  "selection": {
    "selected": 2,
    "total": 5,
    "chars": 895
  }
}'

6. The lanes

This app has one prompt and one model, and routes on the task field. Send it on every request. If it is missing the model picks the closer lane and names its choice, which is a fallback rather than a feature — do not rely on it.

Fields common to both lanes

FieldTypeMeaning
taskstring"explain" or "rewrite"
sourceobject{name, format, fidelity}. fidelity is "exact" for bytes read directly, "converted" for text rebuilt from a PDF or .docx — in which case carriers may have been normalized away before the scan ran
findingsobjectYour Layer A result: {length, suspicious_total, kinds[], hits[]}. Each hit is {codepoint, name, category, kind, confidence, count, sample_offsets}. These are treated as facts the model may not contradict
styleobject{score, level, status, word_count, sentence_count, burstiness_cv, lexical_diversity, ai_ngram_density, findings[]}

task: explain

Extra fields: text (an excerpt, for context only) and optionally clean = {removed_count, replaced_count}.

Output is three sections, in this order:

## VERDICT
Two or three sentences: what kind of contamination this is, whether it looks deliberate, and
which single finding deserves attention first.

## RISK
Two or three short bullets: what follows, and the limit that matters most.

## NEXT
Two or three short bullets: what to do, in order.

The model is deliberately not asked to list the carriers. Your own scan already has every codepoint, name, class, count and offset — rendering that is your job, not the model's. It is also the difference between a run that completes and one that does not: a ~700-character reply finishes in about 35 seconds, while adding a per-codepoint enumeration pushes it past 1,200 characters and the run is killed at the 60-second wall clock. Measured 2026-08-17 on gemma-fast; the failure surfaces as an unclassified "Run failed — the platform could not complete this request" rather than as a timeout.

Reconcile what does come back: any U+XXXX the reply names that is not in your findings.hits is invented, and the page says so above the result.

task: rewrite

Extra fields: strength ("humanize", "paraphrase" or "code"), blocks = [{id, text}], selection = {selected, total, chars} for the whole document, and batch = {index, of, blocks_in_batch}.

Send long documents in batches. A rewrite returns about as much text as it is given, and the run is killed at sixty seconds — which on this model is roughly 700 characters of output. One request cannot rewrite a long document, and an overrun loses the whole reply rather than truncating it. The page therefore groups the selected paragraphs into batches of ~900 characters, submits one job per batch, and renders each batch as it lands. Do the same: pack paragraphs to a character budget, keep a paragraph whole even if it exceeds the budget, and give each batch its own Idempotency-Key so a retry cannot double-bill.

Because the model cannot stream (see the note in step 5), a completed batch is the finest-grained progress signal available. Rendering per batch is what makes a long run feel live — and it means a failed batch costs you one batch, not the whole document. Failed batches refund their hold; leave those paragraphs as they were and say so.

Output:

[[BLOCK 1]]
The rewritten paragraph, plain text.
[[BLOCK 4]]
The next rewritten paragraph.
[[NOTES]]
Two or three sentences on what changed.

Ids are echoed back from your request, so splice each block over the range it came from — work backwards from the end of the document so earlier offsets stay valid. A block that does not come back has not been rewritten: show the original and say so rather than silently substituting it. Gemma 4 caps output at 4,096 tokens, which is why the page sends at most six paragraphs and around 8,000 characters per run.

What to send, and what to keep to yourself

The whole point of computing findings and style locally is that the model never needs your full document. The explain lane takes an excerpt for context; the rewrite lane takes only the paragraphs you chose to have rewritten. Everything else stays in your process.

Doing the scan yourself

The two engines are plain, dependency-free JavaScript in this bundle: /unicode.js exposes WmUnicode.inspect(text, opts) and WmUnicode.clean(text, opts), and /stylometry.js exposes WmStylometry.score(text), scoreParagraphs(text) and selectForRewrite(paragraphs, opts). They have no dependencies and no network access, so they run anywhere a browser or Node does.

Both are independent re-implementations of the text layers of github.com/guillaumemeyer/watermarks-remover (MIT, Guillaume Meyer). If you want the file-level, image and C2PA work, or the research harnesses for scheme-specific detection, go to the upstream project — none of that is in scope here.