Skip to content

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/v1

Authentication

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 progress
  • projects:write Create and submit project requests
  • releases:read Read and download delivered releases
  • releases:write Accept a release or report an issue
  • datasets:read List datasets and read their record schema
  • imports:write Upload and confirm source data
  • exports:read Read export jobs and download their output
  • exports:write Request an export
  • jobs:read Poll the status of imports and exports
  • webhooks:manage Create, rotate and remove webhook endpoints
  • usage: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-..."
  }
}
StatusMeaning
400The request is malformed.
401Missing, invalid, expired or revoked key.
403The key lacks the scope, or the feature is not enabled.
404No such resource for you. We do not distinguish “does not exist” from “is not yours”.
409Conflict — usually an idempotency key reused differently.
422Understood, but not allowed in this state.
429Rate limited. retry-after says how long to wait.
5xxOur 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 → activated

Your 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 you
  • release.withdrawn A delivered release was withdrawn before you accepted it
  • project.approved A project request was approved
  • project.started An approved project was set up and is now running
  • project.progress A project passed a progress milestone
  • project.completed A project finished
  • dataset.ready A dataset is ready to download
  • export.ready An export you requested is ready
  • import.failed An import could not be processed
  • evaluation.approved An evaluation was approved and is ready to launch
  • evaluation.started An evaluation was launched and evaluators are working on it
  • evaluation.progress An evaluation passed a progress milestone
  • evaluation.qa An evaluation's judgements are in quality review
  • evaluation.completed An evaluation finished and its results are available
  • evaluation.report_ready A report was shared with you
  • voice_test.approved A voice test was approved and is ready to launch
  • voice_test.started A voice test was launched and speakers are running sessions
  • voice_test.progress A voice test passed a progress milestone
  • voice_test.completed A voice test finished and its results are available
  • voice_test.report_ready A voice test report was shared with you
  • voice_test.system_unavailable Your system could not be reached, and session assignment has been paused
  • speech_project.approved A speech collection was approved and can begin
  • speech_project.started Recording has begun on a speech collection
  • speech_project.progress A speech collection passed a progress milestone
  • speech_project.collection_complete Recording finished; the corpus is moving to QA, transcription or packaging
  • speech_project.dataset_ready A dataset version was built and is ready for your review
  • speech_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 key300
Import file size25 MB
Rows per import50,000
Results page size500
Exports running at once3
Sandbox rows per project500

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-Key on creating endpoints.
  • x-request-id on every response and in every error.
  • Self-service webhook events, with livemode in 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.