Droidwatch API Reference

Base URL: https://droidwatch.app

All endpoints return JSON. Errors follow the format:

{ "detail": "Human-readable error message", "request_id": "rid-abc123" }

Authentication

Droidwatch supports two credential types:

Type Header Value
JWT Bearer Authorization Bearer <jwt_token>
API key X-API-Key dw_<key>

Create an API key from the dashboard (Profile → API keys). Send it on every request as the X-API-Key header. Anonymous requests are allowed on a limited set of endpoints (uploads are rate-limited by IP).

POST /api/auth/login

Obtain a JWT access token.

curl -X POST https://droidwatch.app/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "username": "analyst",
    "password": "your-password"
  }'

Response:

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "bearer",
  "expires_in": 28800,
  "username": "analyst",
  "role": "user",
  "plan": "pro",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

The token expires after expires_in seconds (default 8 hours). Use the refresh_token with POST /api/auth/refresh-token to get a new access token without re-entering credentials.

Authenticated request pattern:

TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
curl -H "Authorization: Bearer $TOKEN" https://droidwatch.app/api/runs

Upload and Analysis

POST /api/upload

Upload an APK file for analysis. Returns a run_id used in all subsequent calls. Anonymous uploads are allowed (rate-limited by IP).

curl -X POST https://droidwatch.app/api/upload \
  -H "Authorization: Bearer $TOKEN" \
  -F "file=@/path/to/app.apk"

Response:

{
  "run_id": "a3f8e2d1-7b4c-4e9a-8f1d-2c5a6b3d9e0f",
  "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
  "file_size": 4194304,
  "status": "uploaded",
  "message": "Upload accepted."
}

Limits by plan:

Plan Max file size Analyses/day
Anonymous 150 MB 3/day
Free 200 MB 5/day
Pro 500 MB 100/day
Team 750 MB 500/day
Enterprise 1 GB Unlimited

POST /api/analyze

Start analysis for an uploaded file.

curl -X POST https://droidwatch.app/api/analyze \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"run_id": "a3f8e2d1-7b4c-4e9a-8f1d-2c5a6b3d9e0f"}'

GET /api/jobs/{job_id}

Poll analysis status. Returns immediately with current progress.

job_id and run_id are different UUIDs. POST /api/analyze returns both: poll with the job_id, then fetch the report with the run_id. This page used to say /api/jobs/{run_id}, which never worked.

curl "https://droidwatch.app/api/jobs/a3f8e2d1-7b4c-4e9a-8f1d-2c5a6b3d9e0f" \
  -H "Authorization: Bearer $TOKEN"

Response:

{
  "run_id": "a3f8e2d1-7b4c-4e9a-8f1d-2c5a6b3d9e0f",
  "status": "running",
  "stage": "dex_analysis",
  "progress": 45,
  "started_at": "2026-05-16T10:23:01Z",
  "estimated_remaining_sec": 12
}

status values: queued | running | done | failed — the terminal ones are done and failed. There is no completed.


Reports

GET /api/runs/{run_id}/artifact/report.json

Fetch the full analysis report for a completed run.

curl "https://droidwatch.app/api/runs/a3f8e2d1-7b4c-4e9a-8f1d-2c5a6b3d9e0f/artifact/report.json" \
  -H "Authorization: Bearer $TOKEN" | python -m json.tool

Allowed artifacts: report.json, report.html, summary.csv.

Earlier versions of this page documented GET /api/runs/{run_id}/report. That route does not exist and never has.

score is 0-100, where higher is worse. Verdict cut-offs: 65+ Malicious, 40-64 High Risk, 20-39 Suspicious, under 20 Benign. The verdict string is exactly one of Benign, Suspicious, High Risk, Malicious.

Network indicators, certificates and YARA matches are not top-level keys: they live inside their own entry of sections, keyed by key.

Response schema (top level):

