API reference
Partner samples use base https://api.deepleaf.org (and wss://api.deepleaf.org for live calls). Mint keys in the developers console. Audience overview: Developers hub.
Crop Health image identify: /identification on https://api.deepleaf.org.
Quickstart
Your first answer from the agronomist in under five minutes.
- In Developers → API keys, mint an organisation key with the
agronomistscope. Copy thedlk_live_…secret now; it is shown once. Keep it on your server. - Pick a farmer id: any stable id of yours, up to 128 characters (a user id, or
wa:plus a phone number). The same id always reaches the same farmer, fields, and chat. - Send one request. Use any OpenAI client with
model: agronomist, or plain HTTP.
curl https://api.deepleaf.org/v1/chat/completions \
-H "Authorization: Bearer $DEEPLEAF_KEY" \
-H "X-DeepLeaf-Farmer-Id: farmer-4821" \
-H "Content-Type: application/json" \
-d '{
"model": "agronomist",
"messages": [{ "role": "user", "content": "When should I plant tomatoes?" }]
}'That turn is now in the farmer's chat, with its thoughts and tool runs. Open it in the app as that farmer and it is the same conversation. Try it without code in Developers → Playground.
- Need cards, proposals, pins, or voice replies? Use the DeepLeaf-shaped turn (see DeepLeaf-shaped turn).
- Running a WhatsApp bot? Go straight to the relay guide (see WhatsApp bot relay).
- Putting the chat in a web page? Use an embed token and the React SDK (see Embed and React SDK).
Authentication
Organisation keys on servers, embed tokens in browsers, one farmer id per call.
| Credential | Where | Send |
|---|---|---|
Organisation key dlk_live_… | Your server only | Authorization: Bearer dlk_live_… plus X-DeepLeaf-Farmer-Id on every farmer call. Needs the agronomist scope. |
| Embed token (RS256 JWT, 900 s) | A browser page | Authorization: Bearer <token>. It already names the farmer, so no farmer header. |
| Session | The DeepLeaf app and white-label hosts | Same-origin cookie. Not for partner servers. |
- Keys are minted and revoked in Auth (Developers → API keys). The secret is shown once; DeepLeaf keeps only a digest.
- Scopes are per API:
agronomist(/v1),hub(Data Hub),kb(knowledge base),auth. A key without the scope for the API it calls is refused401. - Without
X-DeepLeaf-Farmer-Id, an organisation-key call is404 farmer_not_found. An id longer than 128 characters is422 validation_error. - An organisation id in a request body is ignored. The credential decides the organisation.
- If Auth cannot be reached the answer is
503 auth_unavailable, never a pass.
OpenAI-compatible client
POST /v1/chat/completions with model: agronomist. Text and photos in, text out.
Point any OpenAI SDK at the Agronomist API base URL. model must be agronomist; anything else is 400. The server owns the history, so only the last user message is read: send the new act, not the transcript. Each call continues that farmer's most recent chat (a new farmer gets a new chat).
import base64
with open("leaf.jpg", "rb") as f:
photo = "data:image/jpeg;base64," + base64.b64encode(f.read()).decode()
reply = client.chat.completions.create(
model="agronomist",
messages=[{
"role": "user",
"content": [
{"type": "text", "text": "What is wrong with this leaf?"},
{"type": "image_url", "image_url": {"url": photo}},
],
}],
)const stream = await client.chat.completions.create({
model: "agronomist",
stream: true,
messages: [{ role: "user", content: "Is rain coming this week?" }],
});
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
}| Today | |
|---|---|
| Text in | content as a string or text parts. |
| Photo in | image_url parts with a data:image/…;base64, URL (PNG, JPEG, WebP, up to 8 MB). DeepLeaf does not fetch photos from the web. The last valid image is the photo. |
| Audio in | Not read on this route. Send the clip to POST /v1/dictation, then send the text. |
| Text out | choices[0].message.content, model: agronomist, id = the turn id. |
| Audio out | Not on this route. Use the DeepLeaf-shaped turn: its audio events carry the spoken reply. |
stream: true | Returns OpenAI-shaped chat.completion.chunk frames and [DONE], but only after the turn has finished. For live tokens, thoughts, and tools, use the DeepLeaf-shaped turn. |
| Empty credits | 402 credits_empty before the model runs. |
DeepLeaf-shaped turn
Threads plus one streaming act: thoughts, tools, cards, proposals, and voice.
Open a thread once per farmer and keep its id (or read it back with GET /v1/threads?limit=1). Then send each act to POST /v1/threads/{id}/messages. The answer is Server-Sent Events as the agronomist works.
# 1. Once per farmer: open a chat and keep its id.
curl https://api.deepleaf.org/v1/threads -X POST \
-H "Authorization: Bearer $DEEPLEAF_KEY" \
-H "X-DeepLeaf-Farmer-Id: farmer-4821" \
-H "Content-Type: application/json" -d '{"language": "fr"}'
# → { "id": "thread_…", "open_proposal_id": null, … }
# 2. Every act: send only the new act. The server owns the history.
curl -N https://api.deepleaf.org/v1/threads/$THREAD_ID/messages -X POST \
-H "Authorization: Bearer $DEEPLEAF_KEY" \
-H "X-DeepLeaf-Farmer-Id: farmer-4821" \
-H "Idempotency-Key: wamid.HBgM…" \
-H "Content-Type: application/json" \
-d '{"text": "My tomato leaves have yellow spots."}'| Field | Meaning |
|---|---|
text | What the farmer typed or said. |
images | [{ data, content_type }], base64 or a data URL. The last valid one is the photo. An invalid image is a named error in the chat; the turn continues without it. |
audio | { data, content_type, duration_ms } for a stored voice act (at least 2 s). For a voice note, prefer POST /v1/dictation. |
dictation | true when text came from dictation. Billed as text. |
location | { lat, lon, accuracy_m? } — where the device is. |
capture | { kind: "field_boundary" | "scouting", pins? }. Capture and diagnosis are separate requests. |
confirm / reject | { proposal_id } of the open proposal. Not both. |
suggestion_id | The chip the farmer tapped, e.g. share_location. |
language | Override the chat language for this act. |
client_message_id | Your id for the act. With Idempotency-Key, a retry replays the same turn instead of writing twice. |
event: tool_start
data: {"id":"call_1","name":"observe_field_status","input":{…}}
event: tool_result
data: {"id":"call_1","name":"observe_field_status","ok":true,"source":"hub","as_of":"2026-09-30T06:00:00Z","gap":null,…}
event: text
data: {"id":"msg_…","delta":"Early blight is likely. ","model":"agronomist"}
event: card
data: {"kind":"diagnosis",…}
event: done
data: {"turn_id":"turn_…","thread_id":"thread_…","model":"agronomist","usage":{…},"remaining":{"ai_credits":"41.20"}}| Event | Payload |
|---|---|
thought | { id, delta, text } — the fold. Collapse to “Thought for Ns” when the answer starts. |
tool_start / tool_result | { id, name, input } / { id, name, ok, source, as_of, gap, output }. A gap is a fact DeepLeaf could not ground; say it, do not fill it. |
text | { id, delta, model }. A final frame may carry text (the checked answer) with an empty delta: prefer text when present. |
card | Structured work: kind is e.g. diagnosis, products, practices, map, field_data. |
suggestions | Chips to offer next (ids you send back as suggestion_id). |
proposal | { proposal_id, diff } — a write waiting for the farmer. The thread pauses. |
audio | Spoken reply as audio/pcm frames (data, seq), then a final: true frame with audio_id and url (/v1/clips/{id}). fallback: true means speech failed; show the text. |
language | { language } — the chat language changed. |
error | { code, message, support_id }. Lasting ones are also rows in the chat. |
done | { turn_id, thread_id, title, model, usage, remaining }. Always last. |
Proposals and confirm
The model suggests, the farmer confirms that exact proposal id, the app writes once.
Any write (create a field, write a season, schedule a spray, put a task on the calendar) arrives as a proposal event with a diff. Show the diff. Nothing is written until you send confirm with that proposal_id on the same thread. Before writing, DeepLeaf reads the field and season again; if they drifted it answers 409 field_drifted and asks again.
# The stream sent: event: proposal data: {"proposal_id":"prop_…","diff":{…}}
# Show the diff. When the farmer says yes, confirm THAT id on the same thread:
curl -N https://api.deepleaf.org/v1/threads/$THREAD_ID/messages -X POST \
-H "Authorization: Bearer $DEEPLEAF_KEY" \
-H "X-DeepLeaf-Farmer-Id: farmer-4821" \
-H "Content-Type: application/json" \
-d '{"confirm": {"proposal_id": "prop_…"}}'
# Or close it without a write:
# -d '{"reject": {"proposal_id": "prop_…"}}'
# Any new question, photo, or speech also closes the open proposal.- Confirming an id that is not the open one is
409 proposal_mismatch. GET /v1/threads/{id}returnsopen_proposal_id, so a relay that restarted can still show the pending question.- A retry of the same confirm writes once.
Fields and capture
Create fields in the chat, bind a chat to a field, read field status.
A farmer creates a field in the chat, on your channel. At the field: share one pin per corner (at least three), then say “done” in their language. Away from the field, the app opens a map. The agronomist proposes the field; you confirm. After the write, weather, soil, and satellite jobs start (hectares_processed pool); diagnosis and land advice wait until the field is ready.
{
"capture": { "kind": "field_boundary" },
"location": { "lat": 33.5731, "lon": -7.5898, "accuracy_m": 8 },
"suggestion_id": "share_location"
}
// Repeat per corner. The farmer says "done" (in their language) to close the
// ring; the agronomist then proposes the field, and you confirm that id.| Route | Use |
|---|---|
GET /v1/fields | Fields this farmer may read. |
GET /v1/fields/{id} | One field. |
GET /v1/fields/{id}/status | Pipeline readiness: queued / running / ready / failed per job. |
POST /v1/threads/{id}/field | { field_id } — focus the chat on a field. No model, no meter. |
POST /v1/threads/{id}/field-setup | Start field setup in the chat without a user act. |
POST /v1/fields | { name, ring: [{lat, lon}, …], land_kind: open_land | greenhouse, timezone } — direct write for a signed-in session. Partner integrations should create fields through the chat so the farmer confirms. |
WhatsApp bot relay
Keep your number and your Meta app. Map each WhatsApp message to one DeepLeaf act.
Meta sends messages to your server. Your server calls the Agronomist API with your organisation key and a farmer id derived from the WhatsApp number (for example wa:212600000000). DeepLeaf answers on the same stream the app gets; you send the reply back through Meta. Same agronomist, same fields, same chat: the farmer can open the app later and see it.
| Send to DeepLeaf | |
|---|---|
| Text | { text } |
| Image | Download the media from Meta, then { text: caption, images: [{ data, content_type }] } |
| Location pin | { capture: { kind: "field_boundary" }, location: { lat, lon }, suggestion_id: "share_location" } (one per corner; the farmer says “done”) |
| Voice note | Dictation: POST /v1/dictation { audio: { data, content_type: "audio/ogg" } } → { text }, then { text, dictation: true }. Billed as text. |
| Yes / No button | { confirm: { proposal_id } } or { reject: { proposal_id } } |
| Voice call | Stays on your number. Bridge the call audio to POST /v1/calls and its live socket (see Voice and video calls). |
// WhatsApp relay: Meta → your server → DeepLeaf → your server → Meta.
// Your number, your Meta app. DeepLeaf only sees the farmer id you choose.
import express from "express";
const API = "https://api.deepleaf.org/v1";
const KEY = process.env.DEEPLEAF_KEY; // dlk_live_…, server only
const threads = new Map(); // farmerId → thread id (persist this)
const dl = (farmerId, path, init = {}) =>
fetch(API + path, {
...init,
headers: {
Authorization: `Bearer ${KEY}`,
"X-DeepLeaf-Farmer-Id": farmerId,
"Content-Type": "application/json",
...init.headers,
},
});
async function threadFor(farmerId) {
if (!threads.has(farmerId)) {
const t = await (await dl(farmerId, "/threads", { method: "POST", body: "{}" })).json();
threads.set(farmerId, t.id);
}
return threads.get(farmerId);
}
// Meta message → one DeepLeaf act.
async function toAct(farmerId, msg) {
switch (msg.type) {
case "text":
return { text: msg.text.body };
case "image": {
const { data, mime } = await downloadMetaMedia(msg.image.id); // your helper
return { text: msg.image.caption ?? "", images: [{ data, content_type: mime }] };
}
case "location":
return {
capture: { kind: "field_boundary" },
location: { lat: msg.location.latitude, lon: msg.location.longitude },
suggestion_id: "share_location",
};
case "audio": {
// A voice note is dictation: one clip → finished text → a text turn.
const { data, mime } = await downloadMetaMedia(msg.audio.id);
const heard = await (await dl(farmerId, "/dictation", {
method: "POST",
body: JSON.stringify({ audio: { data, content_type: mime } }),
})).json();
return { text: heard.text, dictation: true };
}
case "interactive": // your Yes / No buttons on a proposal
return msg.interactive.button_reply.id.startsWith("yes:")
? { confirm: { proposal_id: msg.interactive.button_reply.id.slice(4) } }
: { reject: { proposal_id: msg.interactive.button_reply.id.slice(3) } };
}
return null;
}
const app = express();
app.post("/meta/webhook", express.json(), async (req, res) => {
res.sendStatus(200); // Meta wants a fast 200; do the turn after.
for (const msg of req.body.entry?.[0]?.changes?.[0]?.value?.messages ?? []) {
const farmerId = `wa:${msg.from}`; // stable, ≤128 chars, yours
const act = await toAct(farmerId, msg);
if (!act) continue;
const threadId = await threadFor(farmerId);
const r = await dl(farmerId, `/threads/${threadId}/messages`, {
method: "POST",
headers: { "Idempotency-Key": msg.id }, // Meta retries; DeepLeaf writes once
body: JSON.stringify(act),
});
for await (const [event, data] of readSse(r.body)) {
if (event === "text" && data.delta) await bufferText(msg.from, data.delta);
if (event === "proposal") await sendYesNo(msg.from, data.proposal_id, data.diff);
if (event === "audio" && data.final && data.url) await sendVoice(msg.from, farmerId, data.url);
if (event === "error") await sendText(msg.from, data.message);
}
await flushText(msg.from);
}
});
// Minimal SSE reader for fetch bodies.
async function* readSse(body) {
const decoder = new TextDecoder();
let buffer = "";
for await (const chunk of body) {
buffer += decoder.decode(chunk, { stream: true });
let cut;
while ((cut = buffer.indexOf("\n\n")) >= 0) {
const frame = buffer.slice(0, cut);
buffer = buffer.slice(cut + 2);
const event = /^event: (.*)$/m.exec(frame)?.[1];
const data = /^data: (.*)$/m.exec(frame)?.[1];
if (event && data) yield [event, JSON.parse(data)];
}
}
}
// Spoken reply: GET the clip with the same key + farmer header, then convert
// WAV → OGG/Opus (WhatsApp voice notes require it) and upload to Meta.
async function sendVoice(to, farmerId, url) {
const wav = Buffer.from(await (await dl(farmerId, url.replace("/v1", ""))).arrayBuffer());
await sendMetaVoice(to, await wavToOggOpus(wav)); // your helpers (e.g. ffmpeg)
}
app.listen(3000);- Answer Meta with
200first, then run the turn. Send the WhatsApp message id asIdempotency-Keyso Meta's retries write once. - Keep one thread id per farmer. The farmer id must stay stable: a new id is a new farmer and drops the old chat.
- Reply text is short and speakable by design. Send
textdeltas as one WhatsApp message whendonearrives. - For a spoken reply, read the
audioevent withfinal: true,GETitsurlwith the same key and farmer header (WAV), convert to OGG/Opus, and upload to Meta. - If DeepLeaf does not know the farmer's name it asks once, after answering the first question.
- A turn can take tens of seconds when tools run. Show a typing indicator while the stream is open.
Voice and video calls
POST /v1/calls opens a room; audio rides a WebSocket. Same harness, same chat.
// npm install ws
import WebSocket from "ws";
const headers = {
Authorization: `Bearer ${process.env.DEEPLEAF_KEY}`,
"X-DeepLeaf-Farmer-Id": "farmer-4821",
};
const call = await (await fetch("https://api.deepleaf.org/v1/calls", {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ thread_id: THREAD_ID, kind: "audio", language: "fr" }),
})).json();
// → { id, status: "started" | "failed", live: { socket }, unavailable_reason? }
if (!call.live) throw new Error(call.unavailable_reason ?? "call not live");
const ws = new WebSocket("wss://api.deepleaf.org" + call.live.socket, { headers });
ws.on("message", (raw) => {
const msg = JSON.parse(raw);
if (msg.type === "audio") playPcm16(msg.data); // base64 PCM16 mono
if (msg.type === "ended" || msg.type === "error") ws.close();
});
// Microphone: 16 kHz PCM16 mono, base64.
micFrames((pcm) => ws.send(JSON.stringify({ type: "audio", data: pcm.toString("base64") })));
// Hang up:
// ws.send(JSON.stringify({ type: "hangup" }));| Route | Use |
|---|---|
POST /v1/calls | { thread_id, kind: audio | video, language? } → { id, status, live: { socket } }. When the calling service is down: status: failed, unavailable_reason, no minute metered. Empty minutes: 402 call_minutes_empty and a boxed error in the chat. |
WS /v1/calls/{id}/live | Up: audio (16 kHz PCM16 base64), video (JPEG base64), text, hangup. Down: ready, audio, speech, thought, thought_done, text, tool_start, tool_result, done, interrupted, activity, error, ended. Authenticate the upgrade with the same headers as HTTP. |
POST /v1/calls/{id}/speech | { text, role: user | advisor } — typed speech on a call; answers as SSE. |
POST /v1/calls/{id}/state | UI state (mute, unmute, connected, video, audio). Never written to the chat. |
POST /v1/calls/{id}/hangup | End the call. The chat keeps a divider. |
POST /v1/calls/{id}/fail | A call that died before it went live. Leaves a boxed error with a call_err_… support id. |
- Socket close codes:
4401not authenticated,4403forbidden,4409call not found or ended,4503calling unavailable. - Every call emits
call.startedand thencall.endedorcall.failedto your webhooks. - The farmer's speech, the agronomist's speech, tools, and thoughts land in the chat as they happen and stay after hangup.
Webhooks
Signed deliveries routed by your farmer id.
# Register (org key or an org-admin session). The secret is in this response only.
curl https://api.deepleaf.org/v1/webhooks -X POST \
-H "Authorization: Bearer $DEEPLEAF_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://partner.example/deepleaf/events"}'
# → { "id": "hook_…", "url": "…", "events": [ … ], "secret": "whsec_…" }
# Send yourself a test delivery:
curl https://api.deepleaf.org/v1/webhooks/events -X POST \
-H "Authorization: Bearer $DEEPLEAF_KEY" \
-H "Content-Type: application/json" \
-d '{"event": "task_reminder", "farmer_id": "farmer-4821",
"idempotency_key": "test-1", "data": {"task": "spray"}}'import hmac, hashlib
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
SECRET = os.environ["DEEPLEAF_WEBHOOK_SECRET"] # whsec_…, shown once
seen: set[str] = set() # use a real store
@app.post("/deepleaf/events")
async def events(request: Request):
raw = await request.body() # the RAW bytes, before JSON
expected = "sha256=" + hmac.new(SECRET.encode(), raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("X-DeepLeaf-Signature", "")):
raise HTTPException(401)
event = await request.json()
if event["idempotency_key"] in seen: # retries repeat the key
return {"ok": True}
seen.add(event["idempotency_key"])
route_to_farmer(event["farmer_id"], event["type"], event["data"])
return {"ok": True}| Part | Value |
|---|---|
| Body | { id, type, farmer_id, organisation_id, idempotency_key, data } — farmer_id is your id. |
| Headers | X-DeepLeaf-Signature: sha256=<hex HMAC of the raw body>, X-DeepLeaf-Signature-Timestamped: t=<unix seconds>,v1=<hex HMAC of "t." + raw body> (added 2026-10-06; refuse when t is more than 300 s old), X-DeepLeaf-Event, X-DeepLeaf-Farmer-Id |
| Retries | A non-2xx or network failure is retried, up to 3 attempts per delivery. Deduplicate on idempotency_key. |
| Events | call.started, call.ended, call.failed (sent by every call); morning_briefing (the 06:00 briefing), detection (vigour drop, frost, heat, or storm coming), scouting_due, task_reminder (a programmed task's window opens within 24 hours, once per window), and credits.low (a pool went low for this farmer: pool, remaining, estimate, billing_url; once per farmer per pool per day). |
| Manage | GET /v1/webhooks, POST /v1/webhooks, DELETE /v1/webhooks/{id} — org key or org-admin session. No farmer header. This is the only place webhooks are managed; Auth's old /organizations/{orgId}/webhooks answers 410 and points here. |
- Dual-run when signing or your receiver URL changes (Stripe pattern): register the **new** endpoint with
POST /v1/webhookswhile the old one stays active. Every event fans out to all live endpoints for the org; verify the new receiver, thenDELETEthe old id. - Dual-run for signatures: deliveries already send both
X-DeepLeaf-Signature(raw body) andX-DeepLeaf-Signature-Timestamped(replay-safe). Accept either during migration; prefer the timestamped header once your code checkstwithin 300 s. - Do not rotate the
whsec_…by editing an endpoint — revoke and create a new one so both secrets can overlap until drain.
Embed and React SDK
Mint a short-lived token on your server; mount the chat in the browser.
// Your server. Auth mints with your organisation key; the key never reaches the browser.
app.post("/api/deepleaf-token", requireYourUser, async (req, res) => {
// AUTH_ORIGIN: the Auth host for your deployment (through DeepLeaf Web: /auth-api).
const r = await fetch(`${AUTH_ORIGIN}/organizations/${ORG_ID}/embed-tokens`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.DEEPLEAF_KEY}`, // dlk_live_…, agronomist scope
"Content-Type": "application/json",
},
body: JSON.stringify({ farmerId: req.user.id }), // your stable id, ≤128 chars
});
const { token, expiresIn } = await r.json(); // RS256 JWT, 900 s
res.json({ token, expiresIn });
});@deepleaf/agronomist-sdk: one hook (useAgronomist) owns the connection; your app owns sign-in. See Developers → SDK for every export.- Mint with your organisation key (
agronomistscope) onPOST /organizations/{orgId}/embed-tokens. An org owner or admin session is also accepted. Each token names one farmer and lasts 900 seconds. - Passing an organisation key to the hook throws. Keys stay on your server.
- Map tiles use a map-read token, not a chat token:
POST /organizations/{orgId}/map-read-tokenswith a member's session → a 600-second token for tile URLs (Authorization: Beareror?access_token=). Field rights still decide which fields render. - Photos and clips on an embed are fetched with the token (
GET /v1/photos/{id},GET /v1/clips/{id}), not a baresrc. - Python servers: use the OpenAI client (see OpenAI-compatible client) or plain HTTP (see DeepLeaf-shaped turn). There is no separate Python package in this version.
Credits and billing
Four pools, each metered on its own.
| Pool | Spent by | When empty |
|---|---|---|
ai_credits | Text turns, dictation, voice notes, and the tokens and tools a call spends | 402 credits_empty before the model; on a live call, tools and tokens stop |
audio_call_minutes | Live audio calls | 402 call_minutes_empty; the call ends with a message |
video_call_minutes | Live video calls | Same as audio |
hectares_processed | Weather, soil, and satellite jobs after a field is created | 402 hectares_empty |
GET /v1/credits→{ plan, price_list, pools }for this farmer's bill.GET /v1/credits/state→ per pool:state(ok/low/empty),remaining,estimate(about N messages from this seat's measured turn cost, or N minutes),payer(organisationorindividual),can_buy,buy_url,upgrade_url, andactions. Low is under 20% of the plan allowance, or under 5 turns / 3 minutes.POST /v1/credits/ask-admin{ pool }→ a farmer who cannot pay asks the organisation admins to top up (once per pool per day, audited).- Every
doneevent also carriesbalanceforai_credits. A402empty error namespool,remaining,billing_urlandupgrade_url. - Every
doneevent carriesusageandremaining. A dictation reportsbilled_pool: ai_credits. - The Agronomist API uses its own price list; organisation billing.
Bring your own model key
Connect your own provider key; the agronomist, its tools and its guardrails stay the same.
- Ten launch providers plus any OpenAI-compatible base URL.
GET /v1/model-providerslists them with their names (?all=truelists the full 43-provider catalogue). POST /v1/model-connections{ scope, provider_id, api_key, model, base_url?, extra?, consent_accepted: true }. DeepLeaf first lists models, makes a 1-token completion and a 1-token tool call. A failure is refused with a named code and nothing is saved.scope: organisationneeds an org owner or admin (or the organisation key).scope: useris a personal key for the signed-in person.- The key is encrypted at rest, bound to its owner and its row, shown once, then masked (
•••• 1234). It never reaches a browser, a log, a trace or a ticket. - Base URLs must be
https, a public hostname (no IP literals), any port. Private, loopback, link-local and metadata addresses are refused, every resolved address is checked again at connect time, and redirects are refused. GET /v1/model-connections,PATCH …/{id}(rename, disable, change model),POST …/{id}/rotate(validated first),DELETE …/{id}. Every change is audited.GET /v1/modelslistsagronomist(DeepLeaf Agronomist, the default) and every connected model asbyok/{connection_id}/{model}. Send that id asmodelon/v1/chat/completions, or set a chat's model withPUT /v1/threads/{id}/model. An id that is not connected is400 byok_model_not_connected.- Responses still say
model: agronomist. Each saved answer records which model answered;done.answered_bycarries it. - React SDK: the composer shows a model picker when the person has a connected model. Pass
useAgronomist({ modelPicker: false })to hide it in your embed; the chat's stored model on the server still applies. - Billing: model tokens from your key are not charged to
ai_credits. Tool runs keep today's rate.done.usage.model_billed_toisorganisation_provider,user_providerordeepleaf. - If your key fails: by default the turn switches to DeepLeaf Agronomist with a one-line chat notice, billed to
ai_credits. Turn that off withPUT /v1/model-settings { scope, auto_switch: false }; a person can only be stricter than their organisation. With it off the turn stops with a lastingbyok_provider_failederror. - Calls: DeepLeaf Agronomist live calls are unchanged. A Google key can also offer a live call on your own key when the deployment turns it on (
GET /v1/calls/options); call minutes still record and no model cost is charged.
Errors and versioning
One error shape, stable codes, a dated version on every response.
Every failure is { "error": { "code", "message", "support_id" } }. An empty pool (402) also carries pool, remaining, billing_url, upgrade_url and can_buy. Branch on code; message is for people. Quote support_id (also the X-Request-Id header) to support.
| HTTP | code | Meaning |
|---|---|---|
| 400 | model_not_supported | model is not agronomist or a byok/… id |
| 400 | byok_model_not_connected | That BYOK model is not connected for you |
| 422 | byok_auth_failed, byok_model_not_found, byok_tools_unsupported, byok_url_refused, byok_unreachable, byok_rate_limited, byok_consent_required | A model connection was refused; nothing was saved |
| 502 | byok_provider_failed | Your provider failed and auto-switch is off; the chat keeps a lasting message |
| 400 | invalid_image / invalid_audio | Media could not be read |
| 401 | unauthenticated | No credential, or one Auth did not verify |
| 402 | credits_empty, call_minutes_empty, hectares_empty | That pool is empty |
| 403 | forbidden | Rights do not allow it |
| 404 | farmer_not_found | Organisation key without X-DeepLeaf-Farmer-Id |
| 404 | thread_not_found, field_not_found, call_not_found | Not yours, or does not exist |
| 409 | proposal_mismatch, field_drifted | Confirm the open proposal again |
| 409 | field_not_ready, capture_expired, call_ended | Wait, or start again |
| 422 | validation_error, invalid_polygon, audio_too_short, dictation_empty | The body does not fit |
| 500 | turn_failed, step_budget_empty, no_answer | The turn stopped; the chat keeps a message |
| 503 | auth_unavailable | Retry; never treated as a pass |
- Every response carries
X-DeepLeaf-Api-Version(a date, equal toinfo.versioninGET /v1/openapi.json), errors included, on the Agronomist API and on Auth. Optionally send the same header on the request to pin a dated version; omit it for current/v1behaviour. An unsupported date is400 unsupported_api_version. - Limited writes and
429 rate_limitedalso carryX-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset(andRetry-Afteron 429). - Calls to
api.deepleaf.iostill work (edge alias) but responses addDeprecation,Sunset, andLinkto the changelog — preferhttps://api.deepleaf.org. GET /v1/openapi.jsonis open to read, with no credential. It lists partner operations only; the managed WhatsApp channel, operator routes, and the guest trial are named as excluded, not published.
White-label hosts
Your brand on your host; the API stays /v1 with your organisation key.
- Custom subdomain (
{name}.deepleaf.io): DeepLeaf owns DNS and TLS. - Custom domain (
app.partner.org): point it at DeepLeaf; DeepLeaf holds TLS on that name. Both kinds can be on at once. - Org owners and admins add hosts in organisation settings. A host change is confirmed, then written; the old host keeps serving until the new one is ready.
- Farmers and advisors sign in on that host. Cookies, embed tokens, map tiles, and call rooms stay on it. The white-label price list applies.
Crop Health (`/identification`)
Plant identify from an image on the API edge. Not the Agronomist chat diagnose path.
Call POST /identification on https://api.deepleaf.org (or the api.deepleaf.io alias). The edge rewrites to Crop Health POST /identify. Full page: docs hub /docs/api/identification.
| Part | Required | Notes |
|---|---|---|
image | yes | Multipart file field (JPEG/PNG plant photo). |
lang | no | Query string; default en. Comma-separated for several common-name languages. |
curl https://api.deepleaf.org/identification?lang=en \
-F "image=@leaf.jpg"- Agronomist chat leaf diagnose stays on
/v1(chat diagnose) unless products unify later. - Core GraphQL / CDP keep serving legacy callers until Phase 7 Wave 4 metrics allow drain — dual-run, not a cutover. Cohort checklist: docs/launch/core-origin-swap.md.
- Operator verify: empty
POSTshould be a service4xx, not an upstream500for a missing/identificationroute.
OpenAPI documents
Four bounded contexts, four contracts. Each document is read from the service itself, so what you see is what that deployment serves right now. Partners call the same paths on the public host of that service.
- Owns
- Turns, the harness, tools, cards, calls, and chat persistence. One model name on the wire: `agronomist`.
- Browser edge
- /v1
- Credential
- Organisation key scoped `agronomist` with a farmer id header, or an Auth embed token.
- Document
- /v1/openapi.json
Open to read. Documents the OpenAI-shaped and DeepLeaf-shaped turns.
Reading Agronomist /v1 contract…


