API Documentation

Call Dhavana's Dhivehi AI tools directly from your own systems.

Dhavana API — Integration Guide

Companion to the machine-readable contract in dhavana-v1.yaml. The spec is also served live, so you never need this repository to integrate:

WhatWhere
OpenAPI (JSON)https://api.dhavana.com/v1/openapi.json
OpenAPI (YAML)https://api.dhavana.com/v1/openapi.yaml
Livenesshttps://api.dhavana.com/v1/health
Per-tool statushttps://api.dhavana.com/v1/status

Postman / Insomnia / Bruno: Import → Link → https://api.dhavana.com/v1/openapi.json. Every endpoint, example and schema appears automatically, and stays current — we publish the same file the server is validated against, so there is no separate collection to fall out of date.

Generating a client: point any OpenAPI 3.1 generator at that URL, e.g.

# TypeScript npx @hey-api/openapi-ts -i https://api.dhavana.com/v1/openapi.json -o ./src/dhavana # Python openapi-python-client generate --url https://api.dhavana.com/v1/openapi.json # C# (.NET) dotnet tool install --global Microsoft.OpenApi.Kiota kiota generate -d https://api.dhavana.com/v1/openapi.json -l CSharp -o ./Dhavana

1. Getting started

You are issued one or more API keys for your organisation:

dhv_live_<key-id>_<secret>     # real work, spends credits
dhv_test_<key-id>_<secret>     # sandbox: deterministic stubs, costs nothing

Send it as a bearer token on every request:

Authorization: Bearer dhv_live_k_abc123_...

Keys belong to the organisation, not a person. Usage bills to your shared credit pool — the same pool your staff use in the web app.

Keep keys server-side. Never place one in a browser, a mobile app, or a public repository. If a key is exposed, ask us to revoke it; revocation is immediate.

Confirm a key works:

curl https://api.dhavana.com/v1/me \ -H "Authorization: Bearer $DHAVANA_API_KEY"
{ "key_id": "k_abc123", "organization": "org_...", "environment": "live", "scopes": ["text:write", "usage:read"], "effective_scopes": [ "audit:read", "edit:write", "generate:write", "jobs:read", "jobs:write", "ledger:read", "translate:write", "usage:read" ], "retention": "standard", "department_id": null }

Scopes

A key carries scopes; each endpoint requires one. Ask for the narrowest set that does the job — an HR portal's key should not be able to spend the office's credits on presentations.

Scope names follow <endpoint>:<action>, so POST /v1/transcribe needs transcribe:write. The live catalogue is public:

curl https://api.dhavana.com/v1/scopes
ScopeUnlocks
generate:writePOST /v1/generate
translate:writePOST /v1/translate
edit:writePOST /v1/text/edit
ocr:writePOST /v1/ocr
transcribe:writePOST /v1/transcribe
speak:writePOST /v1/speak
presentations:writePOST /v1/presentations
spreadsheets:writePOST /v1/spreadsheets
jobs:read / jobs:writePoll / cancel async jobs
usage:readGET /v1/usage — also grants ledger:read and audit:read
webhooks:writeManage webhook endpoints

Shorthand: text:write (generate + translate + edit), documents:write (presentations + spreadsheets), media:write (ocr + transcribe + speak), and * for everything.

You never need to ask for jobs:*. Any tool scope already permits polling and cancelling the jobs it creates — otherwise you could start work you could not read the result of.

If a call returns 403 insufficient_scope, compare effective_scopes from GET /v1/me against the table above; the granted list may be shorthand.

Start in the sandbox

Use a dhv_test_... key while you build. Requests hit the same routes and return the same shapes, but responses are deterministic stubs: no model runs, no credits are spent, and test_mode: true is set. Switch the key to go live — nothing else changes.


2. Two response styles

Synchronous (/generate, /translate, /text/edit) returns the result directly.

Asynchronous (/transcribe, /speak, /ocr, /presentations, /spreadsheets, and /generate with async: true) returns 202 with a job:

{ "id": "job_9f3a...", "object": "job", "status": "queued", "credits_charged": 4 }

Then either:

  • Poll GET /v1/jobs/{id} until status is succeeded, failed or canceled — always available, and the right choice if your network cannot accept inbound connections; or
  • Register a webhook by passing webhook_url on the original request.

Polling and webhooks are not exclusive. A webhook that never arrives (firewall, outage) does not lose your result — the job remains retrievable.

Polling politely

Poll every 2–3 seconds, and stop at a sensible ceiling. Don't poll in a tight loop.

import time, requests def wait_for_job(job_id, key, timeout=600): deadline = time.time() + timeout while time.time() < deadline: r = requests.get( f"https://api.dhavana.com/v1/jobs/{job_id}", headers={"Authorization": f"Bearer {key}"}, timeout=30, ) r.raise_for_status() job = r.json() if job["status"] in ("succeeded", "failed", "canceled"): return job time.sleep(3) raise TimeoutError(f"job {job_id} did not finish in {timeout}s")