{
  "run_id": "a3f8e2d1-7b4c-4e9a-8f1d-2c5a6b3d9e0f",
  "created_at": "2026-05-16T10:23:45Z",
  "analyzer_version": "2.0.0",
  "platform": "android",
  "metadata": {
    "package": "com.example.banking",
    "app_name": "My Banking App",
    "version_name": "3.2.1",
    "version_code": 421,
    "min_sdk": 24,
    "target_sdk": 34,
    "file_size": 4194304,
    "sha256": "e3b0c44298fc1c...",
    "md5": "d41d8cd98f00b2...",
    "cert_summary": { "...": "signing certificate" }
  },
  "overview": {
    "score": 63,
    "verdict": "High Risk",
    "confidence": 0.86,
    "severities": { "critical": 1, "high": 3, "medium": 7, "low": 4, "info": 12 },
    "top_tags": ["overlay", "sms"],
    "top_signals": ["...findings..."],
    "score_breakdown": ["...contributions..."]
  },
  "sections": [
    {
      "key": "manifest",
      "title": "Manifest",
      "metrics": {},
      "tables": [],
      "findings": ["..."]
    }
  ],
  "narrative": "Plain-language summary of the analysis.",
  "behavior_summary": ["...attack chain steps (paid plans)..."],
  "ttp_mapping": ["...MITRE ATT&CK Mobile entries..."],
  "vt_result": null,
  "mobsf_result": null,
  "coverage": ["...per-stage status: what ran and what did not..."],
  "toolchain": { "...": "tool versions used for this run" }
}

GET /api/runs/{run_id}/report/pdf

Export as a PDF executive summary (Pro plan and above).

curl "https://droidwatch.app/api/runs/$RUN_ID/report/pdf" \
  -H "Authorization: Bearer $TOKEN" \
  -o report.pdf

GET /api/runs/{run_id}/report/stix

Export findings as a STIX 2.1 bundle for SIEM/SOAR ingestion.

curl "https://droidwatch.app/api/runs/$RUN_ID/report/stix" \
  -H "Authorization: Bearer $TOKEN" | python -m json.tool

Threat Feed

The threat feed is public — no authentication required.

GET /api/threat-feed

Paginated list of recent malicious/suspicious public samples.

curl "https://droidwatch.app/api/threat-feed?limit=20&verdict=Malicious"

Query parameters:

Parameter Type Default Description
limit int 50 Results per page (max 500)
offset int 0 Pagination offset
verdict string — Filter: Malicious, High Risk, Suspicious
format string json Response format: json, jsonl, csv, stix

JSONL streaming (for large exports):

curl "https://droidwatch.app/api/threat-feed?format=jsonl&limit=500" \
  > feed.jsonl

STIX 2.1 bundle:

curl "https://droidwatch.app/api/threat-feed?format=stix" \
  | python -m json.tool

GET /api/threat-feed/lookup

Hash, IP, domain, or package name lookup.

# SHA-256 lookup
curl "https://droidwatch.app/api/threat-feed/lookup?q=e3b0c44298fc1c149afbf4c8996fb924"

# Package name lookup
curl "https://droidwatch.app/api/threat-feed/lookup?q=com.example.suspiciousapp"

# Domain lookup
curl "https://droidwatch.app/api/threat-feed/lookup?q=evil-c2.example.com"

Response:

{
  "query": "com.example.suspiciousapp",
  "type": "package",
  "matches": [
    {
      "run_id": "a3f8e2d1-...",
      "sha256": "e3b0c44...",
      "verdict": "Malicious",
      "score": 87,
      "created_at": "2026-05-10T08:14:22Z",
      "share_url": "/r/a3f8e2d1-..."
    }
  ]
}

App Explorer

Public app index — no authentication required.

GET /api/explore

Paginated list of known packages with aggregated analysis statistics.

curl "https://droidwatch.app/api/explore?limit=20&offset=0"

Query parameters:

Parameter Type Default Description
limit int 20 Results per page
offset int 0 Pagination offset
verdict string — Filter by verdict
sort string latest Sort: latest, score, name

Response:

