Prove authorship and detect edits
For writers, agencies, publishers and legal teams. Stamp a text with its owner, the date and a fingerprint of its words, using a key only your signing channel holds. A later check answers four questions: is it your stamp, is the text unchanged, was it cut short or is there no stamp at all.
Endpoints: POST /api/watermark/encode-robust, /decode-robust with a channel-bound token
· MCP tools: watermark_encode_robust, watermark_decode_robust
· Plan: Starter or Pro (signing channels)
· Cost: 1 credit per stamp
The problem
A client says the delivered translation contained an error that you know was not in your version. A contract draft comes back with one clause quietly changed. An article is republished elsewhere with your name but edited sentences. In each case the dispute is about which version of a text is the original, and a file's modification date or an email thread rarely settles it.
How it works
- Stamp the final version.
POST /api/watermark/encode-robustwith a channel-bound token hideso=Acme Ltd;t=2026-11-09;h=…(owner, date and a 12-character hash of the visible text), encrypted with your channel key. - Deliver the stamped text. It looks the same as before.
- Check any copy later.
POST /api/watermark/decode-robustreturns the stamp if it is complete and yours. Recomputing the hash tells you whether any word changed.
Robust mode adds one thing to a plain keyed mark: when the end of the stamp is missing, it reports that the text was cut short instead of failing silently. The hash adds the rest. The stamp occupies the first few hundred characters, and on its own it would not notice a word changed further down; the hash covers every word, ignoring line endings and trailing spaces.
Add it to your workflow
The example reads the channel-bound token from TEXTWATERMARKING_SIGNING_TOKEN and is run
against the API before this page is published.
Stamp and check from the command line (Node.js)
stamp.mjs
// Stamp a text with its owner and check it later (Node 18+). 1 credit per stamp or check; refusals are free.
// node stamp.mjs sign contract.txt "Acme Ltd" → writes contract.stamped.txt
// node stamp.mjs verify contract.stamped.txt → prints the verdict, exit code 0 only if authentic
// Needs a token bound to your signing channel: nobody else can create a stamp that reads as yours.
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
const API = 'https://textwatermarking.com/api/watermark';
const headers = {
Authorization: `Bearer ${process.env.TEXTWATERMARKING_SIGNING_TOKEN}`,
'Content-Type': 'application/json',
};
const post = (path, body) => fetch(`${API}/${path}`, { method: 'POST', headers, body: JSON.stringify(body) })
.then(async r => ({ status: r.status, body: await r.json() }));
// Hash of the visible words, so a change anywhere in the text is caught (not only in the marked part).
function fingerprint(text) {
const visible = text
.replace(/[︀-️\u{E0100}-\u{E01EF}]/gu, '')
.replace(/\r\n?/g, '\n')
.replace(/[ \t]+$/gm, '')
.replace(/^[ \t\n]+|[ \t\n]+$/g, '');
return createHash('sha256').update(visible, 'utf8').digest('hex').slice(0, 12);
}
async function sign(text, owner) {
const day = new Date().toISOString().slice(0, 10);
const r = await post('encode-robust', { text, secret: `o=${owner};t=${day};h=${fingerprint(text)}` });
if (r.status !== 200) throw new Error(r.body.details ?? r.body.error);
return r.body.watermarked;
}
// The whole text, then from each line start: a stamped text pasted below other lines still verifies.
function* starts(text) {
yield text;
for (let i = text.indexOf('\n'); i !== -1; i = text.indexOf('\n', i + 1)) {
if (text.slice(i + 1).trim()) yield text.slice(i + 1);
}
}
async function verify(text) {
let cutShort = false;
for (const candidate of starts(text)) {
const whole = candidate === text;
const r = await post('decode-robust', { text: candidate });
if (r.status === 400 || r.status === 422) continue; // no stamp of yours starts here (free)
if (r.status !== 200) throw new Error(r.body.error);
if (r.body.decoded === null) { cutShort ||= whole; continue; } // the stamp's end is missing: text cut short
const f = Object.fromEntries(r.body.decoded.split(';').map(p => p.split('=')));
return f.h === fingerprint(candidate)
? { ok: true, verdict: `Authentic: stamped by ${f.o} on ${f.t}, text unchanged.` }
: { ok: false, verdict: `Stamped by ${f.o} on ${f.t}, but the text was changed after stamping.` };
}
return { ok: false, verdict: cutShort ? 'The text was cut short; the stamp cannot be read.'
: 'No stamp from your channel, or the stamp was damaged.' };
}
const [cmd, file, owner] = process.argv.slice(2);
const text = readFileSync(file, 'utf8');
if (cmd === 'sign') {
const out = file.replace(/(\.\w+)?$/, '.stamped$1');
writeFileSync(out, await sign(text, owner));
console.log(`Stamped: ${out}`);
} else {
const { ok, verdict } = await verify(text);
console.log(verdict);
process.exitCode = ok ? 0 : 1;
}The check prints one of these verdicts (exit code 0 only for the first):
| Verdict | Meaning |
|---|---|
| Authentic: stamped by Acme Ltd on 2026-11-09, text unchanged. | Your stamp, and every word is as stamped. |
| Stamped by Acme Ltd on 2026-11-09, but the text was changed after stamping. | Your stamp, but at least one word differs. |
| The text was cut short; the stamp cannot be read. | The beginning is there, the rest of the stamp is missing. |
| No stamp from your channel, or the stamp was damaged. | Never stamped, stamped by someone else, or invisible marks were removed. |
A stamped text pasted below other lines (an email, a cover note) is still found: the check also tries the text from each line start, and attempts that find nothing are free.
What it costs
| Item | Value |
|---|---|
| Stamp one text | 1 credit |
| Check one text | 1 credit when a stamp is read; free when none is found or it is incomplete |
| Minimum length | about (stamp bytes + 30) × 8 visible characters; 544 characters for the stamp above |
| Signing channels | Free: none · Starter: 3 · Pro: 20 |
What it does not do
- It does not prove who wrote the words first. It proves your channel stamped this text. Stamp at delivery, so your stamp comes first.
- The date is your own. It comes from your machine's clock, not from a trusted timestamping authority.
- Lines added below the stamped text count as a change. The hash covers everything from the stamp's start to the end of the text.
- Rewriting, retyping and some file conversions remove the stamp. Plain text, HTML and most editors keep it; some PDF exporters do not. Test your delivery format.
- Rotating the channel key ends verification of older stamps. Use a new channel instead if old stamps must stay checkable.
Frequently asked questions
Does a stamp prove I wrote the text?
Is the date trustworthy?
Can my client check a stamp?
How long must the text be?
What does it cost?
Related
- Trace a leaked document to its recipient: one copy per person
- Let your agents sign what they write: the same pattern for agent output
- API endpoints:
encode-robustanddecode-robustfields and integrity results