Every call is a POST, and the operation name is the path
There is one route per operation and nothing else: the name, with its dots turned into slashes, straight off the origin. email.send is POST /email/send; auth.user.list is POST /auth/user/list.
So there are no verbs to choose and no path parameters to build. An id is a field in the JSON body like any other field, which is why the same operation name works unchanged as an MCP tool, a CLI command and an SDK method — only the envelope around it differs.
The body is JSON and may be at most 1,048,576 bytes. A larger one is refused with body_too_large before it is read, from the Content-Length alone where the client declares it. An absent or empty body means {}, so an operation with no required fields needs no body at all.
Every response carries a request id
lath-request-id comes back as a header on every response, and inside the body of every REST refusal an operation makes as error.requestId. The body carries it too because a customer reading a refusal in their own logs has the body and not always the headers. Two bodies do not: a 404 for a path that is not an operation, and a refusal returned through MCP, which is { code, message, fix } alone.
You can supply your own instead of being given one: send lath-request-id on the request and it is honoured if it is 1–64 characters of letters, digits, dot, underscore, colon or hyphen. Anything else is replaced with a generated id rather than rejected — a malformed trace id should not cost you the call. That is what lets one id join your logs to ours.
Paging: the response shape tells you which kind it is
Three shapes, and which one an operation returns is fixed. Lists return { …rows, nextBefore }, newest first; fourteen operations page this way, among them audience.contact.list, email.message.list and developers.events.list. Pass that nextBefore value back as before to get the next page.
Exports return { …rows, nextAfter } — oldest first, so a full copy stays consistent while you walk it. nextAfter is opaque: pass it back unchanged as after, and do not build one yourself. auth.user.export and audience.contact.export are the two that work this way, and a cursor you constructed is refused with invalid_input naming exactly that. sms.conversation.get also reads forward with after, but its after is an ordinary timestamp — the createdAt of the newest message you already have.
Everything else returns the rows with a limit and no cursor — auth.session.mine, email.automation.list, billing.invoice.list. There is no second page to ask for, so ask for the maximum when you need all of them.
For both cursor shapes the stop condition is the same, and it is the one to code against: a page holding fewer rows than you asked for is the last page, and the cursor comes back null. Do not stop on an empty page — stop on a short one, or you will make one extra call every time.
limit is where the bounds vary. Most lists allow up to 200 and default to 50; the two exports allow up to 500 and default to 200; a few cap at 100. Each operation’s reference page carries its own, and asking for more than the maximum is invalid_input, not a silent trim.
Idempotency keys make a retry safe
A client that never heard the reply has no way to know whether the send happened. Send Idempotency-Key with any string you choose and the answer to the first request is stored and replayed to the second, so retrying costs nothing and sends nothing twice.
Over REST it is honoured on every operation that changes something — 149 of them — and it matters on the 62 that are not safe to repeat on their own, email.send among them. Reads ignore the header entirely: repeating a read is already harmless, and a stored answer would only go stale. email.message.list does not take one; email.send does.
The key is scoped to your environment, your credential and that one operation, so the same string used against a different operation is a different key. Two requests carrying the same key at the same time do not race: the second waits for the first and then replays its answer, rather than both running and one losing.
Reusing a key with different input is refused with idempotency_mismatch (409) rather than quietly answering the old question. A stored answer lasts 24 hours; after that the same key is simply a new request.
On the other surfaces it is the same mechanism under a different name: the SDK takes idempotencyKey as a second argument, the CLI takes --idempotency-key, and the hosted MCP server adds an idempotencyKey field to the tools that are neither reads nor retry-safe. The local server lath mcp runs does not offer that field yet.
Rate limits, and the header that says how long to wait
600 requests a minute per credential. A publishable key is one credential, so every browser using it shares that budget. A request with no Authorization header at all is counted against its IP address instead, at 60 a minute — the keyless operations such as completing a magic link, and public reads such as /openapi.json.
The count is kept by each API server process rather than in the database, so while more than one process serves traffic the effective ceiling can be higher. Build against 600; do not rely on more.
Going over returns rate_limited (429) with a retry-after header in seconds, and the same number in the message. Wait that long rather than retrying immediately; a client that retries at once is the reason the limit exists.
The limit is counted per credential, not per account, so rotating a key or issuing a second one gives you a second budget. Individual operations have their own tighter limits where sending an unlimited number would be the abuse — starting a sign-in, redeeming a link, sending a text. Those are counted in the database, set no retry-after header, and put the wait in fix instead; the rate limits guide lists every one.Last modified on September 23, 2026