3. Safe retries — Idempotency-Key

Networks drop responses. If you retry a POST without protection you may pay for the same work twice. Send a unique Idempotency-Key (a UUID) with every POST:

Idempotency-Key: 8f14e45f-ea0e-4c1b-9a2b-1f0c2b3d4e5f
  • Same key, same body → the original response is replayed. No second charge.
  • Same key, different body → 409 idempotency_key_reuse.
  • Same key while the first is still running → 409 request_in_progress; wait and retry.

Records are kept 24 hours. Generate the key once per logical operation and reuse it across retries — a fresh key on each attempt defeats the purpose.


4. Errors

Every error has the same shape. Switch on code; treat message as human text whose wording may change.

{ "error": { "type": "insufficient_credits", "code": "spend_cap_exceeded", "message": "This organization has reached its API spend limit for the current period.", "request_id": "req_01J..." } }
HTTPtypeWhat to do
400invalid_request_errorFix the request. param names the offending field.
401authentication_errorKey missing, malformed or revoked.
403permission_errorinsufficient_scope — the key lacks a scope. ip_not_allowed — call from an allowlisted address.
402insufficient_creditsinsufficient_credits — top up. spend_cap_exceeded — raise the cap or wait for the period to roll over.
429rate_limit_errorBack off for Retry-After seconds.
503upstream_errorTransient. Retry with backoff.
500api_errorRetry once; if it persists, quote the request_id to support.

Always log request_id. It is on every response (body and the x-request-id header) and lets us trace your exact call.

Rate limits

Limits apply per key, with a higher ceiling per organisation. Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset; a 429 adds Retry-After. Retry with exponential backoff and jitter — do not retry immediately in a loop.

Size limits

POST /v1/ocr accepts files up to 100 MB at file_url. Anything larger is rejected with 400 invalid_request_error before any credits are charged, so an oversized file costs you nothing — but it also means you should split long documents client-side and submit them as separate jobs rather than retrying.

There is no size limit on text endpoints beyond the per-field maximums in the API reference.


5. Worked examples

PHP — generate a Dhivehi news article

<?php $key = getenv('DHAVANA_API_KEY'); $ch = curl_init('https://api.dhavana.com/v1/generate'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 120, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $key, 'Content-Type: application/json', 'Idempotency-Key: ' . bin2hex(random_bytes(16)), ], CURLOPT_POSTFIELDS => json_encode([ 'content_type' => 'news article', 'prompt' => "Council opens the new harbour at Hulhumale'", 'language' => 'dv', 'length' => 'short', 'options' => ['writing_style' => 'news'], 'metadata' => ['office_ref' => 'MOE-2026-0412'], ], JSON_UNESCAPED_UNICODE), ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $data = json_decode($body, true); if ($status !== 200) { // Log request_id — it identifies this exact call in Dhavana's audit trail. error_log("Dhavana error {$data['error']['code']} (request {$data['error']['request_id']})"); exit(1); } echo $data['content'];

C# — translate to Dhivehi

using System.Net.Http.Headers; using System.Text; using System.Text.Json; var key = Environment.GetEnvironmentVariable("DHAVANA_API_KEY"); using var http = new HttpClient { BaseAddress = new Uri("https://api.dhavana.com/v1/") }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", key); var payload = new { text = "The meeting has been moved to Thursday morning.", source_language = "en", target_language = "dv" }; using var req = new HttpRequestMessage(HttpMethod.Post, "translate") { Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json") }; req.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString()); var res = await http.SendAsync(req); var json = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement; if (!res.IsSuccessStatusCode) { var err = json.GetProperty("error"); Console.Error.WriteLine($"Dhavana error {err.GetProperty("code")} " + $"(request {err.GetProperty("request_id")})"); return; } Console.WriteLine(json.GetProperty("translated_text").GetString());

Python — OCR a scanned PDF (async)

Pick the mode by what is on the page. The two modes are different engines, not speed tiers:

ModeForLanguages
accurate (default)Printed and typed documentsEnglish, Dhivehi, Arabic — including mixed pages
handwrittenHandwritten documents, varied hands and stylesDhivehi only

handwritten cannot read other scripts; give it English or Arabic and you get nonsense rather than a poorer reading. Use accurate for anything printed, whatever the language.

import os, time, requests KEY = os.environ["DHAVANA_API_KEY"] BASE = "https://api.dhavana.com/v1" H = {"Authorization": f"Bearer {KEY}"} job = requests.post( f"{BASE}/ocr", headers={**H, "Idempotency-Key": os.urandom(16).hex()}, json={"file_url": "https://files.example.gov.mv/minutes-2026-07.pdf", "mode": "accurate"}, timeout=30, ).json() while True: job = requests.get(f"{BASE}/jobs/{job['id']}", headers=H, timeout=30).json() if job["status"] in ("succeeded", "failed", "canceled"): break time.sleep(3) if job["status"] != "succeeded": raise RuntimeError(f"OCR failed: {job['error']}") print(job["result"]["page_count"], "pages") print(job["result"]["text"])

