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.
Start a scan
POST to /api/ci/scans/website, /pdf, or /image with your API key. Get back a jobId immediately.
Poll (or wait)
GET /api/ci/jobs/{jobId} — status moves Queued → Processing → Completed/Failed.
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": "…" }.
| Field | Meaning |
|---|---|
200 OK | GET /api/ci/jobs/{id} (and /pages) — job found. result is null until Completed. |
202 Accepted | POST /api/ci/scans/{website|pdf|image} — job queued. Body is the job envelope with status Queued. |
400 Bad Request | Missing domain/file, unsupported image type, PDF too large, or invalid webhook URL. |
401 Unauthorized | Missing or invalid X-API-Key / Bearer token. |
402 Payment Required | Not enough scan credits. Website, PDF, and image API scans each cost 1 credit — no IP free quota. |
403 Forbidden | The job belongs to a different account. |
404 Not Found | Unknown job id. |
429 Too Many Requests | Rate limit (120 requests/minute per IP on /api/ci/*). |
503 Service Unavailable | Image identification is temporarily disabled. |
Field reference
Every field on the job envelope and on result.
Job envelope
| Field | Meaning |
|---|---|
jobId | Unique id for this job. Same value as result.jobId. |
jobType | website, pdf, or image. |
status | Queued → Processing → Completed, Failed, or Cancelled. |
createdAt | When the job was accepted (ISO-8601 UTC). |
completedAt | When the job finished. Null until then. |
errorMessage | Failure reason. Null on success. |
result | Null until Completed. Then FontScannerScanResult, below. |
result
| Field | Meaning |
|---|---|
jobId | Same as the envelope jobId. |
domain | Website host, uploaded PDF filename, or image filename. |
status | Completed when result is present. |
scanTime | When scanning finished (ISO-8601 UTC). Same instant as completedAt. |
jobType | website, pdf, or image. |
matchedKnownFonts | Commercial / catalog fonts that need a license. Empty if none. |
matchedKnownFonts[].fontName | Canonical font family name. |
matchedKnownFonts[].vendor | Foundry or vendor (e.g. Mark Simonson). |
matchedKnownFonts[].licenseType | Usually Commercial. A non-empty list means fail the build. |
pages | Per-page font list. First 50 website pages; PDF/image jobs use one synthetic page. More pages: GET /api/ci/jobs/{id}/pages. |
pages[].id | Page id. Pass to GET /api/ci/jobs/{jobId}/pages/{pageId} for the CSS snippet. |
pages[].url | Crawled URL, or the PDF/image filename. |
pages[].status | Completed or Failed for that page. |
pages[].scannedAt | When that page was scanned. |
pages[].fonts | Fonts detected on this page. |
result.pages[].fonts[]
| Field | Meaning |
|---|---|
fontName | Normalized family name shown in the scan report. |
fontFamily | Raw CSS font-family token. |
sourceType | GoogleFonts, Typekit, BunnyFonts, SelfHosted, System, Pdf, Image, or Unknown. |
sourceUrl | Stylesheet or font-file URL when we have one. |
detectionContext | Where it was found, e.g. @font-face, computed style, pdf-upload, image. |
sourceSnippet | Null on the job payload. Fetch the single-page endpoint for the CSS rule. |
isLicensed | true if a commercial web license must be purchased or verified. |
isDeclaredCommercial | Named in CSS only — file was not served. |
licenseName | License label from the catalog, e.g. Commercial. |
licenseVendor | Vendor for this usage. |
isIconFont | Icon/symbol font, not a text typeface. |
isUnverified | Self-hosted file that needs a manual rights check. |
purchaseUrl | Where to buy or confirm the license. Null when not applicable. |
licensingHint | Short human-readable guidance. |
evidenceKind | ServedWebfont, CdnWebfont, CssDeclaration, RenderedStyle, ImageIdentification, and similar. |
licensingStatus | ActionRequired (license needed), OpenSource, DeclaredOnly, ManualReview, IconFont, SystemFont, Informational. |
distributesFontFile | true 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
fiFrequently 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