Leaf Disease ID API
The free leaf check runs entirely in the browser and has no API: it is a ViT-base/16 model plus the 38-condition layer in /head.json, all served with the page. What you can call from code is the paid Expert check: one leaf photo goes to an OpenAI vision model (gpt-terra, served by SkillSafe), which returns the crop, the most likely problem, what in the photo shows it, look-alikes and what to do, as one JSON object.
Base URL https://api.skillsafe.ai/v1/app-api. Every response is {"ok":true,"data":…} or {"ok":false,"error":{"code","message"}}. A run is paid with SkillSafe credits from the signed-in account; /me and /estimate are free. A first opinion from a photo, not a diagnosis.
Errors
| HTTP | code | meaning |
|---|---|---|
| 400 | validation_error | Body is not a JSON object, a field has the wrong type, or task / local_top3 is missing. |
| 401 | unauthorized | Missing or expired token, or a guest token on a run: sign in for a personal token. |
| 402 | insufficient_credits | Balance below the run's minimum. Top up at skillsafe.ai. |
| 404 | not_found | A $files id that does not exist or belongs to another token. |
| 429 | rate_limited | Slow down and retry with the same Idempotency-Key. |
1. Get a token
Open the token page, sign in, and copy your token. A guest token can call /me and /estimate but cannot run.
2. Check the session — GET /me
curl -s https://api.skillsafe.ai/v1/app-api/me \
-H "Authorization: Bearer YOUR_TOKEN"
import requests
H = {"Authorization": "Bearer YOUR_TOKEN"}
print(requests.get("https://api.skillsafe.ai/v1/app-api/me", headers=H).json()["data"])
const H = { Authorization: "Bearer YOUR_TOKEN" };
const me = await (await fetch("https://api.skillsafe.ai/v1/app-api/me", { headers: H })).json();
console.log(me.data.subject_type, me.data.credits);
3. Upload the leaf — POST /files
Multipart, field file. Crop to a square around one leaf and shrink it first: the page sends at most 768 px, JPEG, without EXIF. File ids belong to the token that uploaded them.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/files \
-H "Authorization: Bearer YOUR_TOKEN" \
-F "file=@leaf.jpg;type=image/jpeg"
# -> {"ok":true,"data":{"file":{"file_id":"fil_...", ...}}}
with open("leaf.jpg", "rb") as f:
r = requests.post("https://api.skillsafe.ai/v1/app-api/files", headers=H,
files={"file": ("leaf.jpg", f, "image/jpeg")})
file_id = r.json()["data"]["file"]["file_id"]
const fd = new FormData();
fd.append("file", leafBlob, "leaf.jpg");
const up = await (await fetch("https://api.skillsafe.ai/v1/app-api/files", { method: "POST", headers: H, body: fd })).json();
const fileId = up.data.file.file_id;
4. Input fields
All values are strings; local_top3 is JSON text. Without a local result, send "[]" and an empty local_verdict.
| field | type | required | meaning |
|---|---|---|---|
task | string | yes | Always "expert_check". |
local_top3 | string | yes | JSON array of up to 3 {"crop","condition","confidence"} from the free check, e.g. [{"crop":"Tomato","condition":"Late blight","confidence":"81%"}]. The model judges the photo itself and then says whether it agrees. |
local_verdict | string | no | The free check's verdict: confident, likely, uncertain, unfamiliar or not-leaf. |
crop_hint | string | no | The plant, if known, e.g. "Tomato". A strong hint, not a fact. |
colour_map | string | no | Share of the leaf that looks yellowing, brown or pale, e.g. "34% discoloured (yellowing 12%, brown 22%); leaf fills 61% of the square". |
note | string | no | Up to 400 characters from the grower: where it grows, when it started. Treated as data. |
$files | array | yes | One file id from step 3. |
5. Price it — POST /estimate (free)
hold_credits is the most the run can reserve; you are charged charged_credits, usually far less. 10,000 credits = $1.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
-H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
-d '{"task":"expert_check","crop_hint":"Tomato","local_verdict":"likely","local_top3":"[{\"crop\":\"Tomato\",\"condition\":\"Late blight\",\"confidence\":\"81%\"}]","colour_map":"","note":"","$files":["FILE_ID"]}'
import json
top3 = [{"crop": "Tomato", "condition": "Late blight", "confidence": "81%"}]
body = {"task": "expert_check", "crop_hint": "Tomato", "local_verdict": "likely",
"local_top3": json.dumps(top3), "colour_map": "", "note": "", "$files": [file_id]}
print(requests.post("https://api.skillsafe.ai/v1/app-api/estimate", headers=H, json=body).json()["data"])
const top3 = [{ crop: "Tomato", condition: "Late blight", confidence: "81%" }];
const body = { task: "expert_check", crop_hint: "Tomato", local_verdict: "likely",
local_top3: JSON.stringify(top3), colour_map: "", note: "", $files: [fileId] };
const est = await fetch("https://api.skillsafe.ai/v1/app-api/estimate", { method: "POST",
headers: { ...H, "Content-Type": "application/json" }, body: JSON.stringify(body) });
console.log((await est.json()).data);
6. Run — POST /run, then poll GET /jobs/{id}
The body is the same object as the estimate. Send an Idempotency-Key: a retry with the same key never bills twice.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/run \
-H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: leaf-2026-11-01-001" \
-d @body.json # body.json = the object from step 5
# -> {"ok":true,"data":{"job_id":"job_...", ...}}
curl -s https://api.skillsafe.ai/v1/app-api/jobs/JOB_ID -H "Authorization: Bearer YOUR_TOKEN"
# repeat until data.status is "succeeded" or "failed"; the reply text is data.output.output
import time
r = requests.post("https://api.skillsafe.ai/v1/app-api/run",
headers={**H, "Idempotency-Key": "leaf-2026-11-01-001"}, json=body)
job_id = r.json()["data"]["job_id"]
while True:
job = requests.get(f"https://api.skillsafe.ai/v1/app-api/jobs/{job_id}", headers=H).json()["data"]
if job["status"] in ("succeeded", "failed"):
break
time.sleep(1.5)
answer = json.loads(job["output"]["output"])
print(answer["summary"], job["charged_credits"])
const run = await fetch("https://api.skillsafe.ai/v1/app-api/run", { method: "POST",
headers: { ...H, "Content-Type": "application/json", "Idempotency-Key": "leaf-2026-11-01-001" },
body: JSON.stringify(body) });
const jobId = (await run.json()).data.job_id;
let job;
do {
await new Promise((r) => setTimeout(r, 1500));
job = (await (await fetch(`https://api.skillsafe.ai/v1/app-api/jobs/${jobId}`, { headers: H })).json()).data;
} while (job.status !== "succeeded" && job.status !== "failed");
const answer = JSON.parse(job.output.output);
console.log(answer.summary, job.charged_credits);
Output contract
output.output is one JSON object. If a reply is cut off, the page closes the open brackets and shows what arrived; do the same, or retry.
{
"photo": {"status": "ok", "note": "Sharp, evenly lit upper surface of one leaflet."},
"summary": "Most likely late blight on a tomato leaf; fairly sure.",
"crop": {"name": "Tomato", "confidence": "high"},
"problem": {"name": "Late blight", "category": "disease",
"cause": "Phytophthora infestans (oomycete)", "confidence": "medium"},
"evidence": ["Large greasy dark-brown patches not limited by veins", "Pale green margin around the lesions"],
"alternatives": [{"name": "Early blight", "category": "disease",
"tell_apart": "Early blight spots are smaller, with concentric rings, on older leaves first."}],
"management": {"cultural": ["Remove and bag affected leaves", "Water at the base, in the morning"],
"options": ["Protectant fungicides labelled for late blight on tomato"]},
"extension": "Contact your extension service now: late blight spreads fast and may need reporting.",
"local_check": {"agrees": "yes", "note": "Matches the free check's first answer."}
}
| key | values |
|---|---|
photo.status | ok, poor, too_blurry, not_leaf. On not_leaf, crop and problem are null. |
problem.category | disease, pest, nutrient, abiotic, healthy, unknown. |
*.confidence | high, medium, low. |
local_check.agrees | yes, partly, no, n/a. |
management.options | Product groups only: never doses, brand names or spray intervals, which differ by country and crop. |
Check what is legal for your crop and country with a local extension service or plant clinic before buying or spraying anything.