Skip to content
Copylint

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

StatusMeaningWhat to do
400Body is not JSON, or text is missing or over 200,000 charactersFix the body
401Missing, unknown, or revoked keyMint a new key
402No active plan on the accountChoose a plan on the billing page
42960 calls a minute, or the monthly plan limit (2,000 Solo, 20,000 Team)Honor Retry-After, or upgrade
503The API is not configured on this deploymentTry 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 profile

Without 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 $fail

The 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.