$ cleanthis --api
API docs
Clean files and scan links from your own code: uploads, links, webhooks, signed downloads and anonymous accounts.
https://cleanthis.io/api/v1getting started
Quick Start
Get up and running in four steps. Steps 1 and 2 happen in your browser and take about a minute; from step 3 on, everything is curl.
1 Create an account
cleanthis.io uses anonymous accounts — no email or password required. A random account number is your only credential.
Open Get an API key and create an account. You'll be shown an account number like CT-7K9M-X2P4-R8N1-Q5W3-J6L0-V4T2. Save it now: it is shown once, it is the only way to sign in, and it can't be recovered. Confirm you've saved it and continue: you're already signed in, so it takes you straight to your dashboard.
2 Create an API key
In the dashboard's API keys section, create a key. Choose test while you're wiring up your integration (requests are validated but nothing is processed, and they don't count against your quota) and live for real files.
The full key (ct_live_… or ct_test_…) is shown once. Copy it somewhere safe; the dashboard only shows its last four characters after that. Lost it? Revoke it and create a new one.
Everything below sends the key as a Bearer token. The examples use ct_live_Ab3xK9mP... — swap in yours.
3 Sanitize a file (upload → poll → download)
# Upload (default: standard level)
curl -s -X POST https://cleanthis.io/api/v1/sanitize \
-H "Authorization: Bearer ct_live_Ab3xK9mP..." \
-F "file=@document.pdf" | jq
# Upload with a specific sanitization level
curl -s -X POST https://cleanthis.io/api/v1/sanitize \
-H "Authorization: Bearer ct_live_Ab3xK9mP..." \
-F "file=@document.pdf" \
-F "level=aggressive" | jq
# Response:
# {
# "jobId": "a1b2c3d4-...",
# "status": "queued",
# "level": "aggressive",
# "statusUrl": "https://cleanthis.io/api/v1/job/a1b2c3d4-..."
# }
# Poll until complete
curl -s https://cleanthis.io/api/v1/job/JOB_ID \
-H "Authorization: Bearer ct_live_Ab3xK9mP..." | jq
# Response when complete includes a signed downloadUrl:
# {
# "status": "completed",
# "level": "aggressive",
# "downloadUrl": "https://cleanthis.io/api/v1/download/JOB_ID?expires=...&sig=..."
# }
# Download using the signed URL from the response
curl -o clean.pdf "DOWNLOAD_URL_FROM_RESPONSE" \
-H "Authorization: Bearer ct_live_Ab3xK9mP..."4 Sanitize from URL
# Default level (standard)
curl -s -X POST https://cleanthis.io/api/v1/sanitize-url \
-H "Authorization: Bearer ct_live_Ab3xK9mP..." \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/doc.pdf"}' | jq
# With explicit level
curl -s -X POST https://cleanthis.io/api/v1/sanitize-url \
-H "Authorization: Bearer ct_live_Ab3xK9mP..." \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/doc.pdf", "level": "light"}' | jq
# Same poll → download flow as abovegetting started
Authentication
Anonymous Accounts
cleanthis.io uses anonymous accounts. No email, no password — just a random account number (CT-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX) generated at signup. This number is shown once and cannot be recovered — store it securely.
API Keys
API keys are created via the dashboard or the POST /api/v1/account/keys endpoint. Two modes are available:
ct_live_*— Production keys. Requests count against your quota.ct_test_*— Test keys. Requests are fully validated but no file is processed and no real scan runs (you get a deterministic mock response), and they don't count against your quota — ideal for wiring up an integration.
CT-XXXX-XXXX-… value is how you log in; it is not a Bearer token. API keys always start with ct_live_ or ct_test_ followed by 36 characters. Sending the account number to an API endpoint returns 401 {"error":"Invalid API key format."}.
Getting an API key
- Create an account (if you don't have one) — you'll be given a
CT-…account number. Save it; it's shown only once and can't be recovered. - Log in at My Dashboard with that account number.
- Create a key from the dashboard's API-keys section. Pick
testwhile you're integrating,livefor real scans. The full key (ct_live_…/ct_test_…) is shown once — copy it immediately. - Use it as a Bearer token (see below).
Account creation and sign-in only work from the dashboard: they are protected against automated sign-ups, so a plain curl to POST /api/v1/account or /account/login is refused with 400. Once you have a key, every file and scan endpoint is fully scriptable. The account routes are listed under Account Management for reference.
Bearer Token Auth
All sanitization endpoints use Bearer token authentication:
Authorization: Bearer ct_live_YOUR_KEY
Session Auth
Dashboard and account management endpoints use cookie-based session authentication. Sessions are HttpOnly, Secure, and SameSite=Strict. Log in via POST /api/v1/account/login to receive the session cookie.
concepts
Sanitization Levels
Every sanitization request accepts an optional level parameter that controls how aggressively the file is processed. If omitted, standard is used.
| Level | Behaviour | Output Format |
|---|---|---|
light |
Virus scan + privacy metadata removal only. File content stays intact — no re-encoding, no macro removal. For images, preserves ICC color profile and orientation for correct display. Accepts virtually any file type that carries readable metadata. | Original format preserved |
standard |
Default. Full Content Disarm & Reconstruction — re-encode, strip macros, flatten scripts, remove all metadata and hidden content. | Original format preserved |
aggressive |
Everything in Standard, plus conversion to the safest possible format. Destroys editability for maximum protection. | Converted: Office→PDF, Images→PNG, Audio→WAV, Video→MP4, HTML→TXT |
How to specify
File upload (multipart/form-data) — send level as a form field:
curl -X POST https://cleanthis.io/api/v1/sanitize \ -H "Authorization: Bearer ct_live_YOUR_KEY" \ -F "file=@photo.jpg" \ -F "level=light"
URL fetch (application/json) — include level in the JSON body:
curl -X POST https://cleanthis.io/api/v1/sanitize-url \
-H "Authorization: Bearer ct_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/doc.pdf", "level": "aggressive"}'Level in responses
The chosen level is echoed back in both the initial 202 Accepted response and every subsequent poll response, so you can always confirm which level was applied:
{
"jobId": "a1b2c3d4-...",
"status": "queued",
"level": "aggressive",
"statusUrl": "https://cleanthis.io/api/v1/job/a1b2c3d4-..."
}What each level removes
- Light — GPS coordinates, author/creator names, timestamps, camera info, software version, comments. File content (macros, scripts, embedded objects) is left intact. Light mode accepts virtually any file type (RAW photos, PSD, archives, fonts, 3D models, and hundreds more). Only programs and installers (
.exe,.dll,.msi,.lnk, etc.) are blocked. Script source files (.js,.sh,.bat,.ps1,.py, …) are accepted as plain text. - Standard — Everything in Light, plus: VBA macros, ActiveX controls, JavaScript (PDF), embedded attachments, OLE objects, encryption, formula injection (CSV), script tags (HTML/SVG), event handlers, external references.
- Aggressive — Everything in Standard, plus: format conversion to eliminate any format-specific attack surface. The output file type may differ from the input.
concepts
Archives (ZIP, 7z, RAR, TAR family, single-file compression)
Uploading an archive triggers recursive sanitization: each member is sanitized for its own format (PDF, Office, images, etc.) using the chosen level, then everything is re-packaged.
Supported containers:
.zip— output is a fresh.zip.7z— output is a fresh.7z(LZMA2).rar— output is a.zip. There is no open-source RAR encoder, so RAR is a one-way conversion; the report carries aformat_convertedentry saying so. Encrypted and multi-volume RAR archives are rejected..tar,.tar.gz/.tgz,.tar.bz2/.tbz/.tbz2,.tar.xz/.txz,.tar.zst/.tzst— output is always.tar.gzregardless of input compression.gz,.bz2,.xz,.zst(single-file, not wrapping a tar) — decompress → sanitize the inner file → recompress back to the same format. The inner filename must carry an extension hint (e.g.report.csv.gz→ inner.csv) so the decompressed payload can be routed to the right pipeline. Standard/Aggressive reject single-file archives without an inner ext; Light accepts them.
Tar, 7z and RAR archives must contain only regular files and directories. Symbolic links, device files, named pipes, and sockets are rejected at the bomb-check stage — they have no place in a sanitization payload and represent attack surface on extraction.
Limits
- Maximum uncompressed size: 100 MB across all members
- Maximum member count: 100 files (single-file compression: one member by definition)
- Maximum compression ratio: 100:1 (archive-bomb guard)
- Maximum nesting depth: 1 level (an archive inside an archive is allowed; deeper is rejected — applies uniformly across all archive types, including mixed nesting like a
.tar.gzinside a.zipor a.zipinside a.7z)
Archives exceeding any of these are rejected at the bomb-check stage with a 400 response and an actionable error message (e.g. "Archive expands to 487MB, above our 100MB limit").
Content that can't be fully sanitized — archiveUnsupported
Standard and Aggressive levels cover 100+ file extensions with format-specific sanitization. When an archive member has an extension outside that set (rare formats, RAW photos, 3D models, …), the archiveUnsupported parameter controls what happens. Default is drop. This applies to both multi-member archives and single-file compression:
| Value | Multi-member archive (.zip / .7z / .rar / tar family) | Single-file compression (.gz / .bz2 / .xz / .zst) |
|---|---|---|
drop (default) |
Members that can't be fully sanitized are removed from the output. The output contains only files that received full level-grade sanitization. Removed paths are listed in the report under archive_dropped. |
With only one inner file, "drop" means rejecting the whole upload — the response is 400 with an actionable message that suggests Light mode or light_fallback. |
light_fallback |
Members that can't be fully sanitized fall back to Light treatment instead (virus scan + metadata strip). May contain files where macros / scripts / embedded objects were not removed. Listed in the report under archive_fallback. |
The inner file falls back to Light treatment, then is recompressed in the same wrapper. The report's innerTreatment field is set to "light_fallback" so consumers can detect the downgrade without parsing labels. |
Forced to drop when level=aggressive — Aggressive mode's promise is that no original bytes survive, which Light treatment would contradict.
Programs and installers (.exe, .dll, .msi, .lnk, .vbs, .hta, etc.) inside Standard/Aggressive archive jobs are always removed regardless of archiveUnsupported — both as archive members and as the inner file of a single-compressed upload. Every upload — including light_fallback payloads — is virus-scanned at the outer archive level before processing, with nested members (.zip / .7z / .gz / .tar etc.) covered recursively. Script source files (.js, .sh, .bat, .ps1) are not programs here: they are cleaned as plain text like any other text file. Cleaning removes null bytes, control characters and invisible Unicode (text-direction overrides and zero-width characters), not what the script does, so never run a script you didn't expect to receive.
Example
curl -X POST https://cleanthis.io/api/v1/sanitize \ -H "Authorization: Bearer ct_live_YOUR_KEY" \ -F "file=@bundle.zip" \ -F "level=standard" \ -F "archiveUnsupported=light_fallback"
Report
Archive jobs return a roll-up report with per-member tallies plus full per-file detail. The changes array includes summary entries with these type values:
archive— summary count (e.g. "Archive contained 12 files: 10 sanitized at Standard, 2 removed")archive_cleaned— list of members that were processed at the requested levelarchive_fallback— list of members that received Light treatment instead (only present whenarchiveUnsupported=light_fallback)archive_dropped— list of members removed (with reason: unsupported, blocked executable, bomb check)archive_errored— list of members the sanitizer failed on (with reason)
The job's report.memberReports array contains one entry per cleaned/fallback member with that member's own full sanitization report (the same shape as a single-file job), so API consumers can list exactly what was stripped from each file. Each entry has path, cleanedPath, treatment (the level it was actually processed at: "light" / "standard" / "aggressive" / "light_fallback"), and report.
The job's report.archiveStats object contains numeric counts: totalMembers, cleanedCount, fallbackCount, droppedCount, erroredCount, nestedArchiveCount, and policy.
Nested archives are flattened
When an archive contains a nested archive (zip or tar), that wrapper is not represented as a single entry in memberReports. Instead, its contents appear in the parent's memberReports / droppedMembers / erroredMembers arrays with paths prefixed by the wrapper (e.g. inner.tar.gz/photo.png). The result is one flat list of actual files, regardless of how deeply nested they were. The archiveStats.nestedArchiveCount field counts how many wrappers were flattened. Mixed nesting is fine (a .tar.gz inside a .zip or vice versa).
endpoints
Endpoints — Sanitization
| Method | Endpoint | Auth | Rate Limit | Description |
|---|---|---|---|---|
| POST | /api/v1/sanitize |
Bearer | 20/min | Upload file for sanitization. Optional level and webhook fields. |
| POST | /api/v1/sanitize-url |
Bearer | 20/min | Fetch from URL and sanitize. Optional level and webhook fields in JSON body. |
| GET | /api/v1/job/:id |
Bearer | 120/min | Poll job status. Returns downloadUrl when complete. |
| GET | /api/v1/download/:id |
Bearer | 30/min | Download sanitized file. Requires valid signed URL params (expires, sig). |
| POST | /api/v1/cancel/:id |
Bearer | 20/min | Cancel a queued or running job. |
POST /api/v1/sanitize
Upload a file for sanitization. Optionally include a webhook URL, a level, and (for archive / single-file-compressed uploads) an archiveUnsupported policy. See Sanitization Levels and Archives for details.
The maximum file size is 50 MB. A larger file is rejected with 400 {"error": "File too large. Maximum size is 50 MB."}.
POST /api/v1/sanitize Authorization: Bearer ct_live_YOUR_KEY Content-Type: multipart/form-data file: (binary) level: standard (optional: light | standard | aggressive) archiveUnsupported: drop (optional, archive + single-compressed: drop | light_fallback) webhook: https://your-server.com/callback (optional)
Response 202 Accepted:
{
"jobId": "a1b2c3d4-...",
"status": "queued",
"level": "standard",
"statusUrl": "https://cleanthis.io/api/v1/job/a1b2c3d4-...",
"message": "File accepted for sanitization. Poll the statusUrl for progress.",
"webhook": { "url": "https://your-server.com/callback", "status": "registered" }
}POST /api/v1/sanitize-url
Provide a URL to fetch and sanitize. The server downloads the file, then processes it. Accepts the same level and archiveUnsupported parameters as the file upload endpoint. The same 50 MB limit applies to the downloaded file: a larger download is stopped and the request returns 400.
POST /api/v1/sanitize-url
Authorization: Bearer ct_live_YOUR_KEY
Content-Type: application/json
{
"url": "https://example.com/bundle.zip",
"level": "standard",
"archiveUnsupported": "light_fallback",
"webhook": "https://your-server.com/callback"
}Response 202 Accepted:
{
"jobId": "e5f6g7h8-...",
"status": "queued",
"level": "standard",
"statusUrl": "https://cleanthis.io/api/v1/job/e5f6g7h8-...",
"message": "URL accepted for sanitization. Poll the statusUrl for progress.",
"webhook": { "url": "https://your-server.com/callback", "status": "registered" }
}GET /api/v1/job/:id
Poll job status. The response changes as the job progresses. The level field is always included so you can confirm which sanitization mode was applied.
status is one of:
queued— waiting for a free worker.queuePositionandqueueLengthsay where it is in line.processing— being sanitized now.completed— done. The response carriesdownloadUrl,downloadNameandreport.failed— the file could not be sanitized.errorsays why, in plain words.
A finished job (completed or failed) is kept for about 15 minutes, then it and its files are deleted. After that, or after you cancel a job, polling returns 404.
Queued / In progress:
{
"jobId": "a1b2c3d4-...",
"status": "queued",
"level": "standard",
"queuePosition": 2,
"queueLength": 3
}Failed:
{
"jobId": "a1b2c3d4-...",
"status": "failed",
"level": "standard",
"error": "Processing failed. Please try again."
}Completed:
{
"jobId": "a1b2c3d4-...",
"status": "completed",
"level": "standard",
"downloadName": "doc_cleaned.pdf",
"downloadUrl": "https://cleanthis.io/api/v1/download/a1b2c3d4-...?expires=...&sig=...",
"report": {
"changes": [
{ "type": "level", "label": "Sanitization level: Standard — full CDR pipeline" },
{ "type": "macros", "label": "Removed 1 macro component: VBA Project" },
{ "type": "metadata", "label": "Stripped 3 metadata fields: Author: \"John\"; Creator: \"Word\"..." }
],
"summary": "3 changes made during sanitization."
}
}See Sanitization Report & Metadata for full details on the report object.
GET /api/v1/download/:id
Download the sanitized file. Use the downloadUrl from the poll or webhook response — it includes the required expires and sig query parameters.
curl -o clean.pdf "https://cleanthis.io/api/v1/download/JOB_ID?expires=...&sig=..." \ -H "Authorization: Bearer ct_live_YOUR_KEY"
POST /api/v1/cancel/:id
Cancel a queued or in-progress job.
curl -X POST https://cleanthis.io/api/v1/cancel/a1b2c3d4-... \ -H "Authorization: Bearer ct_live_YOUR_KEY"
Response 200 OK:
{ "jobId": "a1b2c3d4-...", "cancelled": true }endpoints
Endpoints — Webpage Scanner BETA
Scan a web page for threats, trackers, and brand-impersonation — the same engine behind the Webpage Scanner page, over the API. tier is optional and defaults to standard. Three tiers:
- Quick (
light) — reputation & blocklists only; never loads the page. API-only — the web Scanner offers Standard and Deep;lightremains available here for cheap, no-contact reputation triage. - Standard (
standard) — reputation + a single page fetch with static analysis (trackers, vulnerable libraries, AI-prompt-injection, paste-and-run / ClickFix lures, TLS, redirects, brand-clone). Default. - Deep (
aggressive) — loads the page in a real sandboxed browser (runtime connections, behaviour, screenshot, cloaking). Asynchronous — returns ajobIdyou poll.
| Method | Endpoint | Auth | Rate Limit | Description |
|---|---|---|---|---|
| POST | /api/v1/scan-url |
Bearer | 10/min · 50/day | Scan a URL. Body { url, tier, bypassCache? }. light/standard return the verdict directly; aggressive returns a jobId. |
| GET | /api/v1/scan-job/:id |
Bearer | 120/min | Poll a Deep (aggressive) scan. Returns the result when complete. |
| POST | /api/v1/scan-job/:id/cancel |
Bearer | 120/min | Cancel an in-flight Deep scan. |
POST /api/v1/scan-url
Quick / Standard (synchronous):
curl -s -X POST https://cleanthis.io/api/v1/scan-url \
-H "Authorization: Bearer ct_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com", "tier": "standard" }' | jqResponse 200 OK:
{
"ok": true,
"url": "https://example.com",
"tier": "standard",
"verdict": "clean", // clean | suspicious | malicious | unreachable | unknown
"findings": [ /* per-source/analyzer results */ ],
"scores": { "security": { "value": 92, "band": "green" }, "privacy": {…}, "legitimacy": {…} },
"cached": false,
"computedAt": "2026-05-31T18:00:00.000Z"
}Deep scan (asynchronous — submit, then poll):
curl -s -X POST https://cleanthis.io/api/v1/scan-url \
-H "Authorization: Bearer ct_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com", "tier": "aggressive" }' | jq
# → { "ok": true, "jobId": "a1b2c3d4-...", "state": "processing" }GET /api/v1/scan-job/:id
Poll until state is completed or failed. While it runs, phase names the browser stage once one is known (queued, proxy, launch, setup, navigate, queue, settle, consent, content, screenshot, compare, baseline; queue means the site itself is making the scan wait before it shows the page) and pollUntil is the Unix-millisecond time the job may run until — keep polling until then before treating a slow page as stuck. On completion, if a screenshot was captured, result.screenshot.url is a short-lived (15 min) signed link.
curl -s https://cleanthis.io/api/v1/scan-job/a1b2c3d4-... \
-H "Authorization: Bearer ct_live_YOUR_KEY" | jq
# { "ok": true, "state": "processing", "phase": "navigate", "pollUntil": 1761400000000 }
# … then …
# { "ok": true, "state": "completed", "result": { "verdict": "clean", "findings": […], "scores": {…}, "screenshot": { "url": "https://cleanthis.io/api/scan-shot/…?expires=…&sig=…" } } }A Deep scan that couldn't finish returns { "ok": true, "state": "failed", "error": "…" }; one you cancelled returns { "ok": true, "state": "cancelled" }. A scan job is kept for 15 minutes after you submit it; after that, or for an unknown ID, polling returns 404.
POST /api/v1/scan-job/:id/cancel
Cancels a Deep scan that is still running. Response 200 OK:
{ "ok": true, "state": "cancelled" }If the scan has already finished, you get { "ok": false, "code": "not_cancellable", "state": "completed" } (or "failed") instead.
Request fields
url(string, required) — the page to scan, up to 2048 characters.tier(string) —light,standard(default) oraggressive.bypassCache(boolean) — Quick and Standard results are cached for 24 hours. Sendtrueto force a fresh scan. Only the JSON valuetruecounts;"true"as a string is ignored. Deep scans are never cached.
Scores
scores has three separate 0–100 scores: security, privacy and legitimacy. There is no overall score. Each one looks like this:
{ "value": 92, "band": "green", "coverage": "full", "driver": null }value— 0 to 100, higher is safer.nullwhen that area couldn't be judged.band—green(85–100),amber(60–84),red(0–59) ornone(no value).coverage— how much of that area the scan could check:full,partial(a quicker tier, or a check that didn't respond;degraded: truemarks the second case),none(not covered at this tier),unloaded(the page couldn't be loaded) orblocked(the page is behind a bot-protection wall).driver— a short phrase naming the main thing that lowered the score, ornull.
A ct_test_ mock response has "scores": null.
Findings
Each entry in findings[] is the result of one check:
{
"source": "namedTrackers",
"sourceLabel": "Trackers",
"result": "notice",
"category": "trackers",
"details": { "note": "This page loads 3 known trackers (…).", "trackerCount": 3 },
"checked_at": 1748714400000
}source— a stable ID for the check.sourceLabelis its display name.result—clean(nothing found),listed(a problem found; this is what moves theverdict),notice(worth knowing, never changes the verdict) orerror(the check couldn't run; treat as unknown, not clean).severity—info,low,mediumorhigh. Optional; many checks leave it out. Alistedfinding withhighseverity makes the verdictmalicious; any otherlistedfinding makes itsuspicious.category,details— optional.detailsdiffers per check;details.note, when present, is a plain-English summary. Don't depend on the otherdetailskeys staying the same.error— may be present whenresultiserror.checked_at— when the check ran, as Unix time in milliseconds.
New checks are added over time, so handle source values you don't recognise.
Note: ct_test_* keys return a deterministic mock verdict (no real scan runs) and don't count against your quota.
endpoints
Endpoints — Account Management
| Method | Endpoint | Auth | Rate Limit | Description |
|---|---|---|---|---|
| POST | /api/v1/account |
— | 5/hr | Create anonymous account |
| POST | /api/v1/account/login |
— | 10/15min | Log in (sets session cookie) |
| POST | /api/v1/account/logout |
Session | — | Clear session |
| GET | /api/v1/account |
Session | 30/min | Get account info + usage |
| DELETE | /api/v1/account |
Session | 30/min | Delete account and all data |
| GET | /api/v1/account/keys |
Session | 30/min | List API keys |
| POST | /api/v1/account/keys |
Session | 30/min | Create API key |
| DELETE | /api/v1/account/keys/:id |
Session | 30/min | Revoke API key |
| GET | /api/v1/account/usage |
Session | 30/min | Usage stats |
| GET | /api/v1/account/webhook-secret |
Session | 30/min | Get webhook signing secret |
| POST | /api/v1/account/webhook-secret/rotate |
Session | 30/min | Rotate webhook secret |
All timestamps in these responses (createdAt, lastUsedAt, expiresAt) are Unix time in milliseconds. null means "never" or "not set".
POST /api/v1/account/login
Body { "accountNumber": "CT-…" }. On success it sets the session cookie and returns 200 OK:
{
"prefix": "CT-7K9M",
"tier": "free",
"message": "Logged in successfully."
}A wrong account number returns 401 {"error": "Invalid account number."}.
These routes are what the dashboard calls. POST /api/v1/account and /account/login are protected against automated sign-ups and only work from the dashboard page (see Getting an API key); the session routes below work with the cookie the dashboard sets.
POST /api/v1/account
Creates an account and signs you in. Response 201 Created — the only time the account number is ever returned:
{
"accountNumber": "CT-7K9M-X2P4-R8N1-Q5W3-J6L0-V4T2",
"prefix": "CT-7K9M",
"warning": "Save this account number now. It cannot be recovered — we do not store it."
}POST /api/v1/account/logout
Clears the session cookie. Response 200 OK: { "message": "Logged out." }
GET /api/v1/account
Response 200 OK. usage covers the current calendar month (UTC):
{
"prefix": "CT-7K9M",
"tier": "free",
"createdAt": 1761219780000,
"activeKeys": 2,
"usage": {
"month": "2026-10",
"sanitizeCount": 37,
"bytesProcessed": 48211904
}
}DELETE /api/v1/account
Deletes the account, its keys and its usage history, and clears the session cookie. This cannot be undone. Response 200 OK:
{ "message": "Account and all associated data deleted." }GET /api/v1/account/keys
Lists every key on the account, newest first, including revoked ones. The full key is never returned after creation. Response 200 OK:
{
"keys": [
{
"id": "3f2b8c1e-5d4a-4e7b-9c21-8a6f0d2e4b17",
"prefix": "ct_live",
"last4": "9mPq",
"label": "production",
"revoked": false,
"createdAt": 1761219780000,
"lastUsedAt": 1761306180000,
"expiresAt": null
}
]
}POST /api/v1/account/keys
Body { "label": "…", "mode": "live" | "test" }. Both fields are optional. mode defaults to live; send "test" to get a ct_test_ key (any other value creates a live key). An account can hold 5 active keys; past that the request returns 400 until you revoke one. Response 201 Created — the only time the full key is returned:
{
"rawKey": "ct_live_Ab3xK9mP...",
"keyId": "3f2b8c1e-5d4a-4e7b-9c21-8a6f0d2e4b17",
"prefix": "ct_live",
"last4": "9mPq",
"label": "production",
"warning": "Save this API key now. It will not be shown again."
}DELETE /api/v1/account/keys/:id
:id is the key's id from the list above. Response 200 OK: { "message": "API key revoked." }. An unknown or already-revoked key returns 404.
GET /api/v1/account/usage
The current month, up to 12 months of history, and your 50 most recent requests. Response 200 OK:
{
"currentMonth": { "month": "2026-10", "sanitizeCount": 37, "bytesProcessed": 48211904 },
"history": [
{ "month": "2026-10", "sanitizeCount": 37, "bytesProcessed": 48211904 },
{ "month": "2026-09", "sanitizeCount": 112, "bytesProcessed": 190336512 }
],
"recent": [
{
"endpoint": "sanitize",
"fileExt": ".pdf",
"fileSizeBytes": 248331,
"status": "queued",
"createdAt": 1761306180000
}
]
}In recent, endpoint is sanitize, sanitize-url or scan-url, and status is queued (accepted), rejected (refused at validation), test (a ct_test_ request) or scanned (a webpage scan). fileExt and fileSizeBytes are null when they don't apply.
GET /api/v1/account/webhook-secret
Returns the secret used to sign your webhooks, creating one on first use. Response 200 OK:
{
"webhookSecret": "<64 hex characters>",
"usage": "Use this secret to verify webhook signatures. Include in your HMAC-SHA256 verification: X-CleanThis-Signature: sha256=HMAC(secret, body)"
}POST /api/v1/account/webhook-secret/rotate
Replaces the secret. The old one stops working immediately. Response 200 OK:
{
"webhookSecret": "<64 hex characters>",
"warning": "Previous webhook secret is now invalid. Update your verification code immediately."
}results
Webhooks
How to use
Include a webhook URL in your sanitize request. When the job completes (or fails), cleanthis.io sends a POST request to that URL with the result.
# Upload with webhook curl -X POST https://cleanthis.io/api/v1/sanitize \ -H "Authorization: Bearer ct_live_YOUR_KEY" \ -F file=@document.pdf \ -F webhook=https://your-server.com/callback
Payload
The webhook POST body is JSON:
{
"event": "job.completed",
"jobId": "a1b2c3d4-...",
"status": "completed",
"downloadUrl": "https://cleanthis.io/api/v1/download/...?expires=...&sig=...",
"downloadName": "doc_cleaned.pdf",
"report": {
"changes": [
{ "type": "level", "label": "Sanitization level: Aggressive — full CDR + format conversion" },
{ "type": "macros", "label": "Removed 1 macro component: VBA Project" },
{ "type": "conversion", "label": "Converted to PDF for maximum safety" }
],
"summary": "3 changes made during sanitization (Aggressive mode)."
},
"timestamp": "2026-04-23T01:23:00.000Z"
}The report field contains the full sanitization report including any detected metadata and the sanitization level applied. See Sanitization Report & Metadata for the complete structure.
When a job fails, the event is job.failed. It has no downloadUrl, downloadName or report; it carries an error instead:
{
"event": "job.failed",
"jobId": "a1b2c3d4-...",
"status": "failed",
"error": "Processing failed. Please try again.",
"timestamp": "2026-04-23T01:23:00.000Z"
}Check event (or status) before reading downloadUrl. A job you cancel while it is still queued sends no webhook. A job you cancel while it is processing may still send a job.failed webhook.
Signature Verification
Every webhook includes two headers for verification:
X-CleanThis-Signature— HMAC-SHA256 of the raw request body, prefixed withsha256=X-CleanThis-Timestamp— when the webhook was sent, as Unix time in seconds (e.g.1761219780)
Always verify the signature before trusting the payload. Use a timing-safe comparison to prevent timing attacks.
Node.js
const crypto = require('crypto');
function verifyWebhook(body, signature, secret) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(body, 'utf8')
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
// Express handler
app.post('/callback', express.text({ type: '*/*' }), (req, res) => {
const sig = req.headers['x-cleanthis-signature'];
if (!verifyWebhook(req.body, sig, WEBHOOK_SECRET)) {
return res.status(403).send('Invalid signature');
}
const event = JSON.parse(req.body);
console.log('Completed:', event.downloadUrl);
res.sendStatus(200);
});Python
import hmac, hashlib
def verify_webhook(body: bytes, signature: str, secret: str) -> bool:
expected = 'sha256=' + hmac.new(
secret.encode(), body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
# Flask handler
@app.route('/callback', methods=['POST'])
def webhook():
sig = request.headers.get('X-CleanThis-Signature', '')
if not verify_webhook(request.data, sig, WEBHOOK_SECRET):
return 'Invalid signature', 403
event = request.get_json(force=True)
print('Completed:', event['downloadUrl'])
return '', 200Retry Behavior
If your endpoint returns a non-2xx status code or doesn't respond within 10 seconds, cleanthis.io tries again. Each webhook gets 3 attempts in total (the first delivery plus 2 retries), with exponential backoff:
- Attempt 1 — immediate
- Attempt 2 — about 2 seconds after attempt 1 failed
- Attempt 3 (final) — about 8 seconds after attempt 2 failed
Each wait has up to 2 seconds of random jitter added. If all 3 attempts fail, the webhook is dropped; poll GET /api/v1/job/:id to get the result instead.
Requirements
- HTTPS only — webhook URLs must use
https:// - Public IP — the URL must resolve to a public IP address (SSRF-protected; private/loopback ranges are rejected)
Managing Your Secret
Retrieve your webhook signing secret from the dashboard or programmatically:
GET /api/v1/account/webhook-secret Cookie: session=...
To rotate your secret (the old secret is immediately invalidated):
POST /api/v1/account/webhook-secret/rotate Cookie: session=...
results
Signed Download URLs
All download URLs use HMAC-SHA256 signed URLs with an expiry timestamp. You never need to construct these yourself — the downloadUrl returned by the poll endpoint and webhook payload is ready to use.
- API downloads — signed URLs expire in 15 minutes
- Web UI downloads — signed URLs expire in 5 minutes
Expired & Tampered URLs
- Expired URLs return
403 Forbiddenwith{"error": "Download link has expired."} - Tampered URLs (modified
sigorexpires) return403 Forbiddenwith{"error": "Invalid signature."} - Once the job is gone (about 15 minutes after it finished) the download returns
404
If your download URL has expired, simply poll GET /api/v1/job/:id again to receive a fresh signed URL.
results
Sanitization Report & Metadata
Every completed job includes a report object in both the poll response (GET /api/v1/job/:id) and the webhook payload. This report describes exactly what was found and removed during sanitization — including embedded metadata.
Report Structure
{
"report": {
"changes": [
{
"type": "macros",
"label": "Removed 1 macro component: VBA Project"
},
{
"type": "exif",
"label": "Stripped 14 EXIF/metadata fields including: GPS Location data; Model: \"iPhone 15 Pro\"",
"details": [
{ "key": "GPSLatitude", "value": "40.7128 N" },
{ "key": "GPSLongitude", "value": "74.0060 W" },
{ "key": "Make", "value": "Apple" },
{ "key": "Model", "value": "iPhone 15 Pro" },
{ "key": "DateTimeOriginal", "value": "2025:03:15 14:30:00" },
{ "key": "Software", "value": "17.4" }
]
}
],
"summary": "2 changes made during sanitization."
}
}Change Types
Each entry in the changes array has a type, a human-readable label, and optionally a details array for metadata fields.
| Type | Description | Has Details |
|---|---|---|
level | Indicates which sanitization level was applied (always present) | No |
conversion | File was converted to a safer format (aggressive mode only) | No |
size | File size changed significantly during sanitization | No |
macros | VBA macros, ActiveX controls, or OLE objects removed | No |
attachments | Embedded file attachments removed (PDF) | No |
encryption | PDF encryption/password protection removed | No |
javascript | JavaScript code removed (PDF) | No |
csv_formulas | Dangerous formula cells neutralised (CSV/TSV) | No |
bidi_controls | Hidden text-direction control characters removed — they can make text or code display differently from what it says (text, config and source-code files) | Yes |
invisible_chars | Invisible zero-width characters removed (text, config and source-code files) | Yes |
scripts | Script tags, iframes, objects, forms removed (HTML/SVG/EPUB/subtitles) | No |
event_handlers | Inline event handlers removed (onclick, onload, etc.) | No |
external_refs | External URL references removed | No |
exif | EXIF/metadata stripped from images, audio, and video | Yes |
metadata | Document metadata stripped (author, creator, software, etc.) | Yes |
Metadata Details
When type is "exif" or "metadata", the change object includes a details array — the complete list of metadata fields that were detected and stripped. Each entry has a key (field name) and value (original value, truncated to 120 characters).
bidi_controls and invisible_chars carry a details array too: one row per kind of character removed, with key = its name and code point (for example right-to-left override (U+202E)) and value = how many were removed.
This is the same data the web UI offers as CSV/JSON export. As an API consumer, you receive it directly in the JSON response and can format it however you need.
Extracting Metadata — Examples
Node.js
const res = await fetch(`https://cleanthis.io/api/v1/job/${jobId}`, {
headers: { 'Authorization': `Bearer ${API_KEY}` }
});
const data = await res.json();
if (data.status === 'completed' && data.report) {
// Find metadata changes (exif or metadata type)
const metaChanges = data.report.changes.filter(
c => c.details && c.details.length > 0
);
for (const change of metaChanges) {
console.log(change.label);
for (const field of change.details) {
console.log(` ${field.key}: ${field.value}`);
}
}
}Python
import requests
res = requests.get(
f'https://cleanthis.io/api/v1/job/{job_id}',
headers={'Authorization': f'Bearer {api_key}'}
)
data = res.json()
if data['status'] == 'completed' and data.get('report'):
for change in data['report']['changes']:
if 'details' in change:
print(change['label'])
for field in change['details']:
print(f" {field['key']}: {field['value']}")curl + jq
# Get full report curl -s https://cleanthis.io/api/v1/job/JOB_ID \ -H "Authorization: Bearer ct_live_YOUR_KEY" | jq '.report' # Extract just the metadata fields curl -s https://cleanthis.io/api/v1/job/JOB_ID \ -H "Authorization: Bearer ct_live_YOUR_KEY" \ | jq '[.report.changes[] | select(.details) | .details[]] | from_entries' # Save metadata as CSV curl -s https://cleanthis.io/api/v1/job/JOB_ID \ -H "Authorization: Bearer ct_live_YOUR_KEY" \ | jq -r '.report.changes[] | select(.details) | .details[] | [.key, .value] | @csv' \ > metadata.csv
Clean Files
If the file was already clean, the report will have an empty changes array:
{
"report": {
"changes": [],
"summary": "No significant threats or changes detected. The file was already clean."
}
}reference
Rate Limits
| Endpoint Group | Limit | Keyed By |
|---|---|---|
| API v1 sanitize / cancel | 20/min | Account ID |
| API v1 job poll | 120/min | Account ID |
| API v1 download | 30/min | Account ID |
| Account create | 5/hr | IP |
| Account login | 10/15min | IP |
| Account management | 30/min | Session |
| Web UI upload | 10/min | IP |
| Web UI poll | 120/min | IP |
| Web UI download | 30/min | IP |
API v1 endpoints are keyed by account ID (not IP), so multiple servers behind NAT share a generous per-account limit. When rate-limited, responses include Retry-After header with the number of seconds to wait.
reference
Error Responses
All errors return JSON with a single error field:
{ "error": "File type not allowed: .exe" }Common Status Codes
| Code | Meaning |
|---|---|
400 | Bad request — missing or invalid parameters |
401 | Unauthorized — missing or invalid API key / session |
403 | Forbidden — expired or tampered signed download URL |
404 | Not found — job ID doesn't exist or doesn't belong to your account |
413 | Payload too large — the request body is far over the 50 MB file limit and was refused before reaching the API (the response is an HTML error page, not JSON). A file just over 50 MB gets a 400 with {"error": "File too large. Maximum size is 50 MB."} instead. |
415 | Unsupported media type — file type not allowed |
429 | Too many requests — rate limit exceeded (check Retry-After header) |
500 | Internal server error — something went wrong on our end |