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

  1. Stamp the final version. POST /api/watermark/encode-robust with a channel-bound token hides o=Acme Ltd;t=2026-11-09;h=… (owner, date and a 12-character hash of the visible text), encrypted with your channel key.
  2. Deliver the stamped text. It looks the same as before.
  3. Check any copy later. POST /api/watermark/decode-robust returns 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):

VerdictMeaning
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

ItemValue
Stamp one text1 credit
Check one text1 credit when a stamp is read; free when none is found or it is incomplete
Minimum lengthabout (stamp bytes + 30) × 8 visible characters; 544 characters for the stamp above
Signing channelsFree: 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?
It proves that your signing channel stamped this exact text, and on which date your system said it did. It does not prove who wrote the words first: anyone can stamp a text they copied. Stamp your work when you deliver it, so your stamp is the earliest.
Is the date trustworthy?
The date comes from the clock of the machine that stamped the text. For a legally recognized timestamp, also register a hash of the document with a qualified timestamping service.
Can my client check a stamp?
Only with a token on your channel. Create a token with the decode permission only and give it to the client, or run the check for them. The token cannot create stamps.
How long must the text be?
About 8 visible characters per byte of the stamp plus 30 bytes of overhead. The stamp used here (owner, date and a 12-character hash) needs about 550 characters, roughly 90 words.
What does it cost?
One credit per stamp and one per check that reads a stamp. Checks that find no stamp, or only an incomplete one, are free.