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

  1. Give each response an id. Your app already logs responses; use that id, for example r-8841. The label is ai;id=r-8841: short, opaque, and meaningless without your logs.
  2. Get the label tokens. Before streaming starts, call POST /api/watermark/tokens with the label as secret. The response is a short run of invisible characters, one per byte of the label. Agents connected over MCP use the watermark_tokens tool.
  3. 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/encode with the full text instead.
  4. Check text when it comes back. Call POST /api/watermark/decode on the text. You get ai;id=r-8841, look up r-8841 in your logs, and see the model, the prompt version and the time. An empty decoded value 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: true

Complete 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

ItemValue
Label one response1 credit (/tokens or /encode)
Check one piece of text1 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 streamingup to 256 bytes; 15 to 40 bytes is typical
Rate limit600 calls per 15 minutes per API token; contact us for more
PlansFree: 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?
No. The label is a short run of Unicode variation selectors placed after the first visible character. They have no width and most fonts draw nothing for them, so the response looks the same. When a user copies the text, the selectors are copied with it.
Can a user remove the label?
Yes. Rewriting, translating or retyping the text removes it, and so do tools that normalize Unicode or strip invisible characters. The label records where text came from in normal workflows; it is not copy protection, and it does not stop a determined person.
Does this detect text written by other AI models?
No. It is not an AI detector. It finds labels that were embedded with TextWatermarking, such as the ones your own app adds. Text from other models carries no label.
What does it cost?
One credit to label a response and one credit for each check that finds a label; a check that finds nothing is free. Refused requests (for example a missing field) cost nothing. The free account includes 100 credits; Starter includes 5,000 credits a month and Pro 25,000.
Is this enough to meet the EU AI Act marking duty?
It addresses the machine-readable marking of text output as long as your pipeline keeps the invisible characters. Whether it is sufficient for your system is a legal question. Most teams combine it with logs they keep and visible disclosure where the law requires it. This page is not legal advice.
  • API endpoints: request and response fields for /tokens, /encode and /decode
  • MCP guide: connect Claude, Cursor and other clients
  • Quickstart: account, token and first call in five minutes