Get a key

$ cleanthis --api

API docs

Clean files and scan links from your own code: uploads, links, webhooks, signed downloads and anonymous accounts.

base URLhttps://cleanthis.io/api/v1
free while in betano email, no passwordct_test_ keys for building

getting 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)

shell
# 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

shell
# 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 above

getting 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.
Your account number is not an API key. The 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

  1. 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.
  2. Log in at My Dashboard with that account number.
  3. Create a key from the dashboard's API-keys section. Pick test while you're integrating, live for real scans. The full key (ct_live_… / ct_test_…) is shown once — copy it immediately.
  4. 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:

header
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:

shell
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:

shell
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:

json
{
  "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 a format_converted entry 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.gz regardless 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.gz inside a .zip or a .zip inside 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:

ValueMulti-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

shell
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 level
  • archive_fallback — list of members that received Light treatment instead (only present when archiveUnsupported=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."}.

http
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:

json
{
  "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.

http
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:

json
{
  "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. queuePosition and queueLength say where it is in line.
  • processing — being sanitized now.
  • completed — done. The response carries downloadUrl, downloadName and report.
  • failed — the file could not be sanitized. error says 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:

json
{
  "jobId": "a1b2c3d4-...",
  "status": "queued",
  "level": "standard",
  "queuePosition": 2,
  "queueLength": 3
}

Failed:

json
{
  "jobId": "a1b2c3d4-...",
  "status": "failed",
  "level": "standard",
  "error": "Processing failed. Please try again."
}

Completed:

json
{
  "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.

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

shell
curl -X POST https://cleanthis.io/api/v1/cancel/a1b2c3d4-... \
  -H "Authorization: Bearer ct_live_YOUR_KEY"

Response 200 OK:

json
{ "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; light remains 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 a jobId you poll.
MethodEndpointAuthRate LimitDescription
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):

shell
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" }' | jq

Response 200 OK:

json
{
  "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):

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

shell
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:

json
{ "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) or aggressive.
  • bypassCache (boolean) — Quick and Standard results are cached for 24 hours. Send true to force a fresh scan. Only the JSON value true counts; "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:

json
{ "value": 92, "band": "green", "coverage": "full", "driver": null }
  • value — 0 to 100, higher is safer. null when that area couldn't be judged.
  • band — green (85–100), amber (60–84), red (0–59) or none (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: true marks the second case), none (not covered at this tier), unloaded (the page couldn't be loaded) or blocked (the page is behind a bot-protection wall).
  • driver — a short phrase naming the main thing that lowered the score, or null.

A ct_test_ mock response has "scores": null.

Findings

Each entry in findings[] is the result of one check:

json
{
  "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. sourceLabel is its display name.
  • result — clean (nothing found), listed (a problem found; this is what moves the verdict), notice (worth knowing, never changes the verdict) or error (the check couldn't run; treat as unknown, not clean).
  • severity — info, low, medium or high. Optional; many checks leave it out. A listed finding with high severity makes the verdict malicious; any other listed finding makes it suspicious.
  • category, details — optional. details differs per check; details.note, when present, is a plain-English summary. Don't depend on the other details keys staying the same.
  • error — may be present when result is error.
  • 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:

json
{
  "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:

json
{
  "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):

json
{
  "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:

json
{ "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:

json
{
  "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:

json
{
  "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:

json
{
  "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:

json
{
  "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:

json
{
  "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.

shell
# 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:

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:

json
{
  "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 with sha256=
  • 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 '', 200

Retry 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:

http
GET /api/v1/account/webhook-secret
Cookie: session=...

To rotate your secret (the old secret is immediately invalidated):

http
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 Forbidden with {"error": "Download link has expired."}
  • Tampered URLs (modified sig or expires) return 403 Forbidden with {"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

json
{
  "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
levelIndicates which sanitization level was applied (always present)No
conversionFile was converted to a safer format (aggressive mode only)No
sizeFile size changed significantly during sanitizationNo
macrosVBA macros, ActiveX controls, or OLE objects removedNo
attachmentsEmbedded file attachments removed (PDF)No
encryptionPDF encryption/password protection removedNo
javascriptJavaScript code removed (PDF)No
csv_formulasDangerous formula cells neutralised (CSV/TSV)No
bidi_controlsHidden 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_charsInvisible zero-width characters removed (text, config and source-code files)Yes
scriptsScript tags, iframes, objects, forms removed (HTML/SVG/EPUB/subtitles)No
event_handlersInline event handlers removed (onclick, onload, etc.)No
external_refsExternal URL references removedNo
exifEXIF/metadata stripped from images, audio, and videoYes
metadataDocument 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:

json
{
  "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:

json
{ "error": "File type not allowed: .exe" }

Common Status Codes

Code Meaning
400Bad request — missing or invalid parameters
401Unauthorized — missing or invalid API key / session
403Forbidden — expired or tampered signed download URL
404Not found — job ID doesn't exist or doesn't belong to your account
413Payload 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.
415Unsupported media type — file type not allowed
429Too many requests — rate limit exceeded (check Retry-After header)
500Internal server error — something went wrong on our end

Ready to try it? Make an anonymous account and your first key in under a minute.

Get an API key