Built for developers

Font & license checks, wired straight into your pipeline

Start a scan with one API call — website, PDF, or image — and get a slim JSON result: commercial matches and per-page fonts. Poll it or have us push it to a webhook. Fail the build when an unlicensed commercial font sneaks in.

Async by default

Every scan starts instantly and runs in the background — never blocks your pipeline waiting on a slow crawl or vision call.

Webhooks or polling

Poll GET /api/ci/jobs/{id}, or pass a webhook URL at start time and we'll POST the result the moment it's ready.

Slim result, as JSON

A slim result: job id, domain, status, scan time, commercial matches, and per-page fonts with license flags.

How it works

One authentication scheme, three scan types. Poll and webhook share one job envelope; completed jobs put the same slim result in result.

1

Start a scan

POST to /api/ci/scans/website, /pdf, or /image with your API key. Get back a jobId immediately.

2

Poll (or wait)

GET /api/ci/jobs/{jobId} — status moves Queued → Processing → Completed/Failed.

3

Act on the result

Once Completed, result is the slim scan model (matched fonts + pages). Fail your build, post a PR comment, whatever you need.

Quickstart

Authenticate with the X-API-Key header — grab a key from your Profile → API Keys tab.

curl -X POST https://api.fontscanner.app/api/ci/scans/website \
  -H "X-API-Key: fsk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"domain": "yourdomain.com"}'

Responds 202 Accepted with { jobId, jobType, status: "Queued" }. Website, PDF, and image scans each use 1 credit. There is no IP free tier on the API — insufficient credits return 402.

Poll for the result

curl https://api.fontscanner.app/api/ci/jobs/{jobId} \
  -H "X-API-Key: fsk_your_key_here"

The job payload inlines the first 50 pages. Page through the rest (or request CSS snippets) with GET /api/ci/jobs/{jobId}/pages and GET /api/ci/jobs/{jobId}/pages/{pageId}— same as the dashboard scan report.

Example response, once completed

result is always the same slim model: jobId, domain, status, scanTime, jobType, matchedKnownFonts, pages. Website jobs list crawled pages; PDF and image jobs put detected fonts on a single page row.

{
  "jobId": "8f14e7c1-4b2a-4d8e-9c11-2a1f8e4c9a2",
  "jobType": "website",
  "status": "Completed",
  "createdAt": "2026-09-11T18:02:04Z",
  "completedAt": "2026-09-11T18:02:41Z",
  "errorMessage": null,
  "result": {
    "jobId": "8f14e7c1-4b2a-4d8e-9c11-2a1f8e4c9a2",
    "domain": "yourdomain.com",
    "status": "Completed",
    "scanTime": "2026-09-11T18:02:41Z",
    "jobType": "website",
    "matchedKnownFonts": [{
      "fontName": "Proxima Nova",
      "vendor": "Mark Simonson",
      "licenseType": "Commercial"
    }],
    "pages": [{
      "id": "c3a91b2e-7d44-4f1a-9b08-11e6a4d82f01",
      "url": "https://yourdomain.com/",
      "status": "Completed",
      "scannedAt": "2026-09-11T18:02:19Z",
      "fonts": [{
        "fontName": "Proxima Nova",
        "fontFamily": "Proxima Nova",
        "sourceType": "SelfHosted",
        "sourceUrl": "https://yourdomain.com/fonts/proxima-nova.woff2",
        "detectionContext": "@font-face",
        "sourceSnippet": null,
        "isLicensed": true,
        "isDeclaredCommercial": false,
        "licenseName": "Commercial",
        "licenseVendor": "Mark Simonson",
        "isIconFont": false,
        "isUnverified": false,
        "purchaseUrl": "https://www.marksimonson.com/fonts/view/proxima-nova",
        "licensingHint": "A paid web license is required to serve this font.",
        "evidenceKind": "ServedWebfont",
        "licensingStatus": "ActionRequired",
        "distributesFontFile": true
      }]
    }]
  }
}

Skip polling — use a webhook

Pass webhook.enabled: true when you start a scan and we'll POST the finished job to your URL, with any headers you specify attached (e.g. a bearer token your endpoint expects).

