API version 2026-09-01
Developer documentation
Create projects, send us your data, and collect human judgements programmatically. Everything below works against the sandbox with a test key, so you can build the whole integration before a single contributor is involved.
Introduction
The API is REST over HTTPS. Requests and responses are JSON, dates are ISO 8601 in UTC, and every response carries an x-request-id header. Quote that id if you contact us about a request — it is how we find it.
One thing to understand before you start: the API cannot start work. It can create a project, attach your data and submit it for review. A person at LAHJA then checks feasibility, contributor supply and pricing before anything begins. That is deliberate — it is what stops a loop in your code costing you money.
Base URL
https://lahja-ai.com/api/v1Authentication
Send your key as a bearer token. Keys are shown once when created and stored only as a hash — if you lose one, revoke it and create another. We cannot recover it.
curl https://lahja-ai.com/api/v1 \
-H "Authorization: Bearer lhj_test_EXAMPLEKEYdoNOTuse00000000000000"Scopes
A key carries only the scopes you give it. Give each system the narrowest set that does its job — a key that only reads results cannot create a project if it leaks.
projects:read— Read projects and their progressprojects:write— Create and submit project requestsreleases:read— Read and download delivered releasesreleases:write— Accept a release or report an issuedatasets:read— List datasets and read their record schemaimports:write— Upload and confirm source dataexports:read— Read export jobs and download their outputexports:write— Request an exportjobs:read— Poll the status of imports and exportswebhooks:manage— Create, rotate and remove webhook endpointsusage:read— Read your own API usage figures
Rotation
Create the new key, deploy it, confirm traffic has moved using lastUsedAt in the portal, then revoke the old one. Revocation takes effect immediately.
Test and live
Keys are either lhj_test_… or lhj_live_…, and the two see different data.
- A test key creates sandbox projects. Nobody works on them, nothing is charged, and results are synthetic — every record is marked so you cannot mistake it for real work.
- A live key creates real projects that people are paid to work on.
- Neither can see the other’s resources. A test key asking for a live project gets a 404.
Webhook payloads carry livemode so your receiver can tell them apart without inspecting the data.
Errors
Errors are JSON with a stable machine-readable code. Match on the code, not the message — messages change.
{
"error": {
"code": "insufficient_scope",
"message": "This key does not have the \"projects:write\" scope.",
"request_id": "9f2c1e5a-..."
}
}| Status | Meaning |
|---|---|
400 | The request is malformed. |
401 | Missing, invalid, expired or revoked key. |
403 | The key lacks the scope, or the feature is not enabled. |
404 | No such resource for you. We do not distinguish “does not exist” from “is not yours”. |
409 | Conflict — usually an idempotency key reused differently. |
422 | Understood, but not allowed in this state. |
429 | Rate limited. retry-after says how long to wait. |
5xx | Our fault. Retry with backoff; never a stack trace. |
Quickstart
Six steps, entirely in the sandbox. Nobody is asked to do any work.
# 1. Check the key works
curl https://lahja-ai.com/api/v1 -H "Authorization: Bearer lhj_test_EXAMPLEKEYdoNOTuse00000000000000"
# 2. Create a project
curl -X POST https://lahja-ai.com/api/v1/project-requests \
-H "Authorization: Bearer lhj_test_EXAMPLEKEYdoNOTuse00000000000000" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: quickstart-1" \
-d '{
"name": "Darija evaluation",
"service_type": "LLM_RESPONSE_RATING",
"language": "ar",
"dialect": "dz-darija-algiers",
"volume": { "prompts": 100, "evaluatorsPerItem": 3 },
"qa_level": "STANDARD",
"delivery_format": "jsonl"
}'
# 3. Upload your prompts and responses
curl -X POST https://lahja-ai.com/api/v1/project-requests/{id}/imports \
-H "Authorization: Bearer lhj_test_EXAMPLEKEYdoNOTuse00000000000000" \
-F file=@prompts.csv
# 4. Poll the import, then confirm it
curl https://lahja-ai.com/api/v1/jobs/{import_id} -H "Authorization: Bearer lhj_test_EXAMPLEKEYdoNOTuse00000000000000"
curl -X POST https://lahja-ai.com/api/v1/imports/{import_id}/confirm \
-H "Authorization: Bearer lhj_test_EXAMPLEKEYdoNOTuse00000000000000"
# 5. Submit for review
curl -X POST https://lahja-ai.com/api/v1/project-requests/{id}/submit \
-H "Authorization: Bearer lhj_test_EXAMPLEKEYdoNOTuse00000000000000"
# 6. Sandbox only: run it to completion with synthetic results
curl -X POST https://lahja-ai.com/api/v1/sandbox/{id}/simulate \
-H "Authorization: Bearer lhj_test_EXAMPLEKEYdoNOTuse00000000000000"Step 6 exists only for test keys. With a live key, step 5 is where your part ends and ours begins.
Projects
Services
GET /v1/services returns this list at runtime, with each service’s volume questions, the QA levels, the delivery formats and the import schemas. Read it rather than hard-coding the values below — it is generated from the same constants the API validates against.
SPEECH_COLLECTION— Native speakers record prompts you supply. Delivered as audio with transcriptions and speaker metadata.SPONTANEOUS_SPEECH— Unscripted speech on prompts or topics you define — closer to how people actually talk.CONVERSATIONAL_SPEECH— Two-party conversations, recorded and transcribed.TRANSCRIPTION— You supply audio; native speakers transcribe it to your conventions.TRANSLATION— Translation between your source language and a Maghrebi variety.TEXT_ANNOTATION— Labelling, classification or span annotation against your guidelines.AUDIO_ANNOTATION— Labelling audio: events, speakers, quality, intent.LLM_RESPONSE_RATING— Native speakers rate your model's responses on criteria you choose. The quickest project to set up.LLM_RESPONSE_RANKING— Side-by-side preference between two responses, with optional reasons.VOICE_AGENT_TESTING— Native testers call or use your voice agent following scenarios, and rate what happened.CUSTOM— Describe what you need and we will scope it with you.
Lifecycle
draft → submitted → (needs_information | quote_required) → approved → activatedYour API calls move a project from draft to submitted. Everything after that is us. Poll GET /v1/project-requests/{id} or subscribe to project.approved.
Changing a draft
PATCH /v1/project-requests/{id} while it is still a draft, or after we have asked you for more information. Once it is submitted the configuration is what a reviewer is reading, so a PATCH returns 422 and asks you to leave a comment instead — otherwise a project could be approved in a shape nobody agreed to.
service_type cannot be changed: it decides the import schema, the volume questions and the task type, so a different service is a different request.
curl -X PATCH https://lahja-ai.com/api/v1/project-requests/{id} \
-H "Authorization: Bearer lhj_test_EXAMPLEKEYdoNOTuse00000000000000" \
-H "Content-Type: application/json" \
-d '{ "qa_level": "ENHANCED", "delivery_format": "jsonl" }'Validation
A GET returns a validation array telling you what is still missing, each entry naming the part of the configuration to fix. Submitting with unresolved errors returns 422 with the same list — the draft is kept, so nothing you configured is lost.
Importing your data
Upload a CSV; we parse and validate it in the background and return a preview. Nothing becomes work until you confirm it.
Files up to 25 MB and 50,000 rows. Above that, split the file or talk to us about a direct upload.
Column formats
LLM response rating
One prompt and one model response per row. Evaluators rate the response.
external_id(optional) — Your own identifier for this row. Returned unchanged in results and exports.prompt(required) — What was asked.response(required) — What your model answered.
LLM pairwise comparison
One prompt and two responses per row. Evaluators choose which is better.
external_id(optional) — Your own identifier for this row. Returned unchanged in results and exports.prompt(required) — What was asked.response_a(required) — First response.response_b(required) — Second response.
Speech prompts
One sentence or prompt per row for a speaker to record.
external_id(optional) — Your own identifier for this row. Returned unchanged in results and exports.prompt_text(required) — The sentence to read, or the topic to speak about.
Audio for transcription
One audio file per row. Upload the audio separately, then reference each file by name.
external_id(optional) — Your own identifier for this row. Returned unchanged in results and exports.audio_file(required) — The file name, matching an uploaded file.duration_seconds(optional) — Helps us estimate the work.
Translation segments
One segment per row to translate.
external_id(optional) — Your own identifier for this row. Returned unchanged in results and exports.source_text(required) — The text to translate.context(optional) — Anything that helps the translator get it right.
Text items
One text item per row to annotate or classify.
external_id(optional) — Your own identifier for this row. Returned unchanged in results and exports.text(required) — The item.
Voice-agent scenarios
One scenario per row for a tester to attempt.
external_id(optional) — Your own identifier for this row. Returned unchanged in results and exports.scenario(required) — What the tester should try to achieve.expected_outcome(optional) — What success looks like.
Your identifiers and metadata
external_id is returned unchanged on every record, in the results API and in exports, so you can join our output back to your own system. Any column we do not recognise is kept as metadata and returned with the record — so a file with product and intent columns needs no stripping. Column names we use ourselves are prefixed client_ rather than overwriting ours.
Jobs
Imports and exports are asynchronous and share one endpoint, so a polling loop does not need to know which it is waiting on.
GET /v1/jobs/{id}
{
"object": "job",
"id": "…",
"type": "import",
"status": "ready",
"progress": 100,
"result": { "total_rows": 1000, "valid_rows": 998, "invalid_rows": 2 },
"error": null
}Poll every few seconds. error is a safe message, never an exception.
Results
Results are cursor-paginated. Use the cursor rather than an offset: records are appended while you read, and offsets shift under you.
GET /v1/projects/{code}/results?limit=100
{
"object": "list",
"schema_version": "1.0",
"data": [ … ],
"has_more": true,
"next_cursor": "…"
}Filter to one record with ?external_id=YOUR_ID. Contributor identities never appear: the underlying query does not select them.
Exports
Ask for a file, poll the job, download it. Exports stay available for 72 hours.
curl -X POST https://lahja-ai.com/api/v1/exports \
-H "Authorization: Bearer lhj_test_EXAMPLEKEYdoNOTuse00000000000000" \
-H "Content-Type: application/json" \
-d '{ "project_id": "…", "format": "jsonl" }'The download carries lahja-checksum-sha256. It is computed from the bytes we wrote, so you can verify what you received — hash the file and compare.
Webhooks
Events
release.delivered— A release was delivered to yourelease.withdrawn— A delivered release was withdrawn before you accepted itproject.approved— A project request was approvedproject.started— An approved project was set up and is now runningproject.progress— A project passed a progress milestoneproject.completed— A project finisheddataset.ready— A dataset is ready to downloadexport.ready— An export you requested is readyimport.failed— An import could not be processedevaluation.approved— An evaluation was approved and is ready to launchevaluation.started— An evaluation was launched and evaluators are working on itevaluation.progress— An evaluation passed a progress milestoneevaluation.qa— An evaluation's judgements are in quality reviewevaluation.completed— An evaluation finished and its results are availableevaluation.report_ready— A report was shared with youvoice_test.approved— A voice test was approved and is ready to launchvoice_test.started— A voice test was launched and speakers are running sessionsvoice_test.progress— A voice test passed a progress milestonevoice_test.completed— A voice test finished and its results are availablevoice_test.report_ready— A voice test report was shared with youvoice_test.system_unavailable— Your system could not be reached, and session assignment has been pausedspeech_project.approved— A speech collection was approved and can beginspeech_project.started— Recording has begun on a speech collectionspeech_project.progress— A speech collection passed a progress milestonespeech_project.collection_complete— Recording finished; the corpus is moving to QA, transcription or packagingspeech_project.dataset_ready— A dataset version was built and is ready for your reviewspeech_project.issue— Something on a speech collection needs your attention
Payload
Deliberately small: an id, a type, and enough to know what changed. Fetch the full resource from the API — we do not push datasets through a webhook.
{
"id": "evt_…", // unique; use it for idempotency
"type": "dataset.ready",
"created": "2026-09-21T10:00:00.000Z",
"livemode": false,
"data": { "project_id": "…", "dataset_id": "…", "record_count": 500 }
}Verifying the signature
Every delivery carries Lahja-Signature: t=…,v1=… where v1 is an HMAC-SHA256 of <timestamp>.<raw body> using your endpoint secret. Verify against the raw body, before any JSON parsing, and reject timestamps older than five minutes.
// Node
import crypto from "node:crypto";
export function verify(secret, rawBody, header, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSec) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}# Python
import hmac, hashlib, time
def verify(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(","))
try:
timestamp = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - timestamp) > tolerance:
return False
expected = hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))Managing endpoints
Endpoints belong to an environment, and a key only sees its own. A test key registering an endpoint registers a test endpoint, so your staging receiver cannot be woken up by a real customer event.
# Register an endpoint
curl -X POST https://lahja-ai.com/api/v1/webhooks \
-H "Authorization: Bearer lhj_test_EXAMPLEKEYdoNOTuse00000000000000" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/lahja",
"events": ["dataset.ready", "export.ready"]
}'
# → { "id": "...", "secret": "whsec_...", "secret_shown_once": true }
# Send yourself a signed ping to check the receiver
curl -X POST https://lahja-ai.com/api/v1/webhooks/{id}/test \
-H "Authorization: Bearer lhj_test_EXAMPLEKEYdoNOTuse00000000000000"
# See the endpoint and its recent delivery attempts
curl https://lahja-ai.com/api/v1/webhooks/{id} -H "Authorization: Bearer lhj_test_EXAMPLEKEYdoNOTuse00000000000000"
# Stop delivering to it
curl -X DELETE https://lahja-ai.com/api/v1/webhooks/{id} \
-H "Authorization: Bearer lhj_test_EXAMPLEKEYdoNOTuse00000000000000"The signing secret appears in the create response and nowhere else — not in the list, not in the detail, and not in the replay of an idempotent retry. If you lose it, rotate it from the portal.
Retries
A non-2xx response is retried with exponential backoff, a bounded number of times, then the delivery is marked failed. Respond 2xx as soon as you have stored the event and do your work afterwards. Deliveries can repeat — use the event id to make your handler idempotent.
Idempotency
Send Idempotency-Key on any POST that creates something. A retry with the same key and the same body replays the original response instead of creating a second resource.
The same key with a different body returns 409. That is almost always a bug in the caller, and returning the earlier resource would hide it. Keys are remembered for 24 hours.
Rate limits and quotas
| Requests per minute, per key | 300 |
| Import file size | 25 MB |
| Rows per import | 50,000 |
| Results page size | 500 |
| Exports running at once | 3 |
| Sandbox rows per project | 500 |
A 429 carries retry-after. Your organisation may be given higher ceilings — ask.
Data contracts
Delivered records follow a versioned schema. Adding a field is a minor version and safe; renaming or removing one means a new major version, and the old one keeps working.
Current: project_results v1.0
record_id(string) — Our identifier for the record.external_id(string, nullable) — Your identifier, exactly as you supplied it.status(string) — accepted | rejected | pending.result(object) — The work itself, shaped by the service.qa_status(string, nullable) — The QA decision.qa_score(number, nullable) — 0–100 where scored.language(string, nullable) — Language label.dialect(string, nullable) — Dialect label.metadata(object, nullable) — The extra columns you sent with the row.completed_at(string, nullable) — ISO 8601.
Changelog
2026-09-01 — current
- Project requests, imports, jobs, results, exports and usage.
- Test and live environments, with sandbox simulation for test keys.
Idempotency-Keyon creating endpoints.x-request-idon every response and in every error.- Self-service webhook events, with
livemodein the payload.
We do not remove endpoints without notice. A deprecated endpoint will carry a Deprecation header and keep working for a stated period.
Operational status
GET /api/health reports whether the application can reach its database. We do not publish an uptime SLA, and this page will not claim one until there is a commercial commitment behind it.