Source Match API
The Source Match endpoint compares a text with up to 20 source documents that you supply, such as reference
answers, earlier submissions, the pages a draft was researched from, or an internal corpus. For each source, it
returns every run of 8 or more words the two share word for word, as character offsets into your text, and an
overall similarity: the share of the text's words copied from any source.
Sapling doesn't search the web for sources. You send the documents to compare against, and nothing leaves Sapling to find more.
It detects copying, not paraphrase or authorship. To ask whether a text was written by AI, use the AI Detector API. You can call both and show the results side by side.
This endpoint is free of charge.
Sample Code
- cURL
- JavaScript
- Python
curl -X POST https://api.sapling.ai/api/v1/source-match \
-H "Content-Type: application/json" \
-d '{"key":"<api-key>", "text":"Our caching layer stores rendered pages for five minutes. When a page changes, the editor service publishes an invalidation event to every edge node, which drops the stale copy before the next request arrives. We chose this design because it keeps reads fast.", "sources":[{"id":"ref-answer", "text":"A good answer explains that the editor service publishes an invalidation event to every edge node, which drops the stale copy before the next request arrives, and discusses the trade-offs."}, {"text":"Caching is hard. Most teams start with a time-based expiry."}]}'
import axios from 'axios';
async function run(text, sources) {
try {
const response = await axios.post(
'https://api.sapling.ai/api/v1/source-match',
{
"key": '<api-key>',
text,
sources,
},
);
const {status, data} = response;
console.log({status});
console.log(JSON.stringify(data, null, 4));
} catch (err) {
const msg = err.response?.data?.msg || err.message;
console.log({err: msg});
}
}
const text = 'Our caching layer stores rendered pages for five minutes. When a page changes, the editor service publishes an invalidation event to every edge node, which drops the stale copy before the next request arrives. We chose this design because it keeps reads fast.';
const sources = [
{id: 'ref-answer', text: 'A good answer explains that the editor service publishes an invalidation event to every edge node, which drops the stale copy before the next request arrives, and discusses the trade-offs.'},
{text: 'Caching is hard. Most teams start with a time-based expiry.'},
];
run(text, sources);
import requests
from pprint import pprint
text = "Our caching layer stores rendered pages for five minutes. When a page changes, the editor service publishes an invalidation event to every edge node, which drops the stale copy before the next request arrives. We chose this design because it keeps reads fast."
sources = [
{"id": "ref-answer", "text": "A good answer explains that the editor service publishes an invalidation event to every edge node, which drops the stale copy before the next request arrives, and discusses the trade-offs."},
{"text": "Caching is hard. Most teams start with a time-based expiry."},
]
response = requests.post(
"https://api.sapling.ai/api/v1/source-match",
json={
"key": "<api-key>",
"text": text,
"sources": sources,
}
)
if 200 <= response.status_code < 300:
pprint(response.json())
else:
print('Error: ', response.status_code, response.text)
Request Parameters
https://api.sapling.ai/api/v1/source-match
HTTP method: POST
key: String
32-character API key. Can also be supplied via the Authorization header as a bearer token; if both are provided, the key parameter takes precedence.
text: String
The text to check, as plain text. Up to 50,000 characters. It is compared exactly as sent (HTML isn't stripped), so
span offsets line up with your own copy.
sources: List[Object]
1 to 20 documents to compare against. Each is an object with:
text(String, required): the source's plain text, up to 100,000 characters. All sources together can be up to 300,000 characters.id(String, optional): your label for the source, up to 128 characters and unique within the request. It is echoed back on the matching source.
min_words: Integer Optional
The shortest shared run of words to report, from 6 to 50. Defaults to 8.
exclude_quotes: Boolean Optional
Ignore quoted passages, as plagiarism checkers do. Words inside double quotation marks (straight "…" or curly
“…”, German „…“, guillemets «…», and CJK corner brackets 「…」 and 『…』) or on block-quote lines starting
with > never count as matched, and no span runs through them. Single quotes aren't treated as quotation marks,
because they double as apostrophes. A quote still open at a blank line is ignored, so one unbalanced mark can't hide
the rest of the text. Defaults to false.
Response Parameters
The response for the cURL request above:
{
"similarity": 0.488,
"matched_words": 21,
"text_words": 43,
"quoted_words": 0,
"sources_checked": 2,
"sources": [
{
"index": 0,
"id": "ref-answer",
"matched_words": 21,
"matched_fraction": 0.488,
"spans": [
{
"start": 79,
"end": 208,
"words": 21,
"text": "the editor service publishes an invalidation event to every edge node, which drops the stale copy before the next request arrives"
}
]
}
],
"min_words": 8,
"exclude_quotes": false,
"version": "0.2.0"
}
The second source shares no run of 8 words with the text, so it isn't listed.
similarity: Float
The share of the text's words that are inside a span from any source, from 0 to 1. Words copied from more than one
source count once.
matched_words, text_words: Integer
The number of the text's words inside any span, and the text's total word count.
quoted_words: Integer
The number of words set aside by exclude_quotes, or 0 when it is off. They still count in text_words.
sources_checked: Integer
The number of sources in the request.
sources: List[Object]
The sources that share at least one span with the text, most matched words first. Sources with no span are left
out. Each has:
index: the source's position in the request'ssourceslist.id: theidyou sent, ornullif you didn't send one.matched_wordsandmatched_fraction: how many of the text's words, and what share of them, are inside a span from this source.spans: the shared passages, each withstartandend(character offsets intotext,endexclusive),words, andtext(the passage as it appears in your text).
min_words: Integer
The minimum span length used.
exclude_quotes: Boolean
Whether quoted passages were ignored.
version: String
The version of the matching algorithm.
How matching works
A span is a run of at least min_words consecutive words that appears in both the text and a source. Matching
ignores case and punctuation, and treats curly and straight apostrophes as the same, so a passage still matches
after its punctuation is edited. Technical terms such as C++ and Node.js stay whole.
The default of 8 words is the usual floor for copy detection. Shorter runs, such as "one of the most important
things", turn up in unrelated documents and would inflate similarity. Raise min_words to report only longer
passages. Lower it, down to 6, for short texts such as answers to a single question.
The same inputs give the same result. No AI model is involved, so responses are fast.
Quoted passages
Suppose the middle sentence of the sample text above quoted the reference answer instead, making the text:
Our caching layer stores rendered pages for five minutes. As the reference answer puts it, "the editor service publishes an invalidation event to every edge node, which drops the stale copy before the next request arrives." We chose this design because it keeps reads fast.
The same 21-word span is reported, and similarity is 0.467 (21 of the 45 words). With "exclude_quotes": true, those 21 words are set aside:
quoted_words is 21, no source is listed, and similarity is 0. Quoted words still count in text_words, so
quoting a source lowers similarity rather than raising it.
Interpreting the result
- Copying is not paraphrase. A passage reworded so that no run of
min_wordswords survives isn't reported. A lowsimilaritydoesn't show that a text is original. - Copying is not always misconduct. Quotations, shared boilerplate, and a question repeated in its answer all
produce spans. Set
exclude_quotesto drop quoted passages, and read the spans before acting onsimilarity. - Results depend on the sources you send. Only the documents in the request are checked.
- It is tuned for languages written with spaces between words. Text in Chinese, Japanese or Thai, for example, matches poorly.
Errors
| Status | Cause |
|---|---|
400 | text or sources is missing or empty, a value has the wrong type, a length or count limit is exceeded, a text contains a NUL character, an id is empty, too long or repeated, min_words is outside 6–50, or exclude_quotes isn't a boolean. |
401 | The API key is missing or invalid. |
402 | The account can't make API requests, for example because its quota is used up. |
413 | The request body is too large. |
429 | Rate limited. The text and every source count against your key's rate limit. |
The 400 and 401 responses have a msg field with the reason.
Notes
- Offsets count Unicode code points (Python
strindexing), not UTF-16 code units or bytes. In JavaScript, useArray.from(text)if the input may contain characters outside the Basic Multilingual Plane. - This endpoint doesn't save the text or the sources, and its usage events record only their sizes, how many sources were sent and matched, and the resulting similarity.
- To compare PDF or Word documents, extract their text first with the file-to-text routes.
- For a resume against the job posting it was submitted for, use the Job Description Match API, which also reports keyword coverage.