curl -X POST https://api.fontscanner.app/api/ci/scans/website \
  -H "X-API-Key: fsk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "domain": "yourdomain.com",
    "webhook": {
      "enabled": true,
      "url": "https://ci.example.com/fontscanner-callback",
      "headers": { "Authorization": "Bearer <your-ci-token>" }
    }
  }'

When the job finishes we POST the job envelope to your URL. Model result as FontScannerScanResult — that is the payload we send and the shape your endpoint should accept.

Your webhook handler model

// What we POST to your webhook URL — same JSON as GET /api/ci/jobs/{id}.
// Deserialize `result` as FontScannerScanResult when status is "Completed".

type FontScannerJob = {
  jobId: string;
  jobType: "website" | "pdf" | "image";
  status: "Queued" | "Processing" | "Completed" | "Failed" | "Cancelled";
  createdAt: string;          // ISO-8601
  completedAt: string | null;
  errorMessage: string | null;
  result: FontScannerScanResult | null;
};

type FontScannerScanResult = {
  jobId: string;
  domain: string;             // scanned host, PDF filename, or image filename
  status: "Completed";
  scanTime: string | null;    // when the job finished (ISO-8601)
  jobType: "website" | "pdf" | "image";
  matchedKnownFonts: {
    fontName: string;
    vendor: string;
    licenseType: string;      // e.g. "Commercial"
  }[];
  pages: FontScannerPage[];
};

type FontScannerPage = {
  id: string;
  url: string;
  status: string;
  scannedAt: string | null;
  fonts: FontScannerFont[];
};

type FontScannerFont = {
  fontName: string;
  fontFamily: string;
  sourceType: string;         // GoogleFonts | Typekit | SelfHosted | Pdf | Image | …
  sourceUrl: string | null;
  detectionContext: string | null;
  sourceSnippet: string | null;
  isLicensed: boolean;        // true → commercial license required
  isDeclaredCommercial: boolean;
  licenseName: string | null;
  licenseVendor: string | null;
  isIconFont: boolean;
  isUnverified: boolean;
  purchaseUrl: string | null;
  licensingHint: string | null;
  evidenceKind: string;
  licensingStatus: string;    // ActionRequired | OpenSource | ...
  distributesFontFile: boolean;
};

For the PDF/image multipart endpoints, pass the same object as a webhook form field, JSON-encoded as a string. webhook.url must be a public http(s) address — we validate it (and re-validate at delivery time) so it can't be pointed at a private or internal address, and we retry failed deliveries with backoff.

HTTP status codes

Success and error bodies use JSON. Errors are { "error": "…" }.

