API Reference

One call. Full compliance.

Strip EXIF, compress to WebP, and get an AI content verdict — all in 1–5 seconds for $0.002 per image.

🛍️
See a full example — complete marketplace app: seller upload → Filtrate scan → buyer listings → admin verdict log. Fork it, deploy it in 5 minutes.
View reference app →
📦
Need a working example? Clone-and-go templates for Next.js, Express, Shopify, AWS Lambda, and React.
See filtrate/examples →

Start with an official SDK

Official client libraries for Node.js/TypeScript and Python. One install, full type safety, no boilerplate.

Install
npm install @filtrate/sdk
Usage — TypeScript
import { Filtrate } from '@filtrate/sdk';

const filtrate = new Filtrate({ apiKey: process.env.FILTRATE_API_KEY });

const result = await filtrate.process({
  imageUrl: 'https://example.com/product-photo.jpg',
  quality: 80,
});

console.log(result.status);         // 'safe' | 'explicit' | 'fraud' | 'banned_logo'
console.log(result.optimized_url);  // CDN URL of processed WebP
console.log(result.size_reduction); // e.g. "67%"
Install
pip install filtrate
Usage — Python
from filtrate import Filtrate

filtrate = Filtrate(api_key="flt_live_...")  # or set FILTRATE_API_KEY env var

result = filtrate.process(
    image_url="https://example.com/product-photo.jpg",
    quality=80,
)

print(result.status)         # 'safe' | 'explicit' | 'fraud' | 'banned_logo'
print(result.optimized_url)  # CDN URL of processed WebP
print(result.size_reduction) # e.g. "67%"
Prefer raw HTTP? Scroll to Code Examples below for curl, fetch, and axios snippets.

Command-line interface

One command to evaluate Filtrate — no account needed for scanning. Works in your terminal or CI pipeline.

1
Scan a local or remote image (anonymous, no API key)

Uses the playground endpoint — 5 requests per IP per 15 minutes, free. Exits 1 on compliance issues, perfect for CI.

2
Process with your API key (authenticated, full tier)

Pass --key $FILTRATE_KEY or run filtrate login once to store your key.

Install
npm install -g @filtrate/cli
# or use without installing:
npx @filtrate/cli scan ./photo.jpg
Scan (anonymous)
npx @filtrate/cli scan https://example.com/listing-photo.jpg
# Or a local file:
npx @filtrate/cli scan ./product-photos/listing-42.jpg
Process (authenticated)
npx @filtrate/cli process ./photo.jpg --key flt_live_xxxxxxxxxxxxxxxx
# Save the cleaned WebP locally:
npx @filtrate/cli process ./photo.jpg --key $FILTRATE_KEY --output cleaned.webp
# Machine-readable JSON:
npx @filtrate/cli process ./photo.jpg --key $FILTRATE_KEY --json
Login (store API key once)
npx @filtrate/cli login
# After login, subsequent commands don't need --key:
npx @filtrate/cli process ./photo.jpg
Use the GitHub Action instead: For automated PR scanning, the Filtrate GitHub Action is faster to configure than the CLI. See GitHub Action below →

Scan every PR automatically

The Filtrate GitHub Action diffs each pull request, scans changed images for EXIF/GPS leaks, and posts a report table as a PR comment. Blocks merge when GPS coordinates are found.

1
Add the workflow file to your repo

Create .github/workflows/filtrate-scan.yml with the snippet below.

2
Open a PR that changes an image

The action comments a scan table automatically. CI fails on GPS leaks by default — set fail-on: none to report-only.

.github/workflows/filtrate-scan.yml
name: Image Privacy Scan

on: pull_request

jobs:
  scan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write  # required for PR comments

    steps:
      - uses: actions/checkout@v4

      - uses: Polsia-Inc/filtrate-scan-action@v1
        with:
          api-key: ${{ secrets.FILTRATE_API_KEY }}  # optional
          fail-on: gps   # gps | any | none
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Inputs: api-key (optional), fail-on (gps / any / none, default: gps), paths (glob, default: *.{jpg,png,webp,…}), comment-on-pr (true / false).
Outputs: issues-found (count), report-url.
View on GitHub Marketplace →
Base URL https://filtrate.polsia.app