{
  "total": 1482,
  "items": [
    {
      "package": "com.example.banking",
      "app_name": "Fake Banking App",
      "latest_verdict": "Malicious",
      "latest_score": 87,
      "run_count": 3,
      "last_seen": "2026-05-15T14:22:11Z"
    }
  ]
}

GET /api/explore/search

Prefix/substring search by package name or app name.

curl "https://droidwatch.app/api/explore/search?q=banking&limit=10"

Query parameters:

Parameter Type Description
q string Search term (package name or app name)
limit int Max results (default 20, max 100)

Batch Analysis

Up to 20 files per batch. Requires an authenticated account with a verified email address.

Analysing a batch is two calls, not one: /upload stores the files and hands back an upload_id for each, and /analyze is what actually queues the jobs.

POST /api/batch/upload

Upload multiple APKs in a single request.

curl -X POST https://droidwatch.app/api/batch/upload \
  -H "Authorization: Bearer $TOKEN" \
  -F "[email protected]" \
  -F "[email protected]" \
  -F "[email protected]"

Response:

{
  "uploaded": [
    {"upload_id": "a1b2c3d4", "filename": "app1.apk", "size": 4194304},
    {"upload_id": "e5f6a7b8", "filename": "app2.apk", "size": 8388608}
  ],
  "errors": [],
  "total_uploaded": 2,
  "total_errors": 0
}

A file the server rejects (wrong extension, over the plan's size limit) lands in errors and does not stop the rest of the batch.

POST /api/batch/analyze

Queue an analysis for each upload. This is the call that returns the batch_id.

curl -X POST https://droidwatch.app/api/batch/analyze \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"uploads": [
        {"upload_id": "a1b2c3d4", "filename": "app1.apk"},
        {"upload_id": "e5f6a7b8", "filename": "app2.apk"}
      ]}'

Response:

{
  "batch_id": "7f3a2b1c-...",
  "started": [
    {"job_id": "...", "run_id": "...", "upload_id": "a1b2c3d4",
     "filename": "app1.apk", "status": "queued"}
  ],
  "errors": [],
  "total_started": 2,
  "total_errors": 0
}

GET /api/batch/{batch_id}

Poll progress for every job in the batch.

curl "https://droidwatch.app/api/batch/7f3a2b1c-..." \
  -H "Authorization: Bearer $TOKEN"

Response:

{
  "batch_id": "7f3a2b1c-...",
  "overall_status": "running",
  "progress_pct": 33,
  "counts": {"queued": 1, "running": 1, "done": 1, "failed": 0},
  "jobs": [{"job_id": "...", "run_id": "...", "status": "done", "progress": 100}]
}

Batches expire 24 hours after they are created, after which this returns 404.


Error codes

HTTP Status Meaning
400 Bad request — invalid parameters or body
401 Not authenticated — missing or expired token
403 Forbidden — insufficient role or plan
404 Resource not found
413 APK exceeds the size limit for your plan
429 Rate limit exceeded
500 Internal server error — include request_id when reporting

SDK / code examples

Python

import requests

BASE = "https://droidwatch.app"
TOKEN = "eyJhbGci..."

session = requests.Session()
session.headers["Authorization"] = f"Bearer {TOKEN}"

# Upload
with open("suspicious.apk", "rb") as f:
    r = session.post(f"{BASE}/api/upload", files={"file": f})
run_id = r.json()["run_id"]

# Trigger analysis. Keep BOTH ids: you poll with job_id and fetch with run_id.
job_id = session.post(f"{BASE}/api/analyze", json={"run_id": run_id}).json()["job_id"]

# Poll. The terminal states are exactly "done" and "failed" — there is no
# "completed", and waiting for one is an infinite loop.
import time
while True:
    job = session.get(f"{BASE}/api/jobs/{job_id}").json()
    if job["status"] in ("done", "failed"):
        break
    time.sleep(3)

# Fetch report
report = session.get(f"{BASE}/api/runs/{run_id}/artifact/report.json").json()
print(report["overview"]["verdict"], report["overview"]["score"])