TypeScript — synthesise speech and save the audio

const KEY = process.env.DHAVANA_API_KEY!; const BASE = "https://api.dhavana.com/v1"; const auth = { Authorization: `Bearer ${KEY}` }; const created = await fetch(`${BASE}/speak`, { method: "POST", headers: { ...auth, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID() }, body: JSON.stringify({ text: "ދިވެހިރާއްޖެ", voice: "ethan" }), }).then((r) => r.json()); let job = created; while (!["succeeded", "failed", "canceled"].includes(job.status)) { await new Promise((r) => setTimeout(r, 3000)); job = await fetch(`${BASE}/jobs/${created.id}`, { headers: auth }).then((r) => r.json()); } if (job.status !== "succeeded") throw new Error(job.error?.message); await Bun.write("speech.wav", Buffer.from(job.result.audio_base64, "base64"));

Voice ids change as we add voices — call GET /v1/voices rather than hardcoding them.


6. Receiving webhooks

Two independent kinds:

  • Job events (job.succeeded, job.failed) — pass webhook_url on the request that creates the job.
  • Account events (credits.low, credits.exhausted, api.spend_cap_warning, api.spend_cap_reached) — register an endpoint once via POST /v1/webhook-endpoints. These tell your finance or ops system you are running out of credit before everything starts failing.
curl -X POST https://api.dhavana.com/v1/webhook-endpoints \ -H "Authorization: Bearer $DHAVANA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url":"https://office.gov.mv/dhavana/hooks","description":"finance system"}'

The response contains a secretshown once. Store it; to rotate, delete the endpoint and create a new one.

Verifying a delivery

Every delivery carries:

X-Dhavana-Signature: t=1753257600,v1=<hex hmac-sha256>
X-Dhavana-Event-Id:  evt_...            (identical to `id` in the body)

Recompute HMAC-SHA256 over the exact string "<t>.<raw request body>" using your endpoint secret. Use the raw body — re-serialising parsed JSON changes the bytes and the signature will not match. Compare in constant time and reject timestamps older than five minutes.

<?php function dhavana_verify(string $rawBody, string $sigHeader, string $secret): bool { if (!preg_match('/^t=(\d+),v1=([a-f0-9]+)$/', $sigHeader, $m)) return false; [, $t, $sig] = $m; if (abs(time() - (int)$t) > 300) return false; // replay window $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret); return hash_equals($expected, $sig); // constant time } $raw = file_get_contents('php://input'); if (!dhavana_verify($raw, $_SERVER['HTTP_X_DHAVANA_SIGNATURE'] ?? '', getenv('DHAVANA_WEBHOOK_SECRET'))) { http_response_code(400); exit; } $event = json_decode($raw, true); // Handle, then acknowledge quickly. http_response_code(200);
static bool Verify(string rawBody, string sigHeader, string secret) { var m = System.Text.RegularExpressions.Regex.Match(sigHeader ?? "", @"^t=(\d+),v1=([a-f0-9]+)$"); if (!m.Success) return false; var t = long.Parse(m.Groups[1].Value); if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > 300) return false; using var hmac = new System.Security.Cryptography.HMACSHA256(Encoding.UTF8.GetBytes(secret)); var expected = Convert.ToHexString( hmac.ComputeHash(Encoding.UTF8.GetBytes($"{t}.{rawBody}"))).ToLowerInvariant(); return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(m.Groups[2].Value)); }

Respond 2xx quickly and do your processing afterwards. Failed deliveries are retried with backoff. Deduplicate on X-Dhavana-Event-Id — a retry after your handler succeeded but the response was lost will deliver the same event id again.

Webhook delivery never affects your requests or results: if your endpoint is unreachable, jobs still complete and remain retrievable.


7. Billing and governance

Every response reports credits_charged. Failed work is refunded automatically.

EndpointAnswers
GET /v1/usageWhat have we spent this period, by tool, against our cap?
GET /v1/ledgerItemise every charge — traceable to request and job id. ?format=csv
GET /v1/auditEvery call our keys made: when, from where, outcome. ?format=csv

GET /v1/ledger reconciles exactly with GET /v1/usage: summary.spend_total for a period equals that period's credits_spent.

Your organisation may also have:

  • A spend cap — a hard per-period ceiling on API spend. Web-app usage is unaffected by it. Exceeding it returns 402 spend_cap_exceeded.
  • A low-balance threshold — triggers credits.low before the pool empties.
  • An IP allowlist per key — requests from other addresses get 403 ip_not_allowed.
  • Zero-retention keys — for keys configured retention: "none" we store only usage metadata; prompt and output text are never persisted. (One consequence: an idempotent replay of a synchronous call cannot return the original content, because it was never stored — you get result_not_retained.)

Check which apply to your key with GET /v1/me.


8. Support

Quote the request_id from the failing response — it identifies the exact call.