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_idandrun_idare different UUIDs.POST /api/analyzereturns both: poll with thejob_id, then fetch the report with therun_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"])