Authenticate your requests

Every API request requires an API key passed as a Bearer token in the Authorization header. Keys are scoped to your account and never expire unless you revoke them.

GET /dashboard — generate and manage your API keys
1
Create an account

Sign up at filtrate.polsia.app/signup. No credit card required.

2
Generate an API key

Go to your Dashboard and click "New API Key". Give it a label like "production" or "dev". Copy it — keys are only shown once.

3
Include it in every request

Pass your key as a Bearer token in the Authorization header.

Authorization header
Authorization: Bearer flt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
No auth required: The playground endpoint at POST /v1/playground lets you test the full pipeline without an API key — no signup needed.

POST /v1/process

The primary endpoint. Upload an image file (multipart/form-data) or provide an image URL (JSON body). The response delivers a processed WebP, size stats, and an AI content moderation verdict — all in one call.

POST /v1/process

Headers

Name Type Required Description
Authorization string required Bearer token with your API key
Content-Type string required* Must be multipart/form-data when uploading a file. Omit for URL-based requests.

Request Body (multipart/form-data)

Field Type Required Description
image file required* Image file to process. JPEG, PNG, WebP, GIF, AVIF, TIFF, HEIC accepted. Max 20 MB.
image_url string required* Remote image URL. Required only if no image field is provided.
compress[quality] integer optional WebP quality 1–100 (default: 85). Lower values = smaller files, more compression artifacts.
strip_exif boolean optional Set to false to keep original orientation. Default: true (strip EXIF and auto-rotate).
* Provide either image (file upload) or image_url (URL). Do not send both in the same request.

Request — URL-based (JSON body)

Alternatively, send JSON with an image_url field and Content-Type: application/json. The image is fetched from your URL before processing.

curl — URL-based request
curl -X POST https://filtrate.polsia.app/v1/process ^
  -H "Authorization: Bearer flt_live_xxxxxxxxxxxx" ^
  -H "Content-Type: application/json" ^
  -d '{"image_url": "https://example.com/photo.jpg", "compress": {"quality": 80}}'

Response Schema

Every successful call returns HTTP 200 with a JSON body. The response contains processing metadata, the CDN URL of the processed WebP, and a compliance verdict.

Success Response (HTTP 200)

Field Type Description
status string Overall compliance verdict: safe, explicit, fraud, or banned_logo
optimized_url string CDN URL of the processed WebP file (expires never)
size_reduction string Size reduction as a percentage, e.g. "67%"
original_size_bytes integer Original image size in bytes
output_size_bytes integer Processed WebP size in bytes
format string Always "webp"
quality integer WebP quality setting used for this call
exif_stripped boolean true — all EXIF/IPTC/XMP metadata removed and image auto-rotated
processing_ms integer Total round-trip time in milliseconds
from_cache boolean true if the moderation verdict was served from cache (same image previously processed)
pipeline_timings object Breakdown of time spent in each pipeline stage in milliseconds
compliance object AI content moderation results — see sub-fields below

compliance sub-object

Field Type Description
verdict string Same as status at top level: safe | explicit | fraud | banned_logo
categories object Boolean flags for each detection category
error string Present only when AI moderation fails. The image is still processed and returned.

Sample Response

200 OK — processed image response
{
  "status": "safe",
  "optimized_url": "https://cdn.polsia.app/filtrate/1234567890.webp",
  "size_reduction": "67%",
  "original_size_bytes": 2048576,
  "output_size_bytes": 675840,
  "format": "webp",
  "quality": 85,
  "exif_stripped": true,
  "processing_ms": 1842,
  "from_cache": false,
  "pipeline_timings": {
    "parseAndReceive": 45,
    "sharpProcessing": 128,
    "r2Upload": 312,
    "moderation": 1342,
    "moderation_cache_hit": 0,
    "response": 15
  },
  "compliance": {
    "verdict": "safe",
    "categories": {
      "explicit": false,
      "fraud": false,
      "banned_logo": false
    }
  }
}

Error Responses

When something goes wrong, the API returns an appropriate HTTP status code and a JSON body with an error field describing what happened.

