Label AI-generated text at the source
For teams that build chatbots, assistants and agents. TextWatermarking adds an invisible label to each response your model generates, without changing a single visible character. When the text appears again later, in a ticket, a document or a complaint, one API call tells you whether it came from your system and which response it was.
Endpoints: POST /api/watermark/tokens, /encode, /decode
· MCP tools: watermark_tokens, watermark_encode, watermark_decode
· Cost: 1 credit per labelled response
The problem
AI-written text leaves your product as soon as a user copies it. A support answer is pasted into a forum, a generated clause ends up in a contract draft, a summary is forwarded to a customer. Weeks later someone asks whether your assistant wrote it and what exactly it said. Your logs show what the model returned, but not which logged response a given piece of text came from. A visible disclaimer is gone after the first edit.
Regulation points in the same direction. Article 50(2) of the EU AI Act requires providers of AI systems that generate text to mark the output in a machine-readable format, so that it can be detected as artificially generated. The obligation applies from 2 August 2026. Recital 133 names watermarks among the suitable techniques, together with metadata, fingerprints and logging. An invisible text label is one of those techniques; it is not a certificate of compliance on its own (see What it does not do). This page is not legal advice.
How it works
- Give each response an id. Your app already logs responses; use that id, for
example
r-8841. The label isai;id=r-8841: short, opaque, and meaningless without your logs. - Get the label tokens. Before streaming starts, call
POST /api/watermark/tokenswith the label assecret. The response is a short run of invisible characters, one per byte of the label. Agents connected over MCP use thewatermark_tokenstool. - Insert them once. Put the tokens directly after the first visible character of
the response. Users see and copy the same text as before. For a response you return in one piece,
call
POST /api/watermark/encodewith the full text instead. - Check text when it comes back. Call
POST /api/watermark/decodeon the text. You getai;id=r-8841, look upr-8841in your logs, and see the model, the prompt version and the time. An emptydecodedvalue means no label was found.
Add it to your workflow
All examples read your API token from TEXTWATERMARKING_API_TOKEN. Create a token on the
dashboard. Each example below is run against the API before this page
is published.
Streamed responses (Node.js)
Wrap your model's stream. The wrapper requests the tokens once, inserts them after the first visible character and passes every other chunk through unchanged. If the label cannot be fetched, for example because the account is out of credits, the answer is still streamed without a label, so a labelling problem never blocks your users.
stream.mjs
// Label a streamed LLM response (Node 18+). Works with any async iterable of text chunks:
// OpenAI, Anthropic, Ollama or your own model server.
const API = 'https://textwatermarking.com/api/watermark';
const headers = {
Authorization: `Bearer ${process.env.TEXTWATERMARKING_API_TOKEN}`,
'Content-Type': 'application/json',
};
// One call per response, made before streaming starts (1 credit).
async function labelTokens(responseId) {
const r = await fetch(`${API}/tokens`, {
method: 'POST', headers, body: JSON.stringify({ secret: `ai;id=${responseId}` }),
});
if (!r.ok) throw new Error(`tokens: HTTP ${r.status}`);
return (await r.json()).tokens;
}
// Insert the invisible tokens once, right after the first visible character.
export async function* labelStream(chunks, responseId) {
// Fail open: if the label can't be fetched, the user still gets the answer.
const tokens = await labelTokens(responseId).catch(() => '');
let done = !tokens;
for await (const chunk of chunks) {
if (done || !chunk.trim()) { yield chunk; continue; }
const lead = chunk.length - chunk.trimStart().length;
const [first] = new Intl.Segmenter().segment(chunk.slice(lead));
const at = lead + first.segment.length; // whole grapheme, safe for emoji
yield chunk.slice(0, at) + tokens + chunk.slice(at);
done = true;
}
}
// Try it with a stand-in for your model's stream:
async function* modelStream() {
yield 'Our refund policy ';
yield 'allows returns within 30 days of delivery.';
}
let answer = '';
for await (const chunk of labelStream(modelStream(), 'r-8841')) answer += chunk;
console.log(answer); // looks unchanged
console.log('labelled:', /[︀-️\u{E0100}-\u{E01EF}]/u.test(answer)); // → labelled: trueComplete responses (curl)
When you send the whole answer at once (email replies, generated documents, batch jobs), label the
finished text with /encode:
mark-answer.sh
#!/usr/bin/env bash
# Mark a finished AI answer with an opaque response id (1 credit).
curl -s https://textwatermarking.com/api/watermark/encode \
-H "Authorization: Bearer $TEXTWATERMARKING_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"text": "Our refund policy allows returns within 30 days of delivery.",
"secret": "ai;id=r-8841"
}'
# → {"encoded":"Our refund policy…","keyed":false,"stats":{…,"method":"auto"}}Checking text that comes back (Python)
Use this in your support tooling, moderation queue or abuse reports. It returns your label, or
None when the text carries none:
find_label.py
# Check a piece of text for your AI label. 1 credit per check.
# Python 3.10+, pip install requests
import os
import sys
import requests
API = "https://textwatermarking.com/api/watermark"
HEADERS = {"Authorization": f"Bearer {os.environ['TEXTWATERMARKING_API_TOKEN']}"}
def find_ai_label(text: str) -> str | None:
"""Return the label your app embedded (e.g. 'ai;id=r-8841'), or None if there is none."""
r = requests.post(f"{API}/decode", json={"text": text}, headers=HEADERS, timeout=10)
if r.status_code == 422: # someone else's keyed or robust mark, not your label (free)
return None
r.raise_for_status()
decoded = r.json()["decoded"] # "" when the text carries no mark (free)
return decoded if decoded.startswith("ai;") else None
if __name__ == "__main__":
# Usage: python find_label.py < pasted.txt
print(find_ai_label(sys.stdin.read()))Agents connected over MCP
An agent in Claude Code, Claude.ai or Cursor can label what it writes with the same tools. Connect the remote MCP server, then add an instruction such as "Before you send a reply to a customer, label it with watermark_encode, secret ai;id=<ticket id>":
claude mcp add --transport http textwatermarking https://textwatermarking.com/mcp \
--header "Authorization: Bearer <API token>"
The MCP guide has the setup for every client.
Cost and limits
| Item | Value |
|---|---|
| Label one response | 1 credit (/tokens or /encode) |
| Check one piece of text | 1 credit (/decode) when a label is found; free when there is none |
| Refused request (400, 401, 402, 422) | free, including 422 for a keyed or robust watermark that a plain decode cannot read |
| Label length for streaming | up to 256 bytes; 15 to 40 bytes is typical |
| Rate limit | 600 calls per 15 minutes per API token; contact us for more |
| Plans | Free: 100 credits to try it · Starter: 5,000 credits/month · Pro: 25,000 credits/month · credit packs for peaks |
Every response carries an X-Credits-Remaining header. When the balance reaches zero, the
API answers 402 with a checkout_url; the streaming example above then sends
the answer without a label.
What it does not do
- It does not survive rewriting. If someone pastes the answer into another model and asks for a rephrase, the result has no label. The same applies to translation, retyping, OCR and screenshots.
- Some software removes invisible characters. Unicode normalization (NFKC), "strip invisible characters" sanitizers, some Markdown renderers and some PDF exporters drop the label. Test your own front end: copy an answer from your UI the way a user would, and decode it.
- An excerpt that leaves out the beginning has no label. The tokens sit after the first character, because a cut usually removes the end of a text, not the start.
- No label is not proof of human writing. It only means no TextWatermarking label was found.
- The label is not secret. Anyone with a TextWatermarking API token can read a
plain label. Use opaque ids and keep personal data out of it. To encrypt labels, use a
signing channel; keyed labels cannot be streamed, so label
the finished text with
/encode, which needs about 8 visible characters per byte of the label plus 30 bytes of overhead (a 12-byte label needs 336 characters).
Frequently asked questions
Does the label change what users see or copy?
Can a user remove the label?
Does this detect text written by other AI models?
What does it cost?
Is this enough to meet the EU AI Act marking duty?
Related
- API endpoints: request and response fields for
/tokens,/encodeand/decode - MCP guide: connect Claude, Cursor and other clients
- Quickstart: account, token and first call in five minutes