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
- What a Plagiarism Checker API Gives You
- Getting an API Key
- Your First Scan
- Understanding the Response
- Node.js: A Reusable Client
- Python: Safe Retries With Idempotency Keys
- Rendering Highlights in Your UI
- Handling Errors
- Pairing Plagiarism and AI Detection
- Pricing, Limits and Cost Planning
- Already Using Walter's API?
- Production Checklist
- Conclusion
- 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.
- Click Create API Key.
- Give it a descriptive name, such as
production-cms. - Under Model Access, select Plagiarism Checker. Add AI Detector too if you plan to run both checks (section 9).
- Choose an expiration: Never, 30, 60 or 90 days.
- 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) => ({
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
})[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/", { content: text }),
]);
return {
plagiarism: { score: plagiarism.plagiarism_score, verdict: plagiarism.verdict },
ai: { score: ai.ai_score, result: ai.result },
needsReview:
plagiarism.plagiarism_score >= 0.15 || ai.ai_score > 0.7,
};
}
The thresholds in needsReview are an example, not a recommendation. Pick yours based on what a false positive costs in your product. For anything with real consequences, such as grades or contracts, route flagged work to a human instead of acting on scores automatically.
Keep the cost difference in mind too. A plagiarism scan is a flat 2 credits, while the AI detector charges 1 credit per word. A 2,000-word essay costs 2 credits to scan for plagiarism and 2,000 credits for AI detection.
For long texts, the detector also supports async processing with polling or a webhook (poll: true or callback_url). The plagiarism endpoint is synchronous only, so give its HTTP call a generous timeout.
10. Pricing, Limits and Cost Planning
Proofademic uses one shared pool of credits across all its APIs. Plagiarism scans are a flat 2 credits regardless of length, and failed scans are refunded automatically.
| Plan | Credits per month | Price per month |
|---|---|---|
| 300K | 300,000 | $49 |
| 1M | 1,000,000 | $129 |
| 2M | 2,000,000 | $229 |
| 5M | 5,000,000 | $479 |
| 12M | 12,000,000 | $899 |
| 25M | 25,000,000 | $1,699 |
Credits reset monthly and don't roll over. The free trial includes 2,000 credits for 5 days.
Because the price is per scan rather than per word, the math is simple. If you only use the plagiarism checker, the 300K plan covers up to 150,000 scans a month. If you also run the AI detector, budget for that by word count, since it will dominate your spend.
Rate limits from the error-handling docs:
| Scope | Default limit |
|---|---|
| Per IP (burst) | 60 requests per minute |
| Per IP (sustained) | 3,000 requests per hour |
| Per user (global) | 60 requests per minute |
| AI detector (sync) | 10 requests per minute |
The docs note that limits may be higher on larger plans. If you're scanning a backlog, put the work in a queue and keep under 60 requests a minute rather than firing everything at once.
The number of active API keys also depends on your plan: 1 on the trial, 5 on the 300K and 1M plans, 10 on 2M and 5M, and 25 on 12M and 25M.
11. Already Using Walter's API?
If your app already uses Walter Writes AI for humanizing or AI detection, you don't need a second vendor for plagiarism. Walter's API has the same plagiarism endpoint, POST /api/plagiarism/scan/, with the same request body, response fields, verdicts, idempotency behavior and flat 2-credit price. The differences are the base URL (https://developer-portal.walterwrites.ai/api) and the key, which needs the plagiarism checker scope.
So pick based on what else your app does. Proofademic fits products built around academic integrity and AI detection. Walter fits products that already humanize or rewrite content. The code in this post works with either one by changing the base URL and key.
12. Production Checklist
- Keep the API key on the server and in environment variables or a secrets manager.
- Create one key per environment, and give each key only the scopes it needs.
- Validate input before calling: at least 50 characters, at most 25,000 words.
- Send an
Idempotency-Keyand reuse it across retries of the same scan. - Retry only on
429and5xx, and honorRetry-After. - Store
scan_id, the verdict, the score and the exact submitted text together. - Escape user text and source titles when rendering highlights.
- Watch
credits_remainingand alert before you run out. - Show matched sources to users instead of only a score, and send borderline results to a person for review.
13. Conclusion
Adding originality checks used to mean picking between an expensive enterprise integration and building something weak in-house. With a plagiarism checker API, it's one authenticated POST request and some careful handling of the response.
The parts that make it production-ready are small: idempotency keys so retries never double-charge, offset-based highlights so users can see what matched, and clear error handling around the limits. Add AI detection next to it, and your app can answer both questions reviewers ask now: was this copied, and was this written by a model?
14. FAQs
What does a plagiarism checker API do?
It compares submitted text against existing sources and returns a similarity score, the sources that matched and the passages that overlap. Proofademic's API returns a 0 to 1 plagiarism_score, a verdict, matched sources with URLs and character-offset highlights.
Does Proofademic have a plagiarism checker API?
Yes. Proofademic launched its plagiarism checker and public API portal in July 2026. The endpoint is POST /api/plagiarism/scan/ on https://developer-portal.proofademic.ai/api, authenticated with an X-API-Key header that has the Plagiarism Checker scope.
Does Walter Writes AI have a plagiarism API?
Yes. Walter's API offers the same POST /api/plagiarism/scan/ endpoint with the same request and response format, at https://developer-portal.walterwrites.ai/api.
How much does a plagiarism scan cost?
A flat 2 credits per scan, regardless of length, up to 25,000 words. Failed scans are refunded automatically. Plans start at $49 a month for 300,000 credits, and the free trial includes 2,000 credits for 5 days.
What's the maximum text length?
25,000 words per scan, with a minimum of 50 characters. Longer documents need to be split into parts.
Is the plagiarism endpoint synchronous?
Yes, it's synchronous only. The response comes back in the same HTTP request, so set a generous timeout on your client.
How do I avoid being charged twice when retrying?
Send an Idempotency-Key header and reuse the same key for every retry of the same scan. Within 5 minutes, a repeat with the same key and text returns the original response without charging again.
Does a plagiarism check catch AI-generated text?
Not reliably. Fresh AI output usually doesn't match existing web pages. Use an AI detector, such as Proofademic's /api/detector/ endpoint, alongside the plagiarism check.
Top comments (0)