Scan API
One endpoint. Same rules and scores as the app and the open-source CLI, with every call saved to your history. Keys are minted at /app/keys and need an active plan. Every rule and its weight is listed in the rules reference.
Authentication
Send the key as a bearer token. Only its hash is stored, so a lost key is replaced, never recovered.
Authorization: Bearer cl_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
POST /api/v1/scan
JSON body. Only text is required.
{
"text": "In today's evolving landscape, our platform is a game-changer.",
"mode": "prose", // prose | chat | ui (default prose)
"threshold": 40, // 1..100 (default 40)
"title": "launch email",// shown in history (default "API scan")
"personal": false // apply the team voice profile (Team plan)
}curl -X POST https://copylint.splitlabs.io/api/v1/scan \
-H "Authorization: Bearer $COPYLINT_KEY" \
-H "Content-Type: application/json" \
-d '{"text":"In today'"'"'s evolving landscape, our platform is a game-changer.","title":"launch email"}'Response
The shape of scan_text.py --json plus a usage block. Critical findings add 20, major add 10, minor add 3, capped at 100. pass is true below the threshold.
{
"score": 40,
"threshold": 40,
"pass": false,
"personal": false,
"findings": [
{ "severity": "critical", "rule": "critical phrase", "detail": "game-changer",
"line": 1, "excerpt": "In today's evolving landscape, our platform is a game-changer.", "suggestion": null },
{ "severity": "critical", "rule": "critical phrase", "detail": "evolving landscape", "line": 1, "excerpt": "...", "suggestion": null }
],
"usage": { "used": 13, "limit": 2000 }
}Suggested fixes
Each finding carries a suggestion when the catalog has a plain fix, and null when the phrase needs a real fact instead. from is the exact text to change, at its offset in the text you sent (UTF-16 code units, as in JavaScript), and to its replacement; an empty to deletes. safe is true for word-level edits that keep the meaning, and false for sentence deletions and words that also have a literal sense. Apply one fix, then scan again: the offsets of later findings move. Suggestions never change the score. AI rewrites of single sentences are in the web app, not the API.
{ "severity": "major", "rule": "major phrase", "detail": "in order to", "line": 2,
"suggestion": { "label": "Replace with \"to\"", "from": "In order to", "to": "to", "at": 57, "safe": true } }Errors and limits
| Status | Meaning | What to do |
|---|---|---|
| 400 | Body is not JSON, or text is missing or over 200,000 characters | Fix the body |
| 401 | Missing, unknown, or revoked key | Mint a new key |
| 402 | No active plan on the account | Choose a plan on the billing page |
| 429 | 60 calls a minute, or the monthly plan limit (2,000 Solo, 20,000 Team) | Honor Retry-After, or upgrade |
| 503 | The API is not configured on this deployment | Try the production host |
CI recipe
The Copylint GitHub Action scans the Markdown a pull request changed, annotates each finding inline, and fails the job when a file scores at or above the threshold. Store the key as a repository secret named COPYLINT_KEY.
# .github/workflows/copy.yml
name: copy
on: [pull_request]
permissions:
contents: read
jobs:
copylint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: 0xNyk/copylint-action@v1
with:
api-key: ${{ secrets.COPYLINT_KEY }}
threshold: 40 # optional
personal: false # Team plan: apply the org voice profileWithout the Action, the same gate is a shell step. The exit code carries the verdict, the same way the open-source CLI does.
# .github/workflows/copy.yml
name: copy
on: [pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- name: Scan changed Markdown
env:
COPYLINT_KEY: ${{ secrets.COPYLINT_KEY }}
run: |
fail=0
for f in $(git diff --name-only --diff-filter=d origin/${{ github.base_ref }} -- '*.md'); do
body=$(jq -Rs --arg t "$f" '{text: ., title: $t}' "$f")
res=$(curl -s -X POST https://copylint.splitlabs.io/api/v1/scan \
-H "Authorization: Bearer $COPYLINT_KEY" -H "Content-Type: application/json" -d "$body")
score=$(echo "$res" | jq .score); pass=$(echo "$res" | jq .pass)
echo "$f: $score/100"
echo "$res" | jq -r --arg f "$f" '.findings[] | "::warning file=\($f),line=\(.line // 1)::\(.severity) \(.rule): \(.detail)"'
[ "$pass" = "true" ] || fail=1
done
exit $failThe warnings become inline annotations on the pull request, the same output the Action produces.
Voice profile
Team accounts build a profile from their own published writing on the voice page. Send "personal": true and the profile relaxes four stylistic budgets: exclamation marks, questions, sentence-length monotony, and the team's favorite openers. It never touches the phrase catalog, the dash rule, template artifacts, or unsupported claims, so a profiled score is never higher than the plain one. The response reports "personal": true when a profile was applied.
Parity with the CLI
The hosted scanner is a port of unmachined's scan_text.py. Every rule is covered, and a parity check scores a fixture set through both on every commit; a difference fails the build. If the CLI and the API disagree on your text, that is a bug we want to hear about.