FieldMeaning
200 OKGET /api/ci/jobs/{id} (and /pages) — job found. result is null until Completed.
202 AcceptedPOST /api/ci/scans/{website|pdf|image} — job queued. Body is the job envelope with status Queued.
400 Bad RequestMissing domain/file, unsupported image type, PDF too large, or invalid webhook URL.
401 UnauthorizedMissing or invalid X-API-Key / Bearer token.
402 Payment RequiredNot enough scan credits. Website, PDF, and image API scans each cost 1 credit — no IP free quota.
403 ForbiddenThe job belongs to a different account.
404 Not FoundUnknown job id.
429 Too Many RequestsRate limit (120 requests/minute per IP on /api/ci/*).
503 Service UnavailableImage identification is temporarily disabled.

Field reference

Every field on the job envelope and on result.

Job envelope

FieldMeaning
jobIdUnique id for this job. Same value as result.jobId.
jobTypewebsite, pdf, or image.
statusQueued → Processing → Completed, Failed, or Cancelled.
createdAtWhen the job was accepted (ISO-8601 UTC).
completedAtWhen the job finished. Null until then.
errorMessageFailure reason. Null on success.
resultNull until Completed. Then FontScannerScanResult, below.

result

FieldMeaning
jobIdSame as the envelope jobId.
domainWebsite host, uploaded PDF filename, or image filename.
statusCompleted when result is present.
scanTimeWhen scanning finished (ISO-8601 UTC). Same instant as completedAt.
jobTypewebsite, pdf, or image.
matchedKnownFontsCommercial / catalog fonts that need a license. Empty if none.
matchedKnownFonts[].fontNameCanonical font family name.
matchedKnownFonts[].vendorFoundry or vendor (e.g. Mark Simonson).
matchedKnownFonts[].licenseTypeUsually Commercial. A non-empty list means fail the build.
pagesPer-page font list. First 50 website pages; PDF/image jobs use one synthetic page. More pages: GET /api/ci/jobs/{id}/pages.
pages[].idPage id. Pass to GET /api/ci/jobs/{jobId}/pages/{pageId} for the CSS snippet.
pages[].urlCrawled URL, or the PDF/image filename.
pages[].statusCompleted or Failed for that page.
pages[].scannedAtWhen that page was scanned.
pages[].fontsFonts detected on this page.

result.pages[].fonts[]

FieldMeaning
fontNameNormalized family name shown in the scan report.
fontFamilyRaw CSS font-family token.
sourceTypeGoogleFonts, Typekit, BunnyFonts, SelfHosted, System, Pdf, Image, or Unknown.
sourceUrlStylesheet or font-file URL when we have one.
detectionContextWhere it was found, e.g. @font-face, computed style, pdf-upload, image.
sourceSnippetNull on the job payload. Fetch the single-page endpoint for the CSS rule.
isLicensedtrue if a commercial web license must be purchased or verified.
isDeclaredCommercialNamed in CSS only — file was not served.
licenseNameLicense label from the catalog, e.g. Commercial.
licenseVendorVendor for this usage.
isIconFontIcon/symbol font, not a text typeface.
isUnverifiedSelf-hosted file that needs a manual rights check.
purchaseUrlWhere to buy or confirm the license. Null when not applicable.
licensingHintShort human-readable guidance.
evidenceKindServedWebfont, CdnWebfont, CssDeclaration, RenderedStyle, ImageIdentification, and similar.
licensingStatusActionRequired (license needed), OpenSource, DeclaredOnly, ManualReview, IconFont, SystemFont, Informational.
distributesFontFiletrue if the site actually served a font file.

Fail a GitHub Actions build on unlicensed fonts

A complete example: start a scan, poll until it finishes, and exit non-zero if a licensed commercial font shows up unaccounted for.

.github/workflows/font-check.yml

- name: FontScanner license check
  run: |
    JOB=$(curl -sf -X POST https://api.fontscanner.app/api/ci/scans/website \
      -H "X-API-Key: ${{ secrets.FONTSCANNER_API_KEY }}" \
      -H "Content-Type: application/json" \
      -d '{"domain": "yourdomain.com"}')
    JOB_ID=$(echo "$JOB" | jq -r '.jobId')

    for i in $(seq 1 60); do
      RESULT=$(curl -sf https://api.fontscanner.app/api/ci/jobs/$JOB_ID \
        -H "X-API-Key: ${{ secrets.FONTSCANNER_API_KEY }}")
      STATUS=$(echo "$RESULT" | jq -r '.status')
      [ "$STATUS" = "Completed" ] || [ "$STATUS" = "Failed" ] && break
      sleep 15
    done

    LICENSED=$(echo "$RESULT" | jq -r '.result.matchedKnownFonts | length')
    if [ "$LICENSED" -gt 0 ]; then
      echo "::error::$LICENSED unlicensed commercial font(s) detected"; exit 1
    fi

Frequently asked questions

How do I authenticate API requests?

Generate an API key from your Profile page (API Keys tab) and send it in the X-API-Key header. The same key works for every FontScanner endpoint, including the CI/CD-specific ones.

What scan types does the API support?

Three: a full website crawl, a PDF upload, and image-based font identification (from a screenshot or design mockup). All three are started, polled, and delivered via webhook the same way.

How do I get the result — do I have to poll?

Either works. Poll GET /api/ci/jobs/{id} until status is Completed, or pass webhook: { enabled: true, url, headers } when you start the scan. We'll POST the same job JSON (slim result inside) to your URL as soon as it's ready.

Does the API return the same data as the dashboard report?

The CI result is a slim subset of the dashboard report: jobId, domain, status, scanTime, jobType, matchedKnownFonts (name, vendor, license type), and pages with per-font license flags. The full dashboard JSON remains on GET /api/scans/{id}.

Does starting a scan through the API use my credits?

Yes. Website, PDF, and image API scans each use 1 scan credit — same as starting them from the dashboard. There is no IP-based free quota on the API. A 402 response means you need to buy credits.

Ready to wire it in?

Create a free account, grab an API key, and run your first scan from a terminal in under a minute.

Create a free account