One POST request gets you a score, a verdict, the sources that matched, and the exact passages to highlight. Here's how to build it into a real product.

If your app accepts writing from users, sooner or later someone asks whether that writing is original. It might be an LMS that takes essays, a CMS where freelancers submit articles, a marketplace for guest posts, or an internal tool where an editor reviews drafts before they go live.

Building plagiarism detection yourself means crawling and indexing the web, fingerprinting text, and matching passages at scale. That's a company, not a feature. A plagiarism checker API lets you skip all of it: you send text, and you get back a score, the matching sources, and the passages that matched.

This guide uses the Proofademic API, which added a plagiarism checker alongside its AI detector in July 2026. Everything below comes from Proofademic's official API documentation, with working examples in curl, Node.js and Python, plus the production details that usually get skipped: idempotent retries, rendering highlights, error handling and cost.

Table of Contents

  1. What a Plagiarism Checker API Gives You
  2. Getting an API Key
  3. Your First Scan
  4. Understanding the Response
  5. Node.js: A Reusable Client
  6. Python: Safe Retries With Idempotency Keys
  7. Rendering Highlights in Your UI
  8. Handling Errors
  9. Pairing Plagiarism and AI Detection
  10. Pricing, Limits and Cost Planning
  11. Already Using Walter's API?
  12. Production Checklist
  13. Conclusion
  14. FAQs

1. What a Plagiarism Checker API Gives You

Proofademic's plagiarism endpoint scans text against web sources and returns:

  • a plagiarism score from 0 (original) to 1 (fully plagiarized)
  • a verdict label you can show directly or map to your own UI
  • the matched sources, strongest first, with URL, title, domain and how much of your text each one covers
  • highlights, as character ranges into the text you submitted, each with a severity and a pointer to its source

That last part matters more than it sounds. A score on its own tells a user "something is wrong." Offsets let you show them exactly which sentences matched and where they came from, which is what a reviewer actually needs.

2. Getting an API Key

Sign in to the Proofademic Developer Portal and open the API Keys page.

  1. Click Create API Key.
  2. Give it a descriptive name, such as production-cms.
  3. Under Model Access, select Plagiarism Checker. Add AI Detector too if you plan to run both checks (section 9).
  4. Choose an expiration: Never, 30, 60 or 90 days.
  5. Copy the key immediately. It's shown only once.

Keys start with prfd_, which makes them easy to spot in config files and secret scanners. A key only works for the scopes you selected. Calling an endpoint outside its scopes returns 403.

New accounts get a free trial with 2,000 credits for 5 days, no credit card required.

Store the key server-side:

export PROOFADEMIC_API_KEY="prfd_..." 

Never ship it to a browser or mobile client. Your frontend should call your backend, and your backend should call Proofademic.

3. Your First Scan

The endpoint is a single POST:

POST https://developer-portal.proofademic.ai/api/plagiarism/scan/ 

The request body has one field, content:

curl -X POST https://developer-portal.proofademic.ai/api/plagiarism/scan/ \ -H "Content-Type: application/json" \ -H "X-API-Key: $PROOFADEMIC_API_KEY" \ -d '{ "content": "The Eiffel Tower is a wrought-iron lattice tower on the Champ de Mars in Paris, France. It is named after the engineer Gustave Eiffel, whose company designed and built the tower from 1887 to 1889." }' 

Limits, per the docs:

Limit Value
Minimum length 50 characters
Maximum length 25,000 words
Processing Synchronous only
Cost Flat 2 credits per scan

Word counts are script-aware: whitespace-separated words for most languages, one word per character for CJK scripts.

4. Understanding the Response

Here's the response from the docs for the request above:

{ "status": "success", "scan_id": "scan_YYbWPwT4JHUabc12", "plagiarism_score": 0.99, "verdict": "severe_plagiarism", "summary": "Nearly all of the submitted text matches known sources.", "sources": [ { "url": "https://en.wikipedia.org/wiki/Eiffel_Tower", "title": "Eiffel Tower - Wikipedia", "domain": "en.wikipedia.org", "similarity_score": 0.97, "matched_word_count": 34, "matched_percentage": 0.92, "matches": [ { "submitted_range": { "start": 0, "end": 133 }, "similarity": 0.97 } ] } ], "highlights": [ { "start": 0, "end": 133, "severity": "high", "source_index": 0 } ], "stats": { "word_count": 37, "input_chars": 196, "sentence_count": 2, "sources_checked": 24, "processing_time_ms": 3100 }, "execution_time": 3.4, "word_count": 37, "credits_remaining": 1998, "user": { "id": 12345 } } 

