Add human-authorship certification to any writing application. The SDK observes how users type — not what they type — and issues cryptographic certificates proving a human wrote the text.
Ready to integrate? Reach out at human@writermark.org to request access to the private beta.
The fastest path. One hook, one component, ~15 lines of code.
npm install @writermark/sdk
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useWriterMark, CertIndicator } from '@writermark/sdk/react'
function MyEditor() {
const editor = useEditor({ extensions: [StarterKit] })
const { status, certificate } = useWriterMark('doc-1', editor, {
writermarkUrl: 'https://writermark.org',
onCheckpoint: (checkpoint, coverage, pass, cert) => {
// Save checkpoint to restore certification across sessions
localStorage.setItem('my-checkpoint', checkpoint)
},
previousCheckpoint: localStorage.getItem('my-checkpoint'),
})
return (
<div>
<EditorContent editor={editor} />
<CertIndicator status={status} certificate={certificate} />
</div>
)
}
That's it. The hook handles the full certification lifecycle — collecting behavioral telemetry, sending it to the server every 30 seconds, and maintaining a signed checkpoint chain. The CertIndicator shows a colored dot with the current status and a hover panel with score details.
| Prop | Type | Description |
|---|---|---|
| writermarkUrl | string | Server URL (e.g. https://writermark.org) |
| onCheckpoint | function | Called after each certification with (checkpoint, coverage, pass, certificate, authorshipMap) |
| previousCheckpoint | string | null | Restore a saved checkpoint to continue certification across sessions |
| previousPass | boolean | Whether the previous checkpoint was passing |
| previousAuthorshipMap | AuthorshipMap | null | Saved authorship map from a previous session |
| debug | boolean | Log certification details to console |
| telemetryConsent | boolean | If true and sourceApp is set, uploads anonymous telemetry for ML training |
| sourceApp | string | App identifier for telemetry (must be allowlisted server-side) |
| Field | Type | Description |
|---|---|---|
| status | 'idle' | 'certifying' | 'certified' | 'not-certified' | Current certification state |
| coverage | number | null | Fraction of text covered by observed keystrokes (0–1) |
| certificate | string | null | Signed attestation JWT when passing |
| checkpoint | string | null | Latest signed checkpoint JWT |
| certifyNow | () => Promise | Force an immediate certification cycle |
| authorshipMap | AuthorshipMap | null | RLE array tracking provenance of each character |
| isTracking | boolean | Whether the collector is attached and running |
If you use TipTap but not React, attach the collector directly and run the certification loop yourself.
import {
Collector,
attachToTipTap,
createCertificationContext,
computeMerkleRoot,
compressEvents,
normalizeText,
} from '@writermark/sdk'
// 1. Create collector and context
const collector = new Collector()
const ctx = createCertificationContext('https://writermark.org')
collector.start()
// 2. Attach to your TipTap editor instance
const cleanup = attachToTipTap(editor, collector, ctx)
// 3. Certify periodically (e.g. every 30s)
let lastIndex = 0
async function certify() {
const events = collector.peekEvents()
const newEvents = compressEvents(events.slice(lastIndex))
if (newEvents.length < 10) return // not enough data yet
const text = editor.getText()
const normalized = normalizeText(text)
const data = new TextEncoder().encode(normalized)
const hash = Array.from(
new Uint8Array(await crypto.subtle.digest('SHA-256', data))
).map(b => b.toString(16).padStart(2, '0')).join('')
const res = await fetch('https://writermark.org/certify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
documentId: 'my-doc',
events: newEvents,
textHash: hash,
charCount: text.length,
checkpoint: ctx.checkpoint,
contentMerkleRoot: await computeMerkleRoot(text, ctx),
authorshipMap: ctx.authorshipMap,
}),
})
const result = await res.json()
ctx.checkpoint = result.checkpoint
ctx.isPassing = result.pass
if (result.authorshipMap) ctx.authorshipMap = result.authorshipMap
lastIndex = events.length
}
setInterval(certify, 30000)
// 4. Clean up when done
// cleanup()
For standard <textarea> or contenteditable elements, use attachToElement instead of attachToTipTap.
import { Collector, attachToElement } from '@writermark/sdk'
const collector = new Collector()
collector.start()
const textarea = document.querySelector('#my-editor')
const cleanup = attachToElement(textarea, collector)
// Then run the same certification loop as above,
// reading text from textarea.value instead of editor.getText()
Works with <textarea>, <input>, and any element with contenteditable.
For non-bundled environments, load the SDK as a script tag. Everything is available on window.WriterMark.
<script src="https://writermark.org/sdk.js"></script>
<script>
const collector = new WriterMark.Collector()
collector.start()
const cleanup = WriterMark.attachToElement(
document.querySelector('#my-editor'),
collector
)
</script>
For editors that aren't TipTap or standard DOM elements, use the Collector directly. Call record* methods from your editor's event handlers.
import { Collector } from '@writermark/sdk'
const collector = new Collector()
collector.start()
// Record events from your editor's callbacks:
collector.recordKey('KeyA', cursorPosition)
collector.recordKeyUp('KeyA')
collector.recordBackspace('Backspace', cursorPosition)
collector.recordEnter('Enter', cursorPosition)
collector.recordPaste(charCount, pastedText, 'external', cursorPosition)
collector.recordCopyOrCut(selectedText, copyStart, copyLength, isCut)
collector.recordCursorJump(distance)
collector.recordSelect(selectionLength)
collector.recordUndo()
collector.recordRedo()
collector.recordFocus()
collector.recordBlur()
collector.recordScroll(deltaY)
collector.recordMouse(xRatio, yRatio) // 0-1 normalized
collector.recordVisibility(isVisible)
collector.recordMutation(position, deleteLength, insertLength, 'typed')
The recordMutation method is key — it tracks document changes for the authorship map. The insertSource parameter should be 'typed', 'paste', 'undo', 'redo', or 'unknown'.
The main certification endpoint. Stateless — all state lives in the signed checkpoint. Rate limit: 30/min per IP.
Request body:
| Field | Type | Description |
|---|---|---|
| documentId | string | Persistent document identifier (required on first call) |
| events | EditorEvent[] | New events since last checkpoint |
| textHash | string | SHA-256 of normalized text |
| charCount | number | Current text length |
| checkpoint | string | null | Previous checkpoint JWT (null for first call) |
| contentMerkleRoot | string | null | Merkle root of document chunks |
| authorshipMap | AuthorshipMap | null | RLE authorship intervals |
Response:
{
"checkpoint": "eyJhbGciOi...",
"score": 0.72,
"behavioralScore": 0.85,
"coverage": 0.95,
"pass": true,
"certificate": "eyJhbGciOi...",
"authorshipMap": [[0, 150, "human_evidenced"]],
"confidence": 0.88,
"activeWritingTimeMs": 120000,
"revisionPercent": 8.5
}
Create a standalone certificate for an excerpt (e.g. copy-paste certification). Requires a valid source JWT and Merkle proofs for the excerpt's chunks. Rate limit: 5/min per IP.
Returns the Ed25519 public key for verifying attestation JWTs.
{
"publicKey": {
"kty": "OKP",
"crv": "Ed25519",
"x": "..."
}
}
Standard JWKS endpoint for public key discovery. Key ID: writermark-v1.
Drop-in React component showing a colored status dot, label, and hover popup with certificate details.
import { CertIndicator } from '@writermark/sdk/react'
<CertIndicator
status={status}
certificate={certificate}
writermarkUrl="https://writermark.org"
/>
| Prop | Type | Description |
|---|---|---|
| status | CertificationStatus | From useWriterMark |
| certificate | string | null | Attestation JWT from useWriterMark |
| writermarkUrl | string? | Server URL for "View certificate" link (defaults to writermark.org) |
| className | string? | CSS class override for the wrapper element |
Opt-in consent banner for anonymous telemetry collection. Only renders when the user hasn't responded yet.
import { TelemetryConsentBanner } from '@writermark/sdk/react'
<TelemetryConsentBanner
sourceApp="my-app"
onConsent={(allowed) => setConsent(allowed)}
/>
WriterMark can optionally collect anonymous typing telemetry for improving the scoring model. This is entirely opt-in — users must explicitly consent, and no text content is ever transmitted.
To enable telemetry, set both telemetryConsent: true and sourceApp in the hook options. Your sourceApp identifier must be allowlisted on the server. Use the TelemetryConsentBanner component to ask users for consent.
Consent state is stored in localStorage under writermark_telemetry_consent. You can read/write it programmatically with getTelemetryConsent() and setTelemetryConsent(value).
The WriterMark protocol is open. You can run your own certification server with your own signing keys. The server is a Node.js/Hono application — see the GitHub repository for setup instructions.
When self-hosting, point the SDK at your server URL instead of writermark.org.
Interested in integrating WriterMark? Reach out at human@writermark.org