Likhat Partner API
Create, revise and download Nepali legal drafts from your own product.
Contents
- Quickstart
- What you must show
- Authentication
- Limits
- Service level
- Errors
- Endpoints
- GET/document-types
- POST/drafts
- GET/drafts/:job_id
- GET/drafts/:job_id/files/docx
- GET/drafts/:job_id/files/docx-preeti
- DEL/drafts/:job_id
- GET/balance
- GET/usage
- POST/drafts/:job_id/revisions
- GET/drafts/:job_id/revisions/:revision_id
- POST/drafts/:job_id/revisions/:revision_id/apply
- POST/drafts/:job_id/revisions/:revision_id/discard
- Job states
- Credits
- Revising and hand-editing
- Retention and deletion
- Where data is processed
Base URL: https://api.likhatai.com. JSON over HTTPS. All paths start with /v1/partner.
A draft is made asynchronously: you create a job, poll it, then fetch the text and the Word files.
Every draft is AI-generated and not lawyer-verified. It must not be filed without a licensed advocate's review (see What you must show).
Quickstart
Use your lk_test_… sandbox key: it runs every check and error path but no model, charges nothing, and returns a fixed sample draft at once.
1. Pick a document type.
curl https://api.likhatai.com/v1/partner/document-types \
-H "x-partner-key: $LIKHAT_KEY"
2. Create a draft. The Idempotency-Key is required; retrying with the same key and body never starts a second draft.
curl -X POST https://api.likhatai.com/v1/partner/drafts \
-H "x-partner-key: $LIKHAT_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "document_type": "bail-application", "brief": "Client was arrested on 2082-05-14 on a cheque dishonour complaint…", "language": "ne" }'
3. Poll every 10 seconds until it ends. Respect Retry-After; do not poll faster.
curl https://api.likhatai.com/v1/partner/drafts/$JOB_ID \
-H "x-partner-key: $LIKHAT_KEY"
4. Download the Word file. Unicode Devanagari, or docx-preeti for the Preeti font.
curl -o draft.docx https://api.likhatai.com/v1/partner/drafts/$JOB_ID/files/docx \
-H "x-partner-key: $LIKHAT_KEY"
5. Revise it. Echo back the version from the last response as base_version.
curl -X POST https://api.likhatai.com/v1/partner/drafts/$JOB_ID/revisions \
-H "x-partner-key: $LIKHAT_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "message": "Shorten paragraph 2.", "base_version": "'"$VERSION"'" }'
A polling loop that respects Retry-After:
const BASE = "https://api.likhatai.com/v1/partner";
const headers = { "x-partner-key": process.env.LIKHAT_KEY };
const OPEN = new Set(["pending", "queued", "running"]);
async function waitForDraft(jobId) {
for (;;) {
const res = await fetch(`${BASE}/drafts/${jobId}`, { headers });
const body = await res.json();
const retry = [409, 429, 503].includes(res.status); // each carries Retry-After
if (!res.ok && !retry) throw new Error(`${body.error.code} (${body.error.request_id})`);
if (res.ok && !OPEN.has(body.status)) return body; // succeeded | refused | failed | cancelled
const wait = Number(res.headers.get("retry-after") ?? 10);
await new Promise((r) => setTimeout(r, wait * 1000));
}
}
What you must show
Contract requirements, not suggestions:
- Always show
draft.noticeto the lawyer reading the draft, as a visible banner. Never remove it, shorten it or move it out of sight. The same notice is also the first block ofdraft.textand is inside the Word files. - Always show
draft.review_notesto the lawyer. It lists blanks, unverified items and points that need a human check. - Show
draft.sourceswith the draft where your UI has room for footnotes. - A Likhat draft is never "verified" because it came from Likhat. Do not label it so.
Authentication
Send your key in the x-partner-key header. Never in a query string.
x-partner-key: lk_live_1a2b3c4d_<43 characters>
lk_test_…keys are sandbox keys: same validation, idempotency and errors, and the same request rate limit, but no model runs and nothing is charged. The daily draft cap does not apply to them, and the drafting checks (for example Preeti-text detection or thin-brief refusal) are not run. A create returnssucceededat once with one fixed sample draft; the Word files are fixed samples. A revision returns one fixedappliedresult: the sample draft is unchanged, so itsversionstays the same (base_versionis still checked against it). Use them to build and test your integration.lk_live_…keys are issued only once the production Anthropic API-key switch is complete. Until then you have a test key only.- Keys are issued by Likhat out of band and shown once. You can hold two live keys at a time so you can rotate without downtime. A revoked key stops working immediately.
- A missing, malformed or revoked key is
401 unauthorized.
Limits
| Limit | Value |
|---|---|
| Requests | 120 per minute per key (429 rate_limited, with Retry-After) |
| Creating drafts | 5 per minute per partner account, also enforced on our side by the drafting route itself (answered as 503 capacity with Retry-After, never 429) |
| New drafts | 50 per day, counted from 00:00 Kathmandu time (429 rate_limited, Retry-After = seconds to the next day) |
| Brief size | 60,000 characters |
| Revision message | 20,000 bytes of UTF-8 (a Devanagari character is 3 bytes, so about 6,600 characters) |
| Revisions running at once | 1 per draft (409 job_in_flight, Retry-After: 10), and 1 across all of Likhat's partners (429 rate_limited, Retry-After: 120) |
| Your drafts running at once | currently up to 4 per partner, the rest wait in the queue (may change) |
Service level
Best effort. No guarantee. A draft typically completes in 20–40 minutes. Partner drafts share one queue with Likhat's own lawyers, so a busy period delays yours. While a job is queued, the response carries queue.position and queue.eta_seconds.
Poll GET /v1/partner/drafts/:job_id every 10 seconds. Poll responses carry Retry-After: 10. Do not poll faster. A job that waits more than 26 hours in the queue is closed as failed with queue_timeout. It is charged only for spend already incurred (zero if the job never started).
Errors
Every error has the same shape:
{ "error": { "code": "insufficient_credits", "request_id": "req-8f2c" } }
detail is sometimes present. Quote request_id when you contact us.
| HTTP | code | Meaning | What to do |
|---|---|---|---|
| 400 | invalid_request | Bad body, unknown document_type, missing Idempotency-Key, or the brief is not Unicode Nepali/English text (detail: "preeti_text") | Fix the request |
| 401 | unauthorized | Key missing, wrong or revoked | Check the key |
| 402 | insufficient_credits | Balance too low to start a draft, or under 50 credits when you start a revision | Ask us to top up |
| 404 | not_found | No such job for your account (also another partner's id, or a removed job) | — |
| 409 | job_in_flight | The draft is not succeeded yet, another operation on it is in progress, a create or revise with this Idempotency-Key is still being accepted, or a proposal can no longer be decided (stale or already decided) | Wait Retry-After seconds, then poll or retry the same request. A proposal answer has no Retry-After: GET the draft for its current text |
| 409 | idempotency_conflict | Same Idempotency-Key, different body | Use a new key |
| 409 | version_conflict | base_version is not the draft's current version: another revision changed it since you pulled it | GET the draft, replace your copy, revise again. See Revising and hand-editing |
| 429 | rate_limited | Over a limit above, or a revision of another draft (or another partner) is running (Retry-After: 120) | Wait Retry-After seconds |
| 503 | capacity | Queue full, disk low, provider quota, or metering down. Nothing was started or charged | Retry after Retry-After seconds |
| 500 | internal | Our fault | Retry once; then contact us with request_id |
Endpoints
GET/v1/partner/document-types
The valid document_type values.
{ "document_types": [ { "id": "bail-application", "ne": "धरौटी निवेदन", "en": "Bail application" } ] }
ne / en can be null.
POST/v1/partner/drafts
Start a draft. The Idempotency-Key header is required (1–200 characters, ASCII, no spaces). Retrying with the same key and the same body returns the same job and never starts a second draft; the same key with a different body is 409 idempotency_conflict. Keys are remembered for at least 24 hours. If a create is refused (credits, validation), the key can be used again. Repeating a create while the first one is still being accepted (a few seconds) answers 409 job_in_flight with Retry-After: 5: wait, then repeat the same request to get the job.
Body (unknown fields are rejected):
| Field | ||
|---|---|---|
document_type | required | an id from /document-types |
brief | required | the facts and instructions, Nepali or English, 1–60,000 chars |
language | optional | ne (default) or en |
parties | optional | up to 20 × { "name", "role" } |
forum | optional | court or office |
advocate | optional | { "name", "advocate_no", "firm", "address" } (only name required). This is the only source of the advocate's identity on the draft. If absent, the draft has blanks (__________). |
firm_ref, user_ref | optional | your own opaque strings, up to 200 chars. Stored for audit; not checked and not returned. |
POST /v1/partner/drafts
x-partner-key: lk_live_…
Idempotency-Key: 6f1c1d52-0c3e-4c8e-9a58-2f1b1f0f7a11
Content-Type: application/json
{
"document_type": "bail-application",
"brief": "Client was arrested on 2082-05-14 on a cheque dishonour complaint…",
"language": "ne",
"parties": [{ "name": "Ram Bahadur Thapa", "role": "applicant" }],
"advocate": { "name": "Sita Sharma", "advocate_no": "12345" }
}
202 Accepted
{
"job_id": "pj_3f9a1c0b5d7e2a4c6b8d0e1f",
"status": "queued",
"created_at": "2026-11-02T09:15:00.000Z",
"queue": { "position": 3, "eta_seconds": { "from": 1200, "to": 2400 } }
}
queue is present when known. If the job is still being handed to the drafter, status is pending.
GET/v1/partner/drafts/:job_id
Status, and the result once succeeded.
Queued:
{
"job_id": "pj_3f9a1c0b5d7e2a4c6b8d0e1f",
"status": "queued",
"created_at": "2026-11-02T09:15:00.000Z",
"queue": { "position": 1, "eta_seconds": { "from": 600, "to": 1500 } }
}
Succeeded:
{
"job_id": "pj_3f9a1c0b5d7e2a4c6b8d0e1f",
"status": "succeeded",
"created_at": "2026-11-02T09:15:00.000Z",
"finished_at": "2026-11-02T09:41:12.000Z",
"credits_charged": 540,
"version": "9c1185a5c5e9fc54612808977ee8f548b2258d31b8d8c9d7c2f4a1e6b0a3d4f1",
"draft": {
"text": "## ⚠️ AI-generated draft — not lawyer-verified\n\nReview every citation, fact, and clause before relying on it. Do not file without a licensed advocate's review.\n\n---\n\n…",
"notice": "## ⚠️ AI-generated draft — not lawyer-verified\n\nReview every citation, fact, and clause before relying on it. Do not file without a licensed advocate's review.\n\n---\n",
"review_notes": "- Blank: date of arrest\n- Unverified: …",
"sources": "[1] …",
"checklist": null,
"document_type": "bail-application",
"language": "ne"
}
}
notice is a Markdown block (a heading, a paragraph and a rule); render it as Markdown or strip the markup, but show all of its words. review_notes, sources, checklist are null when the draft has none. document_type and language are null in the rare case the service cannot read them back; they are never guessed.
credits_charged on a queued or running poll is provisional; it is final only in a terminal state (succeeded, refused, failed, cancelled).
Refused or failed:
{ "job_id": "pj_…", "status": "refused", "created_at": "…", "finished_at": "…", "error_code": "refused_thin_brief", "credits_charged": 120 }
GET/v1/partner/drafts/:job_id/files/docx
GET/v1/partner/drafts/:job_id/files/docx-preeti
The draft as a Word file: Unicode Devanagari, or Preeti font. Binary body, Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document, with a Content-Disposition filename. Only for a succeeded job: a job that is still open, or ended without a draft, returns 409 job_in_flight; an unknown, deleted or purged job returns 404 not_found. The Word files contain the same notice as draft.text.
DEL/v1/partner/drafts/:job_id
Delete a job and its data. A queued or running job is cancelled first (status: cancelled); spend already incurred is still charged. The response is 204 with no body, and repeating the call also returns 204. Afterwards the job reads as 404 not_found. See Retention.
GET/v1/partner/balance
{ "balance": 41200 }
Credits only. No currency, tokens or rates. With a test key the balance is a fixed 100000.
GET/v1/partner/usage?from=2026-11-01&to=2026-11-30
Credits charged per job in the period, newest first, at most 1,000 rows. Deleted jobs are included. from, to are ISO 8601 dates or timestamps, read as UTC (the daily draft cap counts Kathmandu days, so a day here is not the same window). A bare date to covers that whole day; a timestamp to is exclusive. A test key sees only sandbox rows.
{ "jobs": [ { "job_id": "pj_…", "kind": "draft", "created_at": "2026-11-02T09:15:00.000Z", "credits_charged": 540 } ] }
POST/v1/partner/drafts/:job_id/revisions
Revise a succeeded draft with an instruction ("shorten paragraph 2", "add the date of arrest"). The Idempotency-Key header is required, with the same rules as a create: the same key and body returns the same revision and never starts a second one.
Body (unknown fields are rejected):
| Field | ||
|---|---|---|
message | required | the instruction, 1–20,000 bytes of UTF-8, not blank. Anything else is 400 invalid_request. |
base_version | required | the version Likhat last returned for this draft, echoed back unchanged. Missing is 400 invalid_request; not the current version is 409 version_conflict. See Revising and hand-editing. |
scope | optional | { "para": 2, "text": "The applicant was…" }: aim the instruction at one paragraph. para is a whole paragraph number, 1 or more (400 invalid_request otherwise), text the paragraph's opening words (up to 80 characters; longer is cut). Likhat finds the paragraph by text first and by number second, so send both. A scope that matches no paragraph gives an advisory result and changes nothing. |
POST /v1/partner/drafts/pj_3f9a1c0b5d7e2a4c6b8d0e1f/revisions
x-partner-key: lk_live_…
Idempotency-Key: 0b8d1f0e-6a43-4a0b-9d4c-3e5f6a7b8c9d
Content-Type: application/json
{ "message": "Shorten paragraph 2.", "base_version": "9c1185a5c5e9fc54612808977ee8f548b2258d31b8d8c9d7c2f4a1e6b0a3d4f1" }
202 Accepted
{ "revision_id": "pr_4a7c9e1b3d5f7a9c1e3b5d7f", "job_id": "pj_3f9a1c0b5d7e2a4c6b8d0e1f", "status": "running" }
Checks run in this order, and a refused request starts nothing and charges nothing (its key can be used again):
400 invalid_request: bad body or missingIdempotency-Key.404 not_found: no such draft for your account.409 job_in_flight: the draft is notsucceeded(noRetry-After;GETthe draft for its state), or one of its own revisions is still running (Retry-After: 10).402 insufficient_credits: balance under 50 credits.429 rate_limitedwithRetry-After: 120: a revision of another draft is running. Only one runs at a time across all partners, so this can happen even if you have none running. Wait, then retry.409 version_conflict:base_versionis not the draft's currentversion.503 capacity(withRetry-After): Likhat's chat capacity is busy or the metering is down.409 job_in_flight: a lawyer on the same draft has a chat turn running.
If the draft's previous revision is a proposal nobody decided, this request discards it first and the response says "discarded_previous": true. That discard stands even if the new request is then refused at step 6.
GET/v1/partner/drafts/:job_id/revisions/:revision_id
Poll every 10 seconds (responses carry Retry-After: 10 while it is open). An unknown revision, one that belongs to another draft or account, or a removed draft is 404 not_found. A revision usually takes a few minutes. One still open after 17 minutes is closed as failed with timeout.
While it runs: { "revision_id": "pr_…", "job_id": "pj_…", "status": "running" }. Once status is succeeded, type says what happened. A revision whose status is cancelled is a proposal that was discarded, by you or by a newer revision; the draft is unchanged and there is no type.
type | Meaning | Fields |
|---|---|---|
applied | The edit passed our checks and is now the draft. | change_note, version (the new one), draft (the whole current draft, same shape as in GET /drafts/:job_id) |
proposal | The edit is held because our checks flagged it. The draft is unchanged until you apply it. | change_note, hold_reasons |
advisory | An answer, not an edit. The draft is unchanged. | reply, unverified: true |
rejected | The edit failed our checks. The draft is unchanged. | change_note when there is one |
failed | status is failed. | error_code: timeout or upstream. Nothing was applied; send the instruction again. |
{
"revision_id": "pr_4a7c9e1b3d5f7a9c1e3b5d7f",
"job_id": "pj_3f9a1c0b5d7e2a4c6b8d0e1f",
"status": "succeeded",
"type": "applied",
"change_note": "Shortened paragraph 2.",
"version": "5d0b0f2f7a1c4e6b8d9a0c1e3f5a7b9d1c3e5f7a9b1d3f5a7c9e1b3d5f7a9c1e",
"draft": { "text": "…", "notice": "…", "review_notes": "…", "sources": null, "checklist": null, "document_type": "bail-application", "language": "ne" }
}
Read hold_reasons before you apply a proposal. It lists, one string per check that held the edit, the same plain-language reason Likhat's own lawyers see (for example: The edit adds "दफा ९५", which could not be checked against the statute. Verify it before applying.). Show it to the lawyer next to the change: applying a held edit means they have read why it was held. The field is left out (like change_note) when there is nothing to list, and it never appears on other types. Like change_note, each string is plain text with Likhat's internal references (citation tokens, storage paths) removed.
An advisory reply is not checked. It bypasses the citation checks that edits go through, so any law or case it names may be wrong. Show it to the lawyer as unverified. change_note and reply are plain text with Likhat's internal references removed.
An applied result carries the draft as it is when you fetch it, so a later revision shows through an earlier one's draft. Always replace your copy with the newest version and draft you have.
POST/v1/partner/drafts/:job_id/revisions/:revision_id/apply
POST/v1/partner/drafts/:job_id/revisions/:revision_id/discard
Settle a proposal. Send {}, or no body without a JSON Content-Type. apply makes the held edit the draft and returns 200 with the applied shape above (draft and the new version). discard drops it and returns 200 with status: "cancelled"; the draft is unchanged. Neither costs credits.
Only an undecided proposal can be settled; anything else answers 409 job_in_flight. A revision that is still running: poll it until it is a proposal. A proposal that can no longer be decided (already applied or discarded, stale because the draft changed after it was made, or decided by a lawyer at Likhat), and an advisory / rejected / failed revision: the 409 has no Retry-After, because waiting does not help. GET the draft for its current text. If apply answers 5xx, GET the revision: the proposal may already have been applied.
Job states
pending → queued → running → succeeded | refused | failed | cancelled
| State | Meaning |
|---|---|
pending | Accepted; being handed to the drafter (seconds) |
queued | Waiting in line. See queue |
running | Being drafted |
succeeded | Done. draft and version are present |
refused | We would not draft this. error_code: refused_thin_brief (brief too thin) or refused_unsupported_type. Add detail to the brief and create a new job |
failed | Not completed. error_code: timeout, queue_timeout, worker_lost, upstream, internal. Create a new job |
cancelled | You deleted it before it finished |
Terminal states never change. Jobs that fail because the model connection dropped or the drafting worker was lost are not charged. Refusals, timeouts, cancellations and other failures are charged for what they used; credits_charged says how much.
Credits
Credits are checked when you create a draft. If your balance cannot cover the worst case of all your drafts in flight, you get 402 insufficient_credits and nothing starts. Credits are topped up by Likhat after invoice. Per-job charges are in credits_charged and /usage.
A revision needs a balance of at least 50 credits to start. It is charged to your balance for what it used, whatever the outcome (applied, proposal, advisory, rejected or failed); apply and discard are free. /usage lists revisions, but its credits_charged for them is 0 for now: read /balance for the true amount.
Revising and hand-editing
Every draft and revision response carries a version: a hash that Likhat computes from the draft text that response returned. To revise, send it back as base_version, echoed unchanged: the version Likhat last gave you for this draft, never a hash of text you computed yourself. If it is not the draft's current version (another revision landed in between), you get 409 version_conflict. There is no force option.
Likhat revises its own stored text only. Hand edits made in your editor are not sent to Likhat, and a revision does not see them. So: revise first, hand-edit last. After a revision, replace your copy with the returned draft. Warn your user before a revision overwrites local edits. To tell whether they edited, compare your local text with the text of the version you pulled; Likhat cannot see that, and version_conflict only detects a competing revision.
Retention and deletion
- Drafts you do not delete are removed 30 days after last activity.
- After
DELETE, the draft is removed from our live storage within 24 hours. - Backups keep it longer. Nightly backups hold copies for up to 14 days after a deletion. Once our off-box backup bucket is live, up to 30 days.
- Your brief is held in our database until the job finishes, then cleared. Logs carry ids, never brief or draft text.
- Revision chat history is stored with the draft until removed.
Where data is processed
Requests are processed on our servers in Bangalore, India (DigitalOcean), and the drafting model is run by Anthropic in the United States. As the controller, you are responsible for obtaining your clients' consent to this processing.