The fields you'll use most:

Field What it's for
plagiarism_score Overall result, 0.0 to 1.0
verdict Ready-made label for the score
summary A human-readable sentence you can show as-is
sources[] Matched sources, strongest first
sources[].matched_percentage Share of the submitted text this source covers
highlights[] Character ranges to mark in the text
highlights[].source_index Which entry in sources[] the passage matched
scan_id Store it. Include it in support requests
credits_remaining Your balance after this call

Verdicts map to score ranges like this:

Score Verdict
below 0.05 original
0.05 to 0.15 minor_similarity
0.15 to 0.40 moderate_plagiarism
0.40 to 0.70 significant_plagiarism
0.70 and above severe_plagiarism

Highlight severity is based on how closely each passage matches its source: high at 0.85 or above, medium at 0.70 or above, and low below that.

A note on interpretation: a minor_similarity result is normal for most real writing. Quotes, common phrases, names and standard definitions all overlap with something on the web. Treat the verdict as a starting point for review, not an automatic accusation, and show users the matched passages so they can judge for themselves.

5. Node.js: A Reusable Client

This uses the built-in fetch in Node 18 and later, with no dependencies.

// plagiarism.js import { randomUUID } from "node:crypto"; const BASE_URL = "https://developer-portal.proofademic.ai/api"; const API_KEY = process.env.PROOFADEMIC_API_KEY; export class PlagiarismApiError extends Error { constructor(status, body) { super(body?.error ?? `HTTP ${status}`); this.status = status; this.code = body?.code; this.details = body?.details; } } export async function scanForPlagiarism(content, { idempotencyKey = randomUUID() } = {}) { if (content.length < 50) { throw new Error("Text must be at least 50 characters to scan."); } const res = await fetch(`${BASE_URL}/plagiarism/scan/`, { method: "POST", headers: { "Content-Type": "application/json", "X-API-Key": API_KEY, "Idempotency-Key": idempotencyKey, }, body: JSON.stringify({ content }), }); const body = await res.json().catch(() => null); if (!res.ok) throw new PlagiarismApiError(res.status, body); return body; } 

And a small Express route that your frontend can call:

// server.js import express from "express"; import { scanForPlagiarism, PlagiarismApiError } from "./plagiarism.js"; const app = express(); app.use(express.json({ limit: "1mb" })); app.post("/api/originality-check", async (req, res) => { try { const result = await scanForPlagiarism(req.body.text); res.json({ score: result.plagiarism_score, verdict: result.verdict, summary: result.summary, sources: result.sources.map(({ url, title, domain, matched_percentage }) => ({ url, title, domain, matched_percentage, })), highlights: result.highlights, scanId: result.scan_id, }); } catch (err) { if (err instanceof PlagiarismApiError) { return res.status(err.status === 400 ? 400 : 502).json({ error: err.message, code: err.code }); } res.status(400).json({ error: err.message }); } }); app.listen(3000); 

The route returns only what the frontend needs. It doesn't forward credits_remaining or user.id, which are account details your users don't need to see.

6. Python: Safe Retries With Idempotency Keys

Because each scan is billed, a naive retry after a timeout can charge you twice: the first request may have succeeded even though your client never saw the response.

The API handles this with an Idempotency-Key header (any string up to 255 characters). Per the docs:

  • Repeating the same key with the same text within 5 minutes returns the original response. Nothing is re-scanned and nothing is charged.
  • Repeating the same key with different text returns 409 idempotency_key_reuse, so a stale key can never return results for the wrong text.

The rule that follows: generate the key once per logical scan, and reuse it for every retry of that scan.

import os import time import uuid import requests BASE_URL = "https://developer-portal.proofademic.ai/api" API_KEY = os.environ["PROOFADEMIC_API_KEY"] RETRYABLE = {429, 500, 503} def scan_for_plagiarism(content: str, max_attempts: int = 4) -> dict: idempotency_key = str(uuid.uuid4()) # one key for every retry of this scan  headers = { "Content-Type": "application/json", "X-API-Key": API_KEY, "Idempotency-Key": idempotency_key, } for attempt in range(1, max_attempts + 1): try: resp = requests.post( f"{BASE_URL}/plagiarism/scan/", json={"content": content}, headers=headers, timeout=60, ) except requests.RequestException: if attempt == max_attempts: raise time.sleep(2 ** attempt) continue if resp.status_code == 200: return resp.json() if resp.status_code in RETRYABLE and attempt < max_attempts: retry_after = resp.headers.get("Retry-After") time.sleep(int(retry_after) if retry_after else 2 ** attempt) continue body = resp.json() if resp.content else {} raise RuntimeError(f"{resp.status_code} {body.get('code')}: {body.get('error')}") raise RuntimeError("Plagiarism scan failed after retries") if __name__ == "__main__": result = scan_for_plagiarism(open("draft.txt", encoding="utf-8").read()) print(result["verdict"], result["plagiarism_score"]) for src in result["sources"][:3]: print(f"- {src['domain']}: {src['matched_percentage']:.0%} of text") 

