Everything you need to call the Modulaw API from your own code: how requests and responses are shaped, worked examples in cURL, JavaScript and Python, and the endpoints with the permission each one needs.
Who can do this: Anyone on a paid plan. Free trials cannot use the API.
Before you start: Create a key — see Using the Modulaw API.
Base URL and authentication
https://backend.modulaw.ai
Every request carries your key as a Bearer token. Send JSON bodies with a Content-Type header. Call from your server, never a browser.
Authorization: Bearer mk_live_...
Content-Type: application/json
Path shape
Almost everything is workspace-scoped, and the workspace lives in the path:
/api/case-manager/workspaces/{workspaceId}/cases
/api/case-manager/workspaces/{workspaceId}/cases/{caseId}/tasks
The workspace in the path is the one that counts — it is what your key’s workspace pin is checked against. Do not also put a different workspaceId in the body; a mismatch is rejected with 400.
Two areas sit outside /api/case-manager: workspace details at /api/workspaces/{workspaceId}, and the legal corpus at /api/library/….
Response format
Successful responses share one envelope. Your data is always under data:
{
"statusCode": 200,
"data": { ... },
"message": "Cases retrieved successfully",
"success": true
}
Errors are flat, with a human-readable message. Permission failures also carry a code:
{
"success": false,
"message": "Missing required scope \"clients:write\". Granted: cases:read, cases:write.",
"code": "API_KEY_FORBIDDEN"
}
Pagination and filtering
List endpoints accept page and limit, plus filters that vary by resource. Cases accept search, status, priority, practiceGroup, dateFrom and dateTo.
GET /api/case-manager/workspaces/{workspaceId}/cases?page=2&limit=25&status=open
Paginated responses put the records and a pagination block inside data:
{
"statusCode": 200,
"data": {
"cases": [ ... ],
"pagination": {
"total": 138,
"page": 2,
"totalPages": 6,
"hasNextPage": true,
"hasPrevPage": true
}
},
"success": true
}
Quick start
A complete, runnable example: authenticate and list the ten most recent cases. Keep the key in an environment variable, never in source control.
export MODULAW_KEY="mk_live_..."
export WS="your_workspace_id"
curl -s "https://backend.modulaw.ai/api/case-manager/workspaces/$WS/cases?limit=10" \
-H "Authorization: Bearer $MODULAW_KEY"const BASE = 'https://backend.modulaw.ai';
const WS = process.env.MODULAW_WORKSPACE_ID;
async function modulaw(method, path, body) {
const res = await fetch(BASE + path, {
method,
headers: {
Authorization: `Bearer ${process.env.MODULAW_KEY}`,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
});
const json = await res.json();
if (!res.ok) throw new Error(`${res.status}: ${json.message}`);
return json.data;
}
const { cases, pagination } = await modulaw(
'GET',
`/api/case-manager/workspaces/${WS}/cases?limit=10`
);
console.log(`${cases.length} of ${pagination.total}`);import os
import requests
BASE = "https://backend.modulaw.ai"
WS = os.environ["MODULAW_WORKSPACE_ID"]
SESSION = requests.Session()
SESSION.headers.update({
"Authorization": f"Bearer {os.environ['MODULAW_KEY']}",
"Content-Type": "application/json",
})
def modulaw(method, path, json=None, params=None):
r = SESSION.request(method, BASE + path, json=json, params=params, timeout=30)
if not r.ok:
raise RuntimeError(f"{r.status_code}: {r.json().get('message')}")
return r.json()["data"]
data = modulaw("GET", f"/api/case-manager/workspaces/{WS}/cases", params={"limit": 10})
print(len(data["cases"]), "of", data["pagination"]["total"])Worked examples
These use the modulaw() helper from Quick start for JavaScript and Python.
GET — a single record
curl -s "https://backend.modulaw.ai/api/case-manager/workspaces/$WS/cases/$CASE_ID" \
-H "Authorization: Bearer $MODULAW_KEY"const one = await modulaw('GET', `/api/case-manager/workspaces/${WS}/cases/${caseId}`);one = modulaw("GET", f"/api/case-manager/workspaces/{WS}/cases/{case_id}")POST — create
Creating a case needs a title and a client — either an existing clientId, or a clientName with an email or phone, in which case the client is created for you. Returns 201.
curl -s -X POST "https://backend.modulaw.ai/api/case-manager/workspaces/$WS/cases" \
-H "Authorization: Bearer $MODULAW_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Adebayo v. Lagos State",
"clientName": "Adebayo Holdings Ltd",
"clientEmail": "legal@adebayo.example",
"description": "Judicial review of the revocation notice."
}'const created = await modulaw('POST', `/api/case-manager/workspaces/${WS}/cases`, {
title: 'Adebayo v. Lagos State',
clientName: 'Adebayo Holdings Ltd',
clientEmail: 'legal@adebayo.example',
description: 'Judicial review of the revocation notice.',
});created = modulaw("POST", f"/api/case-manager/workspaces/{WS}/cases", json={
"title": "Adebayo v. Lagos State",
"clientName": "Adebayo Holdings Ltd",
"clientEmail": "legal@adebayo.example",
"description": "Judicial review of the revocation notice.",
})PUT — update
curl -s -X PUT "https://backend.modulaw.ai/api/case-manager/workspaces/$WS/cases/$CASE_ID" \
-H "Authorization: Bearer $MODULAW_KEY" \
-H "Content-Type: application/json" \
-d '{"title": "Adebayo v. Lagos State (amended)"}'await modulaw('PUT', `/api/case-manager/workspaces/${WS}/cases/${caseId}`, {
title: 'Adebayo v. Lagos State (amended)',
});modulaw("PUT", f"/api/case-manager/workspaces/{WS}/cases/{case_id}", json={
"title": "Adebayo v. Lagos State (amended)",
})PATCH — partial update
Used where only one field changes, such as a status flip:
curl -s -X PATCH \
"https://backend.modulaw.ai/api/case-manager/workspaces/$WS/cases/$CASE_ID/invoices/$INVOICE_ID/status" \
-H "Authorization: Bearer $MODULAW_KEY" \
-H "Content-Type: application/json" \
-d '{"status": "sent"}'await modulaw(
'PATCH',
`/api/case-manager/workspaces/${WS}/cases/${caseId}/invoices/${invoiceId}/status`,
{ status: 'sent' }
);modulaw(
"PATCH",
f"/api/case-manager/workspaces/{WS}/cases/{case_id}/invoices/{invoice_id}/status",
json={"status": "sent"},
)DELETE — remove
curl -s -X DELETE "https://backend.modulaw.ai/api/case-manager/workspaces/$WS/cases/$CASE_ID" \
-H "Authorization: Bearer $MODULAW_KEY"await modulaw('DELETE', `/api/case-manager/workspaces/${WS}/cases/${caseId}`);modulaw("DELETE", f"/api/case-manager/workspaces/{WS}/cases/{case_id}")Handling rate limits
At 120 requests a minute you will meet 429 eventually. Back off rather than retrying immediately:
async function withRetry(fn, attempts = 5) {
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (err) {
if (!String(err.message).startsWith('429') || i === attempts - 1) throw err;
await new Promise((r) => setTimeout(r, 2 ** i * 1000));
}
}
}
const data = await withRetry(() =>
modulaw('GET', `/api/case-manager/workspaces/${WS}/cases`)
);import time
def with_retry(fn, attempts=5):
for i in range(attempts):
try:
return fn()
except RuntimeError as err:
if not str(err).startswith("429") or i == attempts - 1:
raise
time.sleep(2 ** i)
data = with_retry(
lambda: modulaw("GET", f"/api/case-manager/workspaces/{WS}/cases")
)# curl --retry does not retry 429 by default; add it explicitly
curl -s --retry 5 --retry-delay 2 --retry-all-errors \
"https://backend.modulaw.ai/api/case-manager/workspaces/$WS/cases" \
-H "Authorization: Bearer $MODULAW_KEY"Endpoints
Paths below are relative to https://backend.modulaw.ai/api/case-manager unless stated otherwise. {ws} is your workspace id.
Cases
| Method | Path | Permission |
|---|---|---|
| GET | /workspaces/{ws}/cases | cases:read |
| GET | /workspaces/{ws}/cases/search | cases:read |
| POST | /workspaces/{ws}/cases | cases:write |
| GET | /workspaces/{ws}/cases/{caseId} | cases:read |
| PUT | /workspaces/{ws}/cases/{caseId} | cases:write |
| DELETE | /workspaces/{ws}/cases/{caseId} | cases:write |
| GET | /workspaces/{ws}/cases/{caseId}/timeline | cases:read |
| GET | /workspaces/{ws}/cases/{caseId}/financial-summary | cases:read |
Case notes
| Method | Path | Permission |
|---|---|---|
| GET | /workspaces/{ws}/cases/{caseId}/notes | cases:read |
| POST | /workspaces/{ws}/cases/{caseId}/notes | cases:write |
| PUT | /workspaces/{ws}/cases/{caseId}/notes/{noteId} | cases:write |
| DELETE | /workspaces/{ws}/cases/{caseId}/notes/{noteId} | cases:write |
Tasks
| Method | Path | Permission |
|---|---|---|
| GET | /workspaces/{ws}/cases/{caseId}/tasks | tasks:read |
| POST | /workspaces/{ws}/cases/{caseId}/tasks | tasks:write |
| PATCH | /workspaces/{ws}/cases/{caseId}/tasks/{taskId} | tasks:write |
| DELETE | /workspaces/{ws}/cases/{caseId}/tasks/{taskId} | tasks:write |
Calendar events
| Method | Path | Permission |
|---|---|---|
| GET | /workspaces/{ws}/cases/{caseId}/events | calendar:read |
| POST | /workspaces/{ws}/cases/{caseId}/events | calendar:write |
| GET | /workspaces/{ws}/cases/{caseId}/events/{eventId} | calendar:read |
| PUT | /workspaces/{ws}/cases/{caseId}/events/{eventId} | calendar:write |
| DELETE | /workspaces/{ws}/cases/{caseId}/events/{eventId} | calendar:write |
Documents
| Method | Path | Permission |
|---|---|---|
| GET | /workspaces/{ws}/cases/{caseId}/documents | documents:read |
| GET | /workspaces/{ws}/cases/{caseId}/documents/search | documents:read |
| GET | /workspaces/{ws}/cases/{caseId}/documents/{documentId} | documents:read |
| GET | /workspaces/{ws}/cases/{caseId}/documents/{documentId}/content | documents:download |
| POST | /workspaces/{ws}/cases/{caseId}/documents | documents:write |
| PUT | /workspaces/{ws}/cases/{caseId}/documents/{documentId} | documents:write |
| PUT | /workspaces/{ws}/cases/{caseId}/documents/{documentId}/annotations | documents:write |
| DELETE | /workspaces/{ws}/cases/{caseId}/documents/{documentId} | documents:write |
File uploads are multipart rather than JSON and are not part of this JSON API surface — upload in the app, then manage the record here.
Expenses, invoices and time entries
| Method | Path | Permission |
|---|---|---|
| GET | /workspaces/{ws}/cases/{caseId}/expenses | invoices:read |
| GET | /workspaces/{ws}/cases/{caseId}/expenses/summary | invoices:read |
| POST | /workspaces/{ws}/cases/{caseId}/expenses | invoices:write |
| PUT | /workspaces/{ws}/cases/{caseId}/expenses/{expenseId} | invoices:write |
| PATCH | /workspaces/{ws}/cases/{caseId}/expenses/{expenseId}/status | invoices:write |
| DELETE | /workspaces/{ws}/cases/{caseId}/expenses/{expenseId} | invoices:write |
| GET | /workspaces/{ws}/cases/{caseId}/invoices | invoices:read |
| POST | /workspaces/{ws}/cases/{caseId}/invoices | invoices:write |
| PATCH | /workspaces/{ws}/cases/{caseId}/invoices/{invoiceId}/status | invoices:write |
| GET | /workspaces/{ws}/cases/{caseId}/invoices/{invoiceId}/download | invoices:read |
| GET | /workspaces/{ws}/cases/{caseId}/time-entries | invoices:read |
| POST | /workspaces/{ws}/cases/{caseId}/time-entries | invoices:write |
| PUT | /workspaces/{ws}/cases/{caseId}/time-entries/{timeEntryId} | invoices:write |
| DELETE | /workspaces/{ws}/cases/{caseId}/time-entries/{timeEntryId} | invoices:write |
Clients
| Method | Path | Permission |
|---|---|---|
| GET | /workspaces/{ws}/clients | clients:read |
| GET | /workspaces/{ws}/clients/search | clients:read |
| GET | /workspaces/{ws}/clients/{clientId} | clients:read |
| POST | /workspaces/{ws}/clients | clients:write |
| PUT | /workspaces/{ws}/clients/{clientId} | clients:write |
| DELETE | /workspaces/{ws}/clients/{clientId} | clients:write |
| POST | /workspaces/{ws}/clients/bulk-update | clients:write |
| POST | /workspaces/{ws}/clients/bulk-delete | clients:write |
Custom fields
Custom fields are the workspace-defined extras on a client record — qualification, referral source, budget and so on. These endpoints manage the definitions: the schema that a client’s customFields map is validated against. They are workspace configuration rather than client data, so they take workspace:*, not clients:*.
| Method | Path | Permission |
|---|---|---|
| GET | /workspaces/{ws}/custom-fields | workspace:read |
| POST | /workspaces/{ws}/custom-fields | workspace:write |
| PATCH | /workspaces/{ws}/custom-fields/{fieldId} | workspace:write |
| DELETE | /workspaces/{ws}/custom-fields/{fieldId} | workspace:write |
| POST | /workspaces/{ws}/custom-fields/reorder | workspace:write |
Creating one needs a name and a type of text, number, date, boolean or select. A select field also needs options. The key is derived from the name unless you supply one, and must be unique within the workspace — a clash returns 409.
POST /workspaces/{ws}/custom-fields
{ "name": "Referral source", "type": "select",
"options": ["Website", "Referral", "Event"] }
# Then write it on a client. Only keys with a matching
# definition are stored; unknown keys are rejected.
PUT /workspaces/{ws}/clients/{clientId}
{ "customFields": { "referral_source": "Referral" } }
Read the definitions before writing customFields, so you are using keys that exist. They are also what the list filters accept — ?customFields[referral_source]=Referral.
Intakes, forms and workspace
| Method | Path | Permission |
|---|---|---|
| GET | /workspaces/{ws}/intakes | cases:read |
| GET | /workspaces/{ws}/intakes/{intakeId} | cases:read |
| PATCH | /workspaces/{ws}/intakes/{intakeId} | cases:write |
| GET | /workspaces/{ws}/forms/templates | forms:read |
| POST | /workspaces/{ws}/forms/templates | forms:write |
| GET | /workspaces/{ws}/forms/submissions | forms:read |
| POST | /workspaces/{ws}/forms/submissions | forms:write |
| GET | /api/workspaces/{ws} (different base) | workspace:read |
Legal corpus (not workspace-scoped)
These sit under https://backend.modulaw.ai/api/library and all need library:read.
| Method | Path | Returns |
|---|---|---|
| GET | /jurisdictions | Available jurisdictions and whether each is browsable |
| GET | /ng/browse | Nigerian case law |
| GET | /ng/statutes/browse | Nigerian statutes |
| GET | /ng/statutes/{id} | A single statute |
| GET | /us/courts | US courts |
| GET | /ca/databases | Canadian databases |
Field contracts worth knowing
Most endpoints round-trip cleanly: read a record, change a field, send it back. These are the places where that is not true, or where a field name means something other than it looks like, or where a write is refused for a reason worth understanding. Each one has caught an integrator.
Calendar: the list is a merged feed, not a list of events
GET /workspaces/{ws}/cases/{caseId}/events returns three kinds of record together: calendar events, task due dates and note reminders. So it is a view of the case diary rather than the event resource, and two field names follow from that.
| Field | What it actually is | Values |
|---|---|---|
itemType | Which record kind the row is. Read only, present on the feed. | event, task, note |
type | The event’s category. Required when you create an event. | court_hearing, client_meeting, internal_meeting, deadline, other |
They are different fields. Do not map itemType onto type: it will be rejected as an invalid category, and if it were accepted it would file a hearing as something else entirely.
Dates work the same way. The feed gives every row start and end so the three kinds sort together. A calendar event stores startDate and endDate, and those are what create and update expect.
# A row as it comes back from the feed
{ "itemType": "event", "type": "court_hearing",
"start": "2026-09-14T09:00:00Z", "end": "2026-09-14T11:00:00Z",
"startDate": "2026-09-14T09:00:00Z", "endDate": "2026-09-14T11:00:00Z" }
# What create expects
POST /workspaces/{ws}/cases/{caseId}/events
{ "title": "Directions hearing", "type": "court_hearing",
"startDate": "2026-09-14T09:00:00Z", "endDate": "2026-09-14T11:00:00Z" }
Write startDate and endDate and you will always be correct. An update is rolling out that also accepts the start and end spelling on write, so a feed row can be posted straight back, but the stored names are the ones to build against.
Rows with itemType of task or note are projections of tasks and case notes. They cannot be posted back as events. Create those through their own endpoints.
Tasks: assignedToClient is an object, not a flag
A task is shared with a client by naming the client, not by setting a boolean. Sending true or false has no effect: it is ignored, and the task stays internal.
# Ignored. The task is created, but nothing is shared with anyone.
{ "title": "Chase filing fee", "assignedToClient": false }
# Correct: name the client.
{ "title": "Upload signed engagement letter",
"assignedToClient": { "client": "CLIENT_ID", "requiresClientAction": true } }
# Internal task: leave the field out entirely.
{ "title": "Draft submissions" }
Setting assignedToClient.client is what makes a task visible in the client portal, and it turns on showInClientPortal for you. The client must already be the client on that case. requiresClientAction decides whether they can mark it done, and defaults to true.
Tasks and cases: assignees come back expanded
Reads expand assignedTo into full user records so you can show a name without a second call. Writes want the id.
# What you read back
"assignedTo": [ { "user": { "_id": "USER_ID", "firstName": "Ada",
"lastName": "Okafor", "email": "ada@firm.example" },
"role": "primary" } ]
# What to send
"assignedTo": [ { "user": "USER_ID", "role": "primary" } ]
Send the id and you will always be correct. Posting the expanded object back on a task currently fails with Invalid user ID format; an update is rolling out that accepts either shape.
The user must be a member of the workspace, and role is primary or secondary, defaulting to primary.
Clients: deletion is refused while cases are attached
A case cannot exist without a client — the link is required, not optional. So deleting a client that still has cases is refused rather than quietly detaching them. A single delete returns 409 naming the client and its case count.
Two ways forward: reassign or delete those cases first, or archive the client instead, which keeps the record and its history and is usually what “clean up my client list” means.
PUT /workspaces/{ws}/clients/{clientId}
{ "status": "archived" }
Bulk delete reports per client, and partial success is normal
POST /workspaces/{ws}/clients/bulk-delete takes up to 200 ids per call. Over that it returns 400 — split the list into batches.
It does not return a count. It tells you what happened to each id, because at this size some will always be already deleted, in another workspace, or holding cases. A 200 does not mean everything was removed — read failed.
POST /workspaces/{ws}/clients/bulk-delete
{ "clientIds": ["id1", "id2", "id3"] }
# 200 OK
{ "data": {
"deleted": ["id1"],
"failed": [
{ "id": "id2", "reason": "has 3 linked case(s). Reassign or delete those cases first, or archive the client instead (status: \"archived\") to keep the history intact." },
{ "id": "id3", "reason": "not found in this workspace" }
] } }
Every id you send comes back in exactly one of the two lists, so you can reconcile without guessing. Reasons are plain English and safe to show a user.
Status codes
| Code | Meaning | What to do |
|---|---|---|
200 | OK | — |
201 | Created | The new record is in data |
400 | Bad request | A field is missing or contradictory — the message names it |
401 | Unauthenticated | Key missing, revoked or malformed |
402 | Subscription inactive | The API needs a paid plan; trials do not qualify |
403 | Forbidden | Missing permission, wrong workspace, or a route keys cannot reach |
404 | Not found | Wrong id, or the record is in another workspace |
409 | Conflict | The record cannot be changed in its current state — for example deleting a client that still has cases, or creating a custom field whose key is taken |
429 | Rate limited | Back off and retry |
Rate limits
120 requests per minute, per key. Over that you get 429; wait for the window to reset. Give each application its own key so one busy job cannot rate-limit the others.
Good to know
- A key can never do more than your own account can — your role and workspace membership still apply on top of its permissions.
- A
403names the permission it wanted, so you can grant exactly that one instead of widening the key. - Deleting through the REST API is immediate. The two-step confirmation described in Permissions applies to AI assistants over MCP, not to your own code — so your own integration gets no second chance, and should confirm with the user itself.
- The one deletion that is refused rather than performed is a client that still has cases. That check applies to every caller, your code included.
- If you want an AI assistant rather than your own integration, use MCP — no key required.