Status Name Description
400 Bad Request Missing or invalid input — no image provided, unsupported format, or failed to fetch the remote URL
401 Unauthorized No API key provided, or the key is invalid or revoked
413 Payload Too Large File exceeds the 20 MB limit. Reduce the image size before uploading.
429 Too Many Requests Rate limit exceeded (100 req/min per API key). Check the Retry-After header for the number of seconds to wait.
500 Internal Server Error Unexpected processing failure. Retry the request. If the problem persists, contact support.

Error Response Body

400 Bad Request — example error body
{
  "error": "Provide either image_url in JSON body or an image file as multipart field 'image'"
}

Rate Limit Headers

Header Type Description
X-RateLimit-Limit integer Max requests allowed per minute for this API key
X-RateLimit-Remaining integer Requests remaining in the current 60-second window
Retry-After integer Seconds to wait before retrying (only present on 429 responses)

Async batch processing

For high-volume pipelines processing millions of images, POST /v1/batch accepts up to 1,000 image URLs and returns immediately with a batch_id. A background worker processes images every 2 minutes — results are uploaded to R2 and a webhook fires on completion. Each image counts as 1 API call at $0.002.

When to use batch vs sync:
  • Sync (POST /v1/process): < 50 images, need immediate results, simple scripts
  • Async batch (POST /v1/batch): ≥ 50 images, millions daily, enterprise pipelines, webhook-first architecture

Submit a batch

POST /v1/batch — Request
curl -X POST https://filtrate.polsia.app/v1/batch
  -H "X-API-Key: YOUR_KEY"
  -H "Content-Type: application/json"
  -d '{
    "images": [
      "https://example.com/listings/img-001.jpg",
      "https://example.com/listings/img-002.jpg",
      "https://example.com/listings/img-003.jpg"
    ],
    "callback_url": "https://yourapp.com/webhooks/filtrate",
    "metadata": { "user_id": 42, "upload_batch": "spring-2026" }
  }'
POST /v1/batch — 202 Accepted Response
{
  "batch_id": "batch_a3f1c9d82e0b4f7c",
  "status": "queued",
  "estimated_completion_seconds": 45,
  "image_count": 3,
  "callback_url": "https://yourapp.com/webhooks/filtrate",
  "metadata": { "user_id": 42, "upload_batch": "spring-2026" }
}

Check batch status

GET /v1/batch/:batchId — Response
{
  "batch_id": "batch_a3f1c9d82e0b4f7c",
  "status": "completed",
  "progress": {
    "total": 3,
    "queued": 0,
    "processing": 0,
    "completed": 2,
    "failed": 1
  },
  "results_url": "https://cdn.example.com/batch_results_batch_a3f1c9d...json",
  "created_at": "2026-06-04T18:30:00.000Z",
  "completed_at": "2026-06-04T18:31:12.000Z",
  "total_ms": 72150
}

Webhook payload & signature verification

When a batch completes, Filtrate POSTs to your callback_url with a JSON body signed using HMAC-SHA256. The signature is in the X-Filtrate-Signature header.

Webhook payload example
{
  "batch_id": "batch_a3f1c9d82e0b4f7c",
  "status": "completed",
  "completed_at": "2026-06-04T18:31:12.000Z",
  "total": 3,
  "succeeded": 2,
  "failed": 1,
  "results_url": "https://cdn.example.com/batch_results_batch_a3f1c9d...json",
  "metadata": { "user_id": 42, "upload_batch": "spring-2026" },
  "signature_version": "v1"
}
Node.js — Verify webhook signature
const crypto = require('crypto');

function verifyWebhookSignature(rawBody, signatureHeader, secret) {
  // signatureHeader format: t=,v1=
  const parts = Object.fromEntries(
    signatureHeader.split(',').map(p => p.split('='))
  );
  const timestamp = parts['t'];
  const expected  = crypto.createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(parts['v1'], 'hex')
  );
}