Two details make this safe. The key is created outside the retry loop, so every attempt shares it. And retries only happen for 429 and 5xx errors. The docs are explicit that 400, 401 and 403 mean something in the request needs fixing, so retrying them just wastes time.

7. Rendering Highlights in Your UI

Each highlight is a { start, end } range into the text exactly as you submitted it. That's why it's worth storing the submitted text alongside the result: if a user edits the draft afterward, old offsets no longer line up.

Here's a framework-free function that turns the response into safe HTML with <mark> tags:

function escapeHtml(s) { return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;", })[c]); } export function renderHighlights(text, highlights, sources) { const sorted = [...highlights].sort((a, b) => a.start - b.start); let html = ""; let cursor = 0; for (const h of sorted) { if (h.start < cursor) continue; // skip overlaps const source = sources[h.source_index]; html += escapeHtml(text.slice(cursor, h.start)); html += `<mark class="match match--${h.severity}" title="${escapeHtml( source ? `${source.title} (${source.domain})` : "Matched source" )}">${escapeHtml(text.slice(h.start, h.end))}</mark>`; cursor = h.end; } html += escapeHtml(text.slice(cursor)); return html; } 
.match--high { background: #fecaca; } .match--medium { background: #fde68a; } .match--low { background: #e5e7eb; } 

Escape both the user text and the source titles. Both come from outside your app, and rendering either one as raw HTML is an easy XSS hole.

One edge case worth testing: the docs say offsets refer to the text as submitted, but they don't specify whether they count UTF-16 code units (how JavaScript indexes strings) or Unicode code points. For plain text it makes no difference. If your users paste emoji or other characters outside the Basic Multilingual Plane, run a test scan with one and confirm the highlights still line up.

8. Handling Errors

All errors return JSON with an error message, and most include a machine-readable code and a details object. Branch on code, not on the message string.

Status Code What to do
400 invalid_payload Missing content or under 50 characters. Validate before sending
400 insufficient_content No complete sentences to scan. Ask the user for more text
400 max_words_exceeded Over 25,000 words. details has limit and actual. Split the document
400 invalid_idempotency_key Key longer than 255 characters
401 none Missing, invalid or expired API key
403 insufficient_credits Not enough credits. details has credits and required
403 credits_exhausted / trial_expired Plan or trial is used up
409 idempotency_key_reuse Same key sent with different text. Generate a new key
429 none Rate limited. Wait for the Retry-After header
503 service_unavailable Temporary. Retry after Retry-After if present
503 service_error Scan failed. Credits were refunded automatically

If you need to scan something longer than 25,000 words, split it on paragraph or section boundaries, scan each part, and keep track of each part's starting offset so you can shift its highlights back into the full document.

9. Pairing Plagiarism and AI Detection

Plagiarism and AI-generated text are different problems. A plagiarism scan asks whether this text matches something that already exists. An AI detector asks whether this text was likely written by a model. Fresh AI output usually won't match any web source, so a plagiarism check alone won't flag it, and copied human writing won't look like AI.

The same Proofademic key can call both if you gave it both scopes. The AI detector lives at POST /api/detector/ and returns an ai_score from 0 to 1 plus a per-sentence breakdown. Running both checks in parallel gives a reviewer the full picture:

const BASE_URL = "https://developer-portal.proofademic.ai/api"; async function post(path, body) { const res = await fetch(`${BASE_URL}${path}`, { method: "POST", headers: { "Content-Type": "application/json", "X-API-Key": process.env.PROOFADEMIC_API_KEY, }, body: JSON.stringify(body), }); const json = await res.json(); if (!res.ok) throw new Error(`${path}: ${json.code ?? res.status} ${json.error ?? ""}`); return json; } export async function originalityReport(text) { const [plagiarism, ai] = await Promise.all([ post("/plagiarism/scan/", { content: text }), post("/detector/", Source:https://dev.to/mohab_karim/plagiarism-checker-api-adding-originality-checks-to-your-app-51f6

Related posts