Your website's server can list, update and add the leads in your workspace with one secret key. Here are the calls, the rules and a Next.js example.
Before you start
Ask us for an API key with the Client site (manage leads) preset. It can read and update your leads (leads:read, leads:write) and read their follow-up tasks (tasks:read). It only ever sees your own workspace, and it can't send campaigns, change settings or create other keys.
The key looks like mh_live_... and is shown once. Keep it in an environment variable on your server.
.env.local
# .env.local on your server. No NEXT_PUBLIC_ prefix: it must never reach the browser.
MAHARA_API_KEY=mh_live_...
Server-side only. Never put the key in browser code, a NEXT_PUBLIC_ variable or a mobile app: anyone holding it can read your leads. Your pages call your own server, and your server calls us. If a key leaks, tell us and we revoke it.
Authentication
Every request sends the key as a bearer token: Authorization: Bearer mh_live_.... The API lives at https://maharalabs.com/api/v1 and speaks JSON. A single item comes back as { "data": ... }; a list adds nextCursor.
List leads
GET /api/v1/leads returns your leads, oldest first. All filters are optional:
status: NEW, TRIAGED, CONVERTED or DISMISSED.
source: CONTACT_FORM, QUOTE_FORM, NEWSLETTER, CHATBOT, IMPORT, WEBHOOK, MANUAL or OTHER.
email: an exact address (not case-sensitive).
label: leads carrying this label.
capturedSince: an ISO date or date-time, e.g. 2026-10-01.
limit: 1 to 100, default 50. cursor: the nextCursor from the previous page.
Keep passing nextCursor back as cursor until it is null. The OpenAPI spec lists every lead field.
Every page
// Uses the mahara() helper from the Next.js example below.
let cursor: string | null = null;
do {
const query = new URLSearchParams({ status: "NEW", limit: "100" });
if (cursor) query.set("cursor", cursor);
const page = await mahara<{ data: Lead[]; nextCursor: string | null }>(
`/leads?${query}`,
);
for (const lead of page.data) {
// ...
}
cursor = page.nextCursor;
} while (cursor);
Read and update a lead
GET /api/v1/leads/{id} returns one lead. PATCH /api/v1/leads/{id} changes only the fields you send:
status: NEW, TRIAGED or DISMISSED. A dismissed lead can be reopened as NEW or TRIAGED, which clears the dismissal. A converted lead's status can't change.
dismissReason: why it was dismissed (up to 200 characters), sent with DISMISSED or on a lead that already is.
labels: replaces the lead's labels (up to 20, each up to 64 characters).
notes: free text, up to 4,000 characters. null clears it.
assigneeLabel: who owns it, as text, e.g. Sales desk (up to 80 characters).
The response is the updated lead. To see a lead's follow-up tasks, call GET /api/v1/tasks?leadId=LEAD_ID.
Add a lead
Two ways, both from your server with the same key. Every lead needs an email or a phone. Leads go into your workspace and land in the same inbox as everything else.
POST /api/v1/leads takes email, phone, firstName, lastName, company, title, message, source (default WEBHOOK), labels, notes, assigneeLabel and meta (extra details to keep with the lead). It needs an Idempotency-Key header: 1 to 255 of A-Z a-z 0-9 _ . : -, unique per lead you send. Retrying with the same key and body within 24 hours returns the first response again (with Idempotent-Replayed: true), so a timeout never makes a duplicate. The same key with a different body is refused.
A new lead returns 201. If the same person already sent a lead from the same source in the last 24 hours, the message is added to that lead and you get 200 with it.
POST /api/lead-capture takes the JSON a website form would send: email, phone, firstName, lastName, company, title, message, source and labels. No idempotency header. It returns 201 with ok and the leadId; its errors are a plain { "error": "..." }.
An App Router site: one helper that holds the key, and two route handlers your own admin pages call. They run on your server, so the key never reaches a browser.
lib/mahara.ts
// lib/mahara.ts — import this only from server code (route handlers, server actions).
const API = "https://maharalabs.com/api/v1";
export type Lead = {
id: string;
status: "NEW" | "TRIAGED" | "CONVERTED" | "DISMISSED";
source: string;
email: string | null;
phone: string | null;
firstName: string | null;
lastName: string | null;
company: string | null;
message: string | null;
labels: string[];
notes: string | null;
assigneeLabel: string | null;
capturedAt: string;
};
export async function mahara<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${API}${path}`, {
...init,
headers: {
Authorization: `Bearer ${process.env.MAHARA_API_KEY}`,
"Content-Type": "application/json",
},
cache: "no-store",
});
const body = await res.json();
if (!res.ok) {
const { code, message, requestId } = body.error ?? {};
throw new Error(`Mahara API ${res.status} ${code}: ${message} (${requestId})`);
}
return body as T;
}
app/api/admin/leads/route.ts
// app/api/admin/leads/route.ts — GET /api/admin/leads?status=NEW&cursor=...
import { mahara, type Lead } from "@/lib/mahara";
export async function GET(request: Request) {
// This is your site's own admin endpoint: check your admin login here first.
const url = new URL(request.url);
const query = new URLSearchParams({ limit: "50" });
for (const name of ["status", "source", "label", "capturedSince", "cursor"]) {
const value = url.searchParams.get(name);
if (value) query.set(name, value);
}
const page = await mahara<{ data: Lead[]; nextCursor: string | null }>(
`/leads?${query}`,
);
return Response.json(page);
}
app/api/admin/leads/[id]/route.ts
// app/api/admin/leads/[id]/route.ts — PATCH /api/admin/leads/{id}
import { mahara, type Lead } from "@/lib/mahara";
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
// Check your admin login here first.
const { id } = await params;
const { status, dismissReason, labels, notes, assigneeLabel } = await request.json();
// JSON.stringify leaves out undefined fields, so only what was sent changes.
const { data } = await mahara<{ data: Lead }>(`/leads/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify({ status, dismissReason, labels, notes, assigneeLabel }),
});
return Response.json(data);
}
These two routes hand out your leads, so put them behind your admin login. Without it, anyone who finds the URL could read them.
Errors and limits
Every error from /api/v1 has the same shape:
Error
{
"error": {
"code": "validation_error",
"message": "The body is not valid.",
"details": [{ "path": "email", "message": "A lead needs an email or a phone." }],
"requestId": "req_3f9c0d..."
}
}
400validation_error (details names the field), or idempotency_key_required.
401unauthorized: the key is missing, wrong, revoked or expired.
403insufficient_scope: the key can't do this.
404not_found.
409conflict (e.g. a converted lead's status), or idempotency_in_progress while the first request with that key is running.
413payload_too_large: bodies are limited to 256 KB.
422idempotency_key_reused: the key was used for a different request.
429rate_limited, and 500internal_error.
Each key has a per-minute rate limit. Responses carry x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset (Unix time in seconds); a 429 adds retry-after in seconds. Every response also has an x-request-id: quote it, or the requestId, if you contact us.
Full reference
GET /api/v1/openapi.json is the complete OpenAPI 3.1 spec: every endpoint, field and response. It needs no key, and most API tools and code generators can import it.