// Express handler:
app.post('/webhooks/filtrate', express.raw({type:'application/json'}), (req, res) => {
  const sig    = req.headers['x-filtrate-signature'];
  const secret = process.env.FILTRATE_WEBHOOK_SECRET; // from /settings/webhooks
  const valid  = verifyWebhookSignature(req.body, sig, secret);
  if (!valid) return res.status(401).send('Invalid signature');

  const payload = JSON.parse(req.body);
  console.log(`Batch ${payload.batch_id} completed — ${payload.succeeded} succeeded, ${payload.failed} failed`);

  // Fetch results from results_url...
  res.status(200).send('OK');
});
Python — Verify webhook signature
import hmac, hashlib, os
from flask import Flask, request, abort

app = Flask(__name__)

def verify_signature(raw_body: bytes, sig_header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in sig_header.split(","))
    expected = hmac.new(
        secret.encode(), f"{parts['t']}.{raw_body.decode()}".encode(),
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

@app.route("/webhooks/filtrate", methods=["POST"])
def webhook():
    raw = request.get_data()
    secret = os.environ["FILTRATE_WEBHOOK_SECRET"]
    if not verify_signature(raw, request.headers.get("X-Filtrate-Signature", ""), secret):
        abort(401)
    payload = request.json
    print(f"Batch {payload['batch_id']} completed")
    return "", 200

SDK usage

Node.js — batch.create() + batch.get()
import { Filtrate } from '@filtrate/sdk';

const filtrate = new Filtrate({ apiKey: process.env.FILTRATE_API_KEY });

// Submit batch — returns immediately with batch_id
const queued = await filtrate.batchCreate({
  images: [
    'https://cdn.example.com/listings/img-001.jpg',
    'https://cdn.example.com/listings/img-002.jpg',
  ],
  callbackUrl: 'https://yourapp.com/webhooks/filtrate',
  metadata: { upload_batch: 'spring-2026' },
});

console.log(queued.batch_id); // 'batch_a3f1c9d82e0b4f7c'
console.log(queued.estimated_completion_seconds); // e.g. 15

// Poll until complete
let status;
do {
  await new Promise(r => setTimeout(r, 5000));
  status = await filtrate.batchGet(queued.batch_id);
  console.log(`Progress: ${status.progress.completed}/${status.progress.total}`);
} while (status.status === 'queued' || status.status === 'processing');

// Results are at status.results_url — fetch it
if (status.results_url) {
  const results = await fetch(status.results_url).then(r => r.json());
  console.log(results.batch_id);    // batch_id
  console.log(results.results);      // array of per-image responses
}
Python — batch_create() + batch_get()
from filtrate import FiltrateClient

client = FiltrateClient(api_key="flt_live_...")

# Submit batch
queued = client.batch_create(
    images=[
        "https://cdn.example.com/listings/img-001.jpg",
        "https://cdn.example.com/listings/img-002.jpg",
    ],
    callback_url="https://yourapp.com/webhooks/filtrate",
    metadata={"upload_batch": "spring-2026"},
)

print(queued.batch_id)  # 'batch_a3f1c9d82e0b4f7c'

# Poll until complete
import time
status = None
while True:
    status = client.batch_get(queued.batch_id)
    print(f"Completed: {status.progress.completed}/{status.progress.total}")
    if status.status in ("completed", "failed", "partial"):
        break
    time.sleep(5)

if status.results_url:
    import httpx
    results = httpx.get(status.results_url).json()
    print(results["batch_id"])
    print(results["results"])  # list of per-image responses
Rate limit: Max 5 concurrent active batches per account. The API returns 429 if you exceed this. Wait for a batch to complete (or reach completed/failed status) before submitting another.

Integrate in minutes

Copy the example that matches your stack. Replace YOUR_API_KEY with your actual key from the dashboard.

@filtrate/sdk — TypeScript
import { Filtrate, FiltrateError } from '@filtrate/sdk';

const filtrate = new Filtrate({ apiKey: 'YOUR_API_KEY' });

try {
  const result = await filtrate.process({
    imageUrl: 'https://example.com/photo.jpg',
    quality: 80,
  });

  if (result.status !== 'safe') {
    console.error('Flagged:', result.compliance.verdict);
  } else {
    console.log('Processed URL:', result.optimized_url);
    console.log('Size reduction:', result.size_reduction);
  }
} catch (err) {
  if (err instanceof FiltrateError) {
    console.error(`[${err.code}] ${err.message}`);
  }
}
filtrate — Python
from filtrate import Filtrate, FiltrateError

filtrate = Filtrate(api_key="YOUR_API_KEY")

try:
    result = filtrate.process(
        image_url="https://example.com/photo.jpg",
        quality=80,
    )

    if result.status != "safe":
        print(f"Flagged: {result.compliance.verdict}")
    else:
        print(f"Processed URL: {result.optimized_url}")
        print(f"Size reduction: {result.size_reduction}")
except FiltrateError as e:
    print(f"[{e.code}] {e.message}")
File upload — curl
curl -X POST https://filtrate.polsia.app/v1/process ^
  -H "Authorization: Bearer YOUR_API_KEY" ^
  -F "image=@/path/to/photo.jpg" ^
  -F "compress[quality]=80"
URL-based — curl
curl -X POST https://filtrate.polsia.app/v1/process ^
  -H "Authorization: Bearer YOUR_API_KEY" ^
  -H "Content-Type: application/json" ^
  -d "{\"image_url\": \"https://example.com/photo.jpg\"}"
File upload — fetch / browser
const formData = new FormData();
formData.append('image', fileInput.files[0]);
formData.append('compress[quality]', '80');

const response = await fetch('https://filtrate.polsia.app/v1/process', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer YOUR_API_KEY',
  },
  body: formData,
});

const result = await response.json();
console.log('Processed URL:', result.optimized_url);
console.log('Size reduction:', result.size_reduction);
console.log('Verdict:', result.compliance.verdict);
File upload — Python requests
import requests

with open('photo.jpg', 'rb') as f:
    files = {'image': f}
    data = {'compress[quality]': '80'}
    headers = {'Authorization': 'Bearer YOUR_API_KEY'}

    response = requests.post(
        'https://filtrate.polsia.app/v1/process',
        files=files,
        data=data,
        headers=headers
    )

result = response.json()
print(f"Processed: {result['optimized_url']}")
print(f"Verdict: {result['compliance']['verdict']}")
File upload — Node.js axios
const axios = require('axios');
const FormData = require('form-data');
const fs = require('fs');

const form = new FormData();
form.append('image', fs.createReadStream('./photo.jpg'));
form.append('compress[quality]', '80');

const response = await axios.post(
  'https://filtrate.polsia.app/v1/process',
  form,
  {
    headers: {
      Authorization: 'Bearer YOUR_API_KEY',
      ...form.getHeaders(),
    },
  }
);

const { optimized_url, size_reduction, compliance } = response.data;
console.log(`Processed: ${optimized_url} (${size_reduction} smaller)`);
console.log(`Verdict: ${compliance.verdict}`);

Rate limits and pricing

Free / Testing
Playground endpoint
$0
  • POST /v1/playground — no API key required
  • 10 requests / minute
  • Full pipeline (EXIF strip, WebP, AI moderation)
  • Great for evaluating the API
Pay-as-you-go
$0.002 per image
$0.002/image
  • 100 requests / minute per API key
  • Unlimited images per month
  • No monthly minimums or subscriptions
  • Monthly invoice emailed with full breakdown

Need higher limits?

If your use case requires more than 100 req/min, contact us. We can provision higher throughput for high-volume customers.

No minimum. Filtrate is pay-as-you-go at $0.002/image with no monthly commitment. No credits to track — just sign up, generate an API key, and start processing.

Drop-in middleware packages

Stop writing multipart-parsing-and-scan wrappers. Install one package, add one line, done. Filtrate handles EXIF strip, WebP compression, and AI moderation — results land on req.filtrate or ctx before your handler runs.

$0.002/image. Same pricing as the raw API — no middleware surcharge.
install
npm install @filtrate/express multer
upload.js
import multer from 'multer';
import { filtrate } from '@filtrate/express';

const upload = multer({ storage: multer.memoryStorage() });

// Drop in. One line.
app.post('/upload',
  upload.any(),
  filtrate({ apiKey: process.env.FILTRATE_API_KEY }),
  (req, res) => {
    // Blocked uploads never reach here
    const urls = req.filtrate.results.map(r => r.optimized_url);
    res.json({ ok: true, urls });
  }
);
req.filtrate is available in your handler:
shape
req.filtrate = {
  results: [
    {
      field: 'image',
      filename: 'product.jpg',
      verdict: 'safe',             // 'safe' | 'blocked' | 'error'
      optimized_url: 'https://cdn.filtrate.io/…webp',
      size_reduction: '67%',
      compliance: {
        verdict: 'safe',
        categories: { explicit: false, fraud: false, banned_logo: false },
      }
    }
  ],
  passed: true,      // false if any file was blocked
  blocked: [],       // blocked files (empty when all pass)
}
install
npm install @filtrate/fastify @fastify/multipart
server.js
import Fastify from 'fastify';
import multipart from '@fastify/multipart';
import { filtrate } from '@filtrate/fastify';

const fastify = Fastify();
await fastify.register(multipart);

// Drop in. One line.
await fastify.register(filtrate, {
  apiKey: process.env.FILTRATE_API_KEY
});

fastify.post('/upload', async (request, reply) => {
  // Blocked uploads never reach here
  const urls = request.filtrate.results.map(r => r.optimized_url);
  return { ok: true, urls };
});

await fastify.listen({ port: 3000 });
install
npm install @filtrate/next
app/api/upload/route.ts (App Router)
import { withFiltrate } from '@filtrate/next';

// Drop in. One line.
export const POST = withFiltrate(async (request, ctx) => {
  // ctx.results — per-image verdicts
  // ctx.passed  — true if all images are safe
  const urls = ctx.results.map(r => r.optimized_url);
  return Response.json({ ok: true, urls });
});
pages/api/upload.ts (Pages Router)
import multer from 'multer';
import { withFiltratePages } from '@filtrate/next';

const upload = multer({ storage: multer.memoryStorage() });

export default function handler(req, res) {
  upload.any()(req, res, () => {
    withFiltratePages(async (req, res, ctx) => {
      res.json({ ok: true, urls: ctx.results.map(r => r.optimized_url) });
    })(req, res);
  });
}

export const config = { api: { bodyParser: false } };

Options (all packages)

Option Type Default Description
apiKey string FILTRATE_API_KEY Your Filtrate API key from /dashboard
block string[] ['explicit','fraud','banned_logo'] Verdicts that reject the upload with HTTP 422
onBlock function 422 JSON Custom handler when uploads are rejected
timeout number 30000 Per-image timeout in milliseconds
mode 'sync' | 'async' 'sync' async: never blocks — results logged only
baseUrl string Filtrate API Override for local testing
npm: @filtrate/express · @filtrate/fastify · @filtrate/next — all MIT licensed, TypeScript types included.

GitHub Action — Image Privacy Scan

Add EXIF/GPS scanning to any repo in 5 lines of YAML. Every PR that touches image files gets a compliance report as a comment — GPS leaks are caught before merge.

GitHub Marketplace

5-line quickstart

.github/workflows/filtrate-scan.yml
name: Image Privacy Scan
on: pull_request
jobs:
  scan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write   # required to post the PR comment
    steps:
      - uses: actions/checkout@v4
      - uses: Polsia-Inc/filtrate-scan-action@v1
        with:
          api-key: ${{ secrets.FILTRATE_API_KEY }}
          fail-on: gps   # fail CI when GPS coordinates are found
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

That's it. The action diffs the PR for changed image files, calls the Filtrate scan API, posts a results table as a PR comment, and exits non-zero if GPS coordinates are found.

Inputs

InputRequiredDefaultDescription
api-keyNoYour Filtrate API key. Optional — anonymous scanning works without a key (rate-limited).
fail-onNogpsgps = fail on GPS, any = fail on any EXIF, none = report only.
pathsNo**/*.{jpg,...}Glob pattern to filter which files to scan.
comment-on-prNotrueSet to false to suppress the PR comment.

Outputs

OutputDescription
issues-foundNumber of images with EXIF/GPS issues.
report-urlLink to Filtrate signup/docs for scanned images.

View on GitHub Marketplace →   |   Get API key →

Ready to integrate?

Get your API key and start processing images in under 60 seconds. No credit card required.