<p align="center">
  <img alt="Levo" src="https://static.levocdn.com/logos/levo-logo-1024.png" width="256" height="102">
</p>

<p align="center">
  <a href="https://levo.so"><b>Levo</b></a> is an AI-powered CMS and growth platform that turns websites into<br/>
  revenue channels — publish AI-written pages and blogs in minutes, ship proven<br/>
  growth playbooks, and watch audience analytics roll in, all without engineers.<br/>
  New to Levo? <a href="https://app.levo.so">Sign up free</a> · <a href="https://levo.so">Learn more at levo.so</a>
</p>

<h1 align="center">@levo-so/client</h1>

<p align="center">
  The official TypeScript SDK for the times when you <i>do</i> want engineers.<br/>
  Content CRUD, user auth, notifications, and transactions in one<br/>
  <code>createClient</code> call — in Node.js, Bun, or Deno.
</p>

<p align="center">
  <a href="https://www.npmjs.com/package/@levo-so/client"><img src="https://img.shields.io/npm/v/@levo-so/client.svg?style=for-the-badge&color=cb3837&logo=npm&logoColor=white" alt="npm version"></a>
  <a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-22c55e.svg?style=for-the-badge" alt="MIT License"></a>
  <img src="https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript">
  <img src="https://img.shields.io/badge/Bun-%23000000.svg?style=for-the-badge&logo=bun&logoColor=white" alt="Bun">
  <img src="https://img.shields.io/badge/Node-%3E%3D18-5FA04E?style=for-the-badge&logo=node.js&logoColor=white" alt="Node >=18">
</p>

<p align="center">
  <a href="https://claude.ai/new?q=Help%20me%20use%20%40levo-so%2Fclient%20%E2%80%94%20the%20Levo%20TypeScript%20SDK.%20Read%20the%20latest%20README%20at%20https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F%40levo-so%2Fclient%20and%20then%20answer%20my%20question.">
    <img src="https://www.google.com/s2/favicons?domain=claude.ai&sz=64" height="24" align="middle" alt="">
    &nbsp;<b>Ask&nbsp;Claude</b>
  </a>
  &nbsp;&nbsp;·&nbsp;&nbsp;
  <a href="https://chatgpt.com/?q=Help%20me%20use%20%40levo-so%2Fclient%20%E2%80%94%20the%20Levo%20TypeScript%20SDK.%20Read%20the%20latest%20README%20at%20https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F%40levo-so%2Fclient%20and%20then%20answer%20my%20question.">
    <img src="https://www.google.com/s2/favicons?domain=chatgpt.com&sz=64" height="24" align="middle" alt="">
    &nbsp;<b>Ask&nbsp;ChatGPT</b>
  </a>
  &nbsp;&nbsp;·&nbsp;&nbsp;
  <a href="https://www.perplexity.ai/search?q=Help%20me%20use%20%40levo-so%2Fclient%20%E2%80%94%20the%20Levo%20TypeScript%20SDK.%20Read%20the%20latest%20README%20at%20https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F%40levo-so%2Fclient%20and%20then%20answer%20my%20question.">
    <img src="https://www.google.com/s2/favicons?domain=perplexity.ai&sz=64" height="24" align="middle" alt="">
    &nbsp;<b>Ask&nbsp;Perplexity</b>
  </a>
  &nbsp;&nbsp;·&nbsp;&nbsp;
  <a href="https://grok.com/?q=Help%20me%20use%20%40levo-so%2Fclient%20%E2%80%94%20the%20Levo%20TypeScript%20SDK.%20Read%20the%20latest%20README%20at%20https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F%40levo-so%2Fclient%20and%20then%20answer%20my%20question.">
    <img src="https://www.google.com/s2/favicons?domain=grok.com&sz=64" height="24" align="middle" alt="">
    &nbsp;<b>Ask&nbsp;Grok</b>
  </a>
</p>

---

## Table of Contents

**Getting started**

- [Requirements](#requirements) — runtimes and prerequisites
- [Installation](#installation) — add the package to your project
- [Get your API key](#get-your-api-key) — where to find it in the dashboard
- [Quick start](#quick-start) — minimal working example
- [Configuration](#configuration) — `createClient` options reference
- [Type generation](#type-generation) — typed collections from your schema
- [Error handling](#error-handling) — `LevoError` shape and common patterns
- [Query reference](#query-reference) — `LevoQuery` operators, shapes, and examples

**Content**

- [findMany](#findmany) — list documents with filters, sort, and pagination
- [findUnique](#findunique) — fetch a single document by `_id`
- [findFirst](#findfirst) — fetch the first document matching a query
- [count](#count) — count matches without fetching documents
- [create](#create) — insert a new document
- [edit](#edit) — partial update on a single document
- [remove](#remove) — delete a single document
- [publish](#publish) — move a draft to published
- [unpublish](#unpublish) — move a published document back to draft
- [increment](#increment) — atomic counter updates (+/-)
- [aggregate](#aggregate) — group-by, sum, average, and having clauses
- [Bulk operations](#bulk-operations) — apply the same change to many documents
  - [editBulk](#editbulk) — bulk partial update
  - [removeBulk](#removebulk) — bulk delete
  - [publishBulk / unpublishBulk](#publishbulk--unpublishbulk) — bulk status change
  - [incrementBulk](#incrementbulk) — bulk atomic counters

**Membership**

- [How auth cookies flow](#how-auth-cookies-flow) — ASCII walkthrough end-to-end
- [signInWithPassword](#signinwithpassword) — email/username + password login
- [signupWithPassword](#signupwithpassword) — create a new account
- [signOut](#signout) — end the current session
- [getMe](#getme) — fetch the signed-in user
- [updateMe](#updateme) — partial profile update
- [changePassword](#changepassword) — rotate the current user's password
- [requestOtp / signInWithOtp](#requestotp--signinwithotp) — passwordless OTP flow
- [requestMagicLink](#requestmagiclink) — single-use signed login link via email
- [getOAuthURLs](#getoauthurls) — social login start URLs
- [The OAuth indirection](#the-oauth-indirection) — why `urls.google` points to your domain
- [Forwarding cookies in your framework](#forwarding-cookies-in-your-framework) — Express, Next.js, Hono, Fastify, Node
- [The `domain` option](#the-domain-option) — what it is, local dev, silent failure mode

**Notifications**

- [sendEmail](#sendemail) — transactional email, plain HTML or rich-text template

**Operational**

- [Rate limits and quotas](#rate-limits-and-quotas) — global rate, body size, auth, handling 429s

**Advanced**

- [Transactions](#transactions) — atomic multi-step content operations
- [The transaction lifecycle](#the-transaction-lifecycle) — ASCII walkthrough

**Reference**

- [Putting it together](#putting-it-together) — end-to-end example
- [Common error codes](#common-error-codes) — what you'll see in `error.details.code`
- [Telemetry](#telemetry) — what this SDK does and does not send
- [Support](#support) — where to get help
- [License](#license)

---

## 📋 Requirements

- **Node.js ≥ 18**, **Bun**, or **Deno**. Uses the native `fetch` API — no polyfills needed.
- A Levo workspace ID, an API key, and (for Membership flows) the host your site actually runs on.

---

## 📦 Installation

```bash
npm install @levo-so/client
# or
pnpm add @levo-so/client
# or
yarn add @levo-so/client
# or
bun add @levo-so/client
```

---

## 🔑 Get your API key

You need a workspace API key and your workspace ID before you can call the SDK.

**Get the API key**

1. Sign in to your [Levo dashboard](https://app.levo.so).
2. Open **Settings → Workspace → API Keys**.
   Direct link: `https://app.levo.so/workspace/acme/settings/api-keys` — replace `acme` with your own workspace slug. (Your slug is the segment right after `/workspace/` in any dashboard URL you're already on.)
3. Click **Create new key**, give it a name, and copy the value. The key is shown only once and looks like `wk.v1.r5zP4lNnzKNVn4RDfabc.0OgVsYU6xa8`.

**Find your workspace ID**

Open **Settings → Workspace → About**. Your workspace ID (e.g. `WABC1234`) is listed at the top of the page.

**Store them in your environment**

Never commit the key to source — use your host's secrets manager, or a `.env` file that is `.gitignore`d.

```bash
# .env
LEVO_API_KEY=wk.v1.r5zP4lNnzKNVn4RDfabc.0OgVsYU6xa8
LEVO_WORKSPACE=WABC1234
LEVO_DOMAIN=example.com
```

If you don't already have one, add the file to `.gitignore`:

```bash
echo ".env" >> .gitignore
```

---

## 🚀 Quick start

> **One-time setup:** before the SDK can query anything, you need at least one collection defined in your workspace. Open **[Collections](https://app.levo.so)** in the Levo dashboard, create one (`case_studies`, `inquiries`, whatever your domain calls for), and define its fields. The SDK talks to collections you create — it does not create them for you.

```typescript
import { createClient } from '@levo-so/client';

const client = createClient({
  key: process.env.LEVO_API_KEY!,
  workspace: process.env.LEVO_WORKSPACE!,
  domain: process.env.LEVO_DOMAIN, // required for Membership flows
});

const studies = await client.content.findMany('case_studies', {
  where: { _status: 'published' },
  sort: { created_at: 'desc' },
  limit: 20,
});

console.log(studies.data);       // typed array of case studies
console.log(studies.meta.total); // total count across pages
```

> **`case_studies` is a placeholder.** Collections are things you define in the Levo dashboard under **Collections** — replace `case_studies` with your own key throughout these examples. Run `generate-types` (see below) and your editor will autocomplete the ones your workspace actually has.

> **Never** hardcode your API key in source. Load it from environment variables or a secret manager.

---

## ⚙️ Configuration

`createClient(options)` accepts:

| Option      | Type     | Required        | Description                                                             |
| ----------- | -------- | --------------- | ----------------------------------------------------------------------- |
| `key`       | `string` | ✅               | Your Levo API key.                                                      |
| `workspace` | `string` | ✅               | Your workspace ID, e.g. `WABC1234`.                                     |
| `domain`    | `string` | Membership only | The host your site runs on. Used to scope Membership auth cookies.      |
| `endpoint`  | `string` | —               | Override the API base URL. Defaults to `https://public-api.levo.so`. Useful for pointing at a staging environment, a self-hosted deployment, or a local mock during tests. |

---

## 🧬 Type generation

`@levo-so/client` ships with full TypeScript support. Run `generate-types` once against your workspace, and every collection, every field, and every relation becomes fully typed.

### What you actually get

**For you — full IDE autocomplete.** Your editor knows every collection, what `client.content.findMany('case_studies', { where: ... })` accepts at each level, and which fields are required on `create`. Typos on field names get flagged the moment you write them. No context-switching to the dashboard to remember what a collection looks like — it's right there in your autocomplete dropdown.

**For AI coding assistants — a verification layer against hallucinations.** When Claude Code, Cursor, Copilot, or any other AI agent writes SDK calls on your behalf, TypeScript checks every payload against the exact shape of your workspace. Hallucinated field names (`author` when the field is `author_name`), wrong types (`price: "99.00"` when it's a number), fabricated relations — all fail at compile time instead of in production. If you let AI agents touch your codebase, running `generate-types` before they do is the cheapest bug filter you can buy.

### Running it

```bash
npx @levo-so/client generate-types --workspace <workspace-id> --key <api-key>
# or
bunx @levo-so/client generate-types --workspace <workspace-id> --key <api-key>
```

### Where the file lands

The CLI writes `generated.d.ts` into the installed SDK, at `node_modules/@levo-so/client/dist/generated.d.ts`. The client's own entrypoints import it from there, which is how autocomplete "just works" after generation — no extra TypeScript config, no path mapping, no module augmentation in your project.

### Consequences — read these before wiring it up

- **You cannot commit the generated file.** It lives inside `node_modules`, which is always gitignored.
- **Every fresh install wipes it.** `npm install`, `bun install`, CI caches — all replace `dist/generated.d.ts` with the stock one from the tarball.
- **You must regenerate after every install** before TypeScript sees your real schema.

### Recommended workflow — manual, checked into CI

We recommend **running the CLI as an explicit step**, not as a `postinstall` hook:

```jsonc
// package.json
{
  "scripts": {
    "generate-types": "generate-types --workspace $LEVO_WORKSPACE --key $LEVO_API_KEY",
    "dev":            "npm run generate-types && next dev",
    "build":          "npm run generate-types && next build"
  }
}
```

**Why not `postinstall`?** `postinstall` runs during `npm install` / `bun install`, and CI pipelines often run install **before** secrets are injected — so `LEVO_API_KEY` is empty and the CLI fails silently, leaving you with stale types. Worse, it fails in places where `npm install` is automated (renovate bots, security scanners) and has no reason to have your credentials.

**Add the `generate-types` step to every CI pipeline that builds or tests the project** — right after `npm install`, right before `tsc` / `next build` / `vitest`. Skipping it means TypeScript checks your code against whatever schema was baked into the last published SDK release, not your actual workspace. Your build will compile against stale types and fail in surprising ways.

> **Heads up:** there is no `--out` flag yet — the output path is fixed to `node_modules/@levo-so/client/dist/generated.d.ts`. If you need the types in your own repo (e.g. to commit a frozen schema snapshot), that's on the roadmap but not shipped.

---

## 🚨 Error handling

Every failure throws a `LevoError` with a structured `details` object — the same shape returned by the Levo API. Network failures (timeouts, aborts, DNS) throw plain `Error`/`TypeError` from the native `fetch` layer.

```typescript
import { createClient, LevoError } from '@levo-so/client';

try {
  await client.content.edit('case_studies', '7302960573923055239', {
    title: 'New title',
  });
} catch (error) {
  if (error instanceof LevoError) {
    console.error(error.details.code);        // machine-readable, e.g. "bevy.content.NOT_FOUND"
    console.error(error.details.title);       // human-readable title
    console.error(error.details.description); // detailed message
    return;
  }
  throw error;
}
```

> **Note:** read methods like `findUnique` and `findFirst` return `null` on "not found" — they do **not** throw. Only mutations (`edit`, `remove`, `publish`, `unpublish`, `increment`) throw `LevoError` when the id doesn't exist.

For the full list of codes you'll see in `error.details.code`, jump to [Common error codes](#common-error-codes) near the end of this document.

---

## 🔍 Query reference

Every Content method that takes a filter uses the same `LevoQuery` type family. Understanding it once saves you flipping back through the method-by-method examples — and your IDE's autocomplete will walk you through the rest.

### The big picture

| Type | Where it appears | What it is |
| --- | --- | --- |
| `LevoQuery.Where<Fields>` | `count`, `editBulk`, `removeBulk`, `publishBulk`, `unpublishBulk`, `incrementBulk` | A filter clause — the "where" in SQL terms |
| `LevoQuery.FindQuery<Fields>` | `findMany`, `findFirst` | Filter + sort + pagination + select + include + search |
| `LevoQuery.AggregateQuery<Fields>` | `aggregate` | Group by, aggregate ops, `where` + `having` + sort + pagination |
| `LevoQuery.Sort<Fields>` | inside `FindQuery` and `AggregateQuery` | `{ field: 'asc' \| 'desc' }` |
| `LevoQuery.Pagination` | inside `FindQuery` | `{ page, limit }`, defaults `{ page: 1, limit: 10 }` |

### `Where` — comparison operators

A `Where` is either a **field filter** (plain object mapping field names to values or operator maps) or a **logical combinator** (`AND` / `OR` over sub-filters). Every example below is followed by a plain-English description of what it matches.

**Equality**

```typescript
{ category: 'fintech' }
// case studies whose category equals "fintech"

{ category: { equals: 'fintech' } }
// same as above — explicit form, useful if TypeScript ever gets confused by the shorthand

{ category: { not: 'archived' } }
// case studies whose category is anything other than "archived"
```

**Sets — `in` / `not_in`**

```typescript
{ source: { in: ['homepage', 'pricing', 'docs'] } }
// inquiries that came from the homepage, pricing, or docs page

{ source: { not_in: ['test', 'internal'] } }
// inquiries from any source other than test or internal
```

**Numeric / date comparison**

```typescript
{ views: { gt: 1000 } }
// case studies with more than 1000 views

{ rating: { gte: 0.8 } }
// testimonials with a rating of 0.8 or higher

{ created_at: { lt: new Date('2025-01-01') } }
// anything created before Jan 1, 2025

{ created_at: { between: [new Date('2025-01-01'), new Date('2025-06-30')] } }
// anything created in the first half of 2025
```

**String**

```typescript
{ title: { contains: 'growth' } }
// case studies whose title contains the word "growth"

{ email: { starts_with: 'support@' } }
// inquiries whose email starts with "support@"

{ email: { ends_with: '@acme.test' } }
// inquiries from anyone at acme.test
```

`not_contains`, `not_starts_with`, `not_ends_with` follow the same shape with inverted semantics.

**Null / empty**

```typescript
{ note: { empty: true } }
// inquiries whose note field is null or an empty string

{ reference_number: { is_not_empty: true } }
// inquiries that have a non-empty reference number
```

**Array containment**

For primitive arrays (like `tags: string[]`), implicit equality works — it matches if any element in the array equals the value:

```typescript
{ tags: 'urgent' }
// case studies whose tags array contains "urgent"
```

**Geo — `within`**

```typescript
{
  location: {
    within: {
      latitude: 12.9716,
      longitude: 77.5946,
      distance: 5,
      unit: 'kilometers', // 'kilometers' | 'miles' | 'meters' | 'feet'
    },
  },
}
// anything whose `location` point is within 5 km of the given coordinates
```

**Full-text search**

```typescript
{ search: 'growth strategy' }
// full-text search across all indexed fields for "growth strategy"
```

**Logical combinators — `AND` / `OR`**

```typescript
{ AND: [{ _status: 'published' }, { views: { gt: 1000 } }] }
// case studies that are BOTH published AND have more than 1000 views

{ OR: [{ category: 'fintech' }, { category: 'saas' }] }
// case studies in EITHER the fintech or the saas category
```

### `FindQuery` — the shape `findMany` / `findFirst` accept

```typescript
{
  where:   { _status: 'published' },     // LevoQuery.Where — any filter from above
  sort:    { created_at: 'desc' },       // LevoQuery.Sort — 'asc' | 'desc' per field
  select:  { title: true, summary: true }, // projection; omit for all fields
  include: { author: true },             // relation loading
  search:  'growth strategy',            // full-text at query level
  page:    1,                            // 1-indexed
  limit:   20,                           // max per page
}
// "give me the 20 most recent published case studies with only their title and summary,
//  plus the related author record, filtered by the full-text phrase 'growth strategy'"
```

### `AggregateQuery` — the shape `aggregate` accepts

```typescript
{
  group:     ['day:created_at', 'source'],   // group by bucketed date + source
  aggregate: [
    { column: 'amount', by: 'sum' },         // → field `sum_amount` in result
    { column: '_id',    by: 'count' },       // → field `count__id`
  ],
  where:  { created_at: { gt: new Date('2025-01-01') } }, // pre-aggregation filter
  having: { sum_amount: { gte: 1000 } },                   // post-aggregation filter
  sort:   { sum_amount: 'desc' },                          // sort by the aggregated field
  limit:  30,
}
// "group sales by day and source from Jan 1, 2025 onward, sum the amounts per group,
//  only keep groups that totaled at least $1000, sort biggest first, cap at 30 rows"
```

**Aggregate operators**: `sum`, `count`, `count_distinct`, `avg`, `min`, `max`, `median`, `first`, `last`, `percentile25`, `percentile75`.

**Date bucketing**: `day:<field>`, `week:<field>`, `month:<field>`, `quarter:<field>`, `year:<field>`.

Computed column names are `<operator>_<column>` — that's how you reference them in `sort` and `having`. Your IDE's autocomplete will generate them for you from the aggregated fields.

---

## 📚 Content

Methods are ordered by how frequently you'll reach for them — reads first, then writes, then analytics and bulk operations at the end.

### findMany

**List documents from a collection.** Takes any `LevoQuery.FindQuery`: `where`, `sort`, `select`, `include`, `search`, `page`, `limit`. **Returns a paginated envelope**, not a raw array — the documents live on `.data`, total count on `.meta.total`, so you can drive pagination UIs from one call.

Defaults to `page: 1, limit: 10` if omitted. Always set `limit` explicitly on large collections so you don't accidentally page through everything.

```typescript
const response = await client.content.findMany('case_studies', {
  where: { _status: 'published' },
  sort: { created_at: 'desc' },
  page: 1,
  limit: 20,
});
```

Returns:

```jsonc
{
  "data": [
    { "_id": "7302960573923055239", "title": "How Acme tripled signups", "_status": "published" },
    { "_id": "7302960573923055240", "title": "Globex cuts churn 40%",   "_status": "published" }
  ],
  "meta": { "total": 42, "page": 1, "limit": 20 }
}
```

### findUnique

**Fetch a single document by its `_id`.** Returns `null` if nothing matches — it does not throw on "not found", so always null-check before accessing fields. Use this when you already know the id (e.g. from a URL parameter or another query result).

```typescript
const study = await client.content.findUnique('case_studies', '7302960573923055239');
if (!study) return;

console.log(study.title);
```

### findFirst

**Return the first document matching a filter** — useful when you want "just one" out of many and don't care about pagination metadata. Returns `null` if nothing matches. The query shape is identical to `findMany`; internally it's `findMany(..., { limit: 1 }).data[0] ?? null`.

```typescript
const latest = await client.content.findFirst('case_studies', {
  where: { _status: 'published' },
  sort: { created_at: 'desc' },
});
```

### count

**Return how many documents match a query — without fetching any of them.** Ideal for dashboard tiles, "X new items" badges, or short-circuiting a flow before a more expensive call. The second argument is a plain `where` clause — **not** wrapped in `{ where: ... }`.

```typescript
const total     = await client.content.count('case_studies');
const published = await client.content.count('case_studies', { _status: 'published' });
```

Returns:

```
42
```

### create

**Insert a new document.** Returns the created document with its auto-generated `_id`. TypeScript will reject missing required fields at compile time — run `generate-types` first to get that safety.

```typescript
const inquiry = await client.content.create('inquiries', {
  email: 'jane@acme.test',
  name: 'Jane Doe',
  source: 'homepage',
});
```

Returns:

```jsonc
{
  "_id": "7302960573923055239",
  "email": "jane@acme.test",
  "name": "Jane Doe",
  "source": "homepage",
  "_status": "draft",
  "created_at": "2026-04-15T09:12:33.000Z"
}
```

### edit

**Partial update — only the fields you pass change.** Returns the updated document. Throws `LevoError` with code `bevy.content.NOT_FOUND` if the id doesn't exist.

```typescript
const updated = await client.content.edit('case_studies', '7302960573923055239', {
  title: 'How Acme tripled signups in six weeks',
});
```

### remove

**Delete a single document by id.** Returns the **deleted document** (not a boolean) — useful if you need to log the removed value, fan out cleanup to downstream systems, or undo.

```typescript
const removed = await client.content.remove('inquiries', '7302960573923055239');
```

### publish

**Transition a draft to published.** Returns a boolean — `true` if the state changed, `false` if the document was already published (so you can safely call it idempotently).

```typescript
const ok = await client.content.publish('case_studies', '7302960573923055239');
```

Returns:

```
true
```

### unpublish

**Inverse of `publish`.** Returns `true` if the document was moved back to draft, `false` if it was already in draft.

```typescript
const ok = await client.content.unpublish('case_studies', '7302960573923055239');
```

### increment

**Atomically add (or subtract) to numeric fields on a single document.** Negative values decrement. All updates in one call are applied atomically on the server — so even under heavy concurrency, counts never race or tear.

```typescript
await client.content.increment('case_studies', '7302960573923055239', {
  views: 1,
  shares: 1,
});
```

Common pattern — bump a view counter on every page load without a read-modify-write cycle:

```typescript
await client.content.increment('case_studies', study._id, { views: 1 });
```

### aggregate

**Group-by / sum / average / count queries.** The server computes on the database directly, so this is the right tool for dashboard tiles, weekly reports, leaderboard views, and analytics summaries — anything where fetching rows and summing on the client would be wasteful.

Aggregated column names follow the pattern `<op>_<column>` (e.g. `sum_amount`, `avg_rating`) — that's how you reference them in `sort` and `having`.

```typescript
const result = await client.content.aggregate('testimonials', {
  group: ['industry'],
  aggregate: [{ column: 'rating', by: 'avg' }],
  where: { created_at: { gte: new Date('2024-01-01') } },
  sort: { avg_rating: 'desc' },
  having: { avg_rating: { gt: 4 } },
  page: 1,
  limit: 10,
});
```

Returns:

```jsonc
{
  "data": [
    { "industry": "Fintech",   "avg_rating": 4.8 },
    { "industry": "SaaS",      "avg_rating": 4.6 },
    { "industry": "Retail",    "avg_rating": 4.3 }
  ]
}
```

### Bulk operations

Every single-document mutation has a `*Bulk` counterpart that accepts a `where` clause and applies the same change to every match — in **one request**. They exist to kill the N+1 anti-pattern of "list documents, then loop and mutate each one" — that pattern wastes round-trips, burns your rate limit budget, and races against concurrent writers.

**Return value is always a number** — the count of rows affected. No document payloads come back.

Reach for bulk when you would otherwise write `for (const row of results) { ... await ... }`.

#### editBulk

**Apply the same partial update to every document matching a filter.**
Classic cases: "mark every inquiry older than 90 days as handled", "set `priority: 'low'` on every case study in the `legacy` category", "flag every testimonial with a rating under 3 for review". Three positional args: `(key, where, data)`.

```typescript
const count = await client.content.editBulk(
  'inquiries',
  { created_at: { lt: new Date('2024-01-01') } }, // where
  { handled: true },                              // data
);
```

Returns:

```
42
```

#### removeBulk

**Delete every document matching a filter in one call.**
Classic cases: clean up stale drafts from last year, wipe test data after E2E runs, garbage-collect resolved inquiries. Two positional args: `(key, where)`. The `where` is passed **directly** — not wrapped in `{ where: ... }`.

```typescript
const count = await client.content.removeBulk('inquiries', {
  handled: true,
  updated_at: { lt: new Date('2023-01-01') },
});
```

#### publishBulk / unpublishBulk

**Flip many documents from draft → published (or back) with a single call.**
Classic cases: publish every draft scheduled for today, unpublish everything from a category that got deprecated, stage-release content by industry at a launch moment.

```typescript
const published = await client.content.publishBulk('case_studies', {
  _status: 'draft',
  scheduled_at: { lte: new Date() },
});

const unpublished = await client.content.unpublishBulk('case_studies', { category: 'deprecated' });
```

#### incrementBulk

**Atomically bump counters on every match.** Same atomicity guarantees as `increment`, just multiplied — the server applies all row-level updates in one pass. Three positional args: `(key, where, data)`.

Classic cases: reset `daily_views` to 0 at midnight (pass a negative value equal to the current count), bump a shared counter for every document in a featured bucket, grant bonus points to all records in a cohort.

```typescript
const count = await client.content.incrementBulk(
  'case_studies',
  { category: 'featured' }, // where
  { views: 1 },             // data
);
```

---

## 🔐 Membership

Membership lets your end-users sign in, sign up, and manage their account against a Levo workspace. The tricky bit — especially for a first-time reader — is that the SDK runs on **your server**, talks to Levo from **your server**, and gets back **cookies meant for the end-user's browser**. Here's how that actually flows.

### How auth cookies flow

```
  Browser                    Your Server                   Levo API
     │                              │                          │
     │ POST /api/login              │                          │
     │ { email, password }          │                          │
     ├─────────────────────────────▶│                          │
     │                              │ signInWithPassword(...)  │
     │                              ├─────────────────────────▶│
     │                              │                          │
     │                              │   { data, cookies[],     │
     │                              │     token, headers }     │
     │                              │◀─────────────────────────┤
     │                              │                          │
     │   Set-Cookie: tk_m_at=...    │                          │
     │   (scoped to your domain)    │                          │
     │◀─────────────────────────────┤                          │
     │                              │                          │
     │  (browser stores tk_m_at)    │                          │
     │                              │                          │
     │ GET /api/me                  │                          │
     │ Cookie: tk_m_at=...          │                          │
     ├─────────────────────────────▶│                          │
     │                              │ getMe(req.cookies)       │
     │                              │ (SDK reads tk_m_at,      │
     │                              │  sends Authorization:    │
     │                              │  Bearer tk_m_at)         │
     │                              ├─────────────────────────▶│
     │                              │◀── user profile ─────────┤
     │                              │                          │
     │◀── { email, name, ... } ─────┤                          │
```

Three things to take away:

1. **The SDK never talks directly to the browser.** Your server is always in the middle. Browsers do not, and should not, ship API keys.
2. **Cookies come back inside the `cookies` array** on every auth response. They are standard `Set-Cookie` header strings — you forward them verbatim (see [Forwarding cookies in your framework](#forwarding-cookies-in-your-framework)).
3. **On subsequent requests**, the browser sends the cookie to your server. The SDK's `getMe` / `updateMe` / `changePassword` accept either a **raw access token string** or your framework's cookies object — it looks for the `tk_m_at` key.

> **`domain` is required** for all Membership calls. Set it once in `createClient`, or pass it per-call via `options.domain`. See [The `domain` option](#the-domain-option) — the silent failure mode is nasty.

### signInWithPassword

**Sign in an existing user.** Accepts either `email` or `username`. Returns **four things**: `{ data, cookies, headers, token }` — the user profile, the cookie strings to forward, the raw headers (rarely needed), and a `token` object containing the raw access token if you want to store it server-side instead of relying on the cookies.

```typescript
const { data: user, cookies, token } = await client.membership.signInWithPassword({
  email: 'jane@acme.test',
  password: 'password',
});

// Forward cookies to the browser so it can authenticate subsequent requests
for (const cookie of cookies) response.append('Set-Cookie', cookie);

console.log(token.access_token); // the raw bearer token, if you want to store it server-side
```

Override the cookie domain per-call if you serve multiple sites from the same workspace:

```typescript
await client.membership.signInWithPassword(
  { username: 'jane', password: 'password' },
  { domain: 'acme.example.com' },
);
```

### signupWithPassword

**Create a new account** with an email (or username) and password. Returns the same `{ data, cookies, headers, token }` shape as sign-in — the user is signed in immediately on success.

```typescript
const { data: user, cookies, token } = await client.membership.signupWithPassword({
  email: 'jane@acme.test',
  password: 'password',
});

for (const cookie of cookies) response.append('Set-Cookie', cookie);
```

### signOut

**End the current session.** Returns **expired** `Set-Cookie` strings — forward them so the browser deletes its session cookies. Pass either the raw token or the incoming cookies object.

```typescript
const { data: ok, cookies } = await client.membership.signOut(request.cookies);

for (const cookie of cookies) response.append('Set-Cookie', cookie);
```

### getMe

**Fetch the currently signed-in user.** Accepts either a raw access token **or** your framework's incoming cookies object — whichever is easier at the call site. When you pass a cookies object, the SDK reads the `tk_m_at` key.

```typescript
// from a stored token
const me = await client.membership.getMe(savedToken);

// from the incoming request cookies (Express / Next.js / Hono — all work)
const me = await client.membership.getMe(request.cookies);
```

### updateMe

**Partial update on the current user's profile.** Only the fields you pass change. Takes the same token-or-cookies auth shape as `getMe`.

```typescript
await client.membership.updateMe(request.cookies, {
  first_name: 'Jane',
  last_name: 'Doe',
});
```

### changePassword

**Change the current user's password.** Invalidates the existing session on the server — the caller should refresh credentials and forward new cookies after.

```typescript
await client.membership.changePassword(request.cookies, {
  password: 'new-password',
});
```

### requestOtp / signInWithOtp

**Two-step passwordless flow:** send a code, then verify it. Good for email/phone logins without passwords — one less thing for users to remember, and no password reset flow to maintain.

```typescript
await client.membership.requestOtp({
  content: 'jane@acme.test',
  medium: 'email',
});

const { data: user, cookies, token } = await client.membership.signInWithOtp({
  content: 'jane@acme.test',
  code: '123456',
});

for (const cookie of cookies) response.append('Set-Cookie', cookie);
```

### requestMagicLink

**Email the user a single-use signed URL that logs them in when clicked.** The link lands on your `redirect_uri`. Lower-friction than OTP for desktop users — one click in their inbox and they're in.

```typescript
await client.membership.requestMagicLink({
  content: 'jane@acme.test',
  medium: 'email',
  redirect_uri: 'https://example.com/callback',
});
```

> The `redirect_uri` must be on the same `domain` configured on the client, or on a domain fronted by Levo's proxy layer. Any other host is rejected by the server.

### getOAuthURLs

**Get provider-specific start URLs for social login** (Google, Microsoft, and whichever else your workspace has configured). Each URL kicks off the OAuth dance and lands back on your `redirect_uri`.

```typescript
const urls = await client.membership.getOAuthURLs('https://example.com/callback');
```

Returns:

```jsonc
{
  "google":    "https://example.com/.levo/public/api/v1/membership/auth/oauth/callback-from/google?redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&workspace_id=WABC1234",
  "microsoft": "https://example.com/.levo/public/api/v1/membership/auth/oauth/callback-from/microsoft?..."
}
```

### The OAuth indirection

Notice that `urls.google` starts with **your own domain** (`https://example.com/.levo/...`), not Google's. That's not a bug — it's how Levo avoids leaking your workspace secrets to the browser and how it sets the session cookie on the right domain at the end.

```
 Browser         Your Server         Levo (proxy on your domain)        Google
    │                  │                         │                         │
    │ click "Sign in"  │                         │                         │
    ├─────────────────▶│ getOAuthURLs(cb)        │                         │
    │                  ├────────────────────────▶│                         │
    │                  │◀── { google: ..., ... } ┤                         │
    │◀── 302 urls.google                         │                         │
    │                                            │                         │
    │  urls.google is on YOUR domain —           │                         │
    │  the /.levo/... path is a reverse proxy    │                         │
    │  that Levo serves through your site        │                         │
    │                                            │                         │
    │  GET urls.google ──────────────────────────▶                         │
    │◀────────── 302 to Google's consent screen ─┤                         │
    │                                                                      │
    │  user approves on accounts.google.com                                │
    ├──────────────────────────────────────────────────────────────────────▶│
    │                                                                      │
    │◀──── 302 back to levo proxy w/ ?code=xyz ───────────────────────────┤
    │                                                                      │
    │  GET /.levo/.../google?code=xyz ──────────▶                          │
    │                                            │                         │
    │                                            │── exchange code ───────▶│
    │                                            │◀── user profile ────────┤
    │                                            │                         │
    │                                            │  create/sign-in         │
    │                                            │  the account            │
    │                                            │                         │
    │◀── 302 to your redirect_uri ───────────────┤                         │
    │    Set-Cookie: tk_m_at=...                 │                         │
    │    (scoped to your domain, because         │                         │
    │     the response came from your domain)    │                         │
    │                                                                      │
    │  lands on /callback, signed in                                       │
```

**Why it's done this way**, in two lines:

1. Google's OAuth response can only set cookies for the host it redirected to. Levo's reverse proxy has to live on your domain so the `Set-Cookie` lands where your browser will actually send it back.
2. Levo never exposes your OAuth client secrets to the browser — the code exchange happens entirely server-side inside Levo's proxy.

You don't have to configure the reverse proxy yourself if your site is on Levo — it's mounted automatically. If you host the site elsewhere, you must point `/.levo/*` at Levo's ingress.

### Forwarding cookies in your framework

The SDK returns cookies as `string[]` — already formatted as standard `Set-Cookie` header values. Every HTTP framework has its own way to append them to the outgoing response. Five of the most common:

<details>
<summary><b>Express</b></summary>

```typescript
import express from 'express';
const app = express();

app.post('/api/login', async (req, res) => {
  const { cookies } = await client.membership.signInWithPassword(req.body);
  for (const cookie of cookies) res.append('Set-Cookie', cookie);
  res.json({ ok: true });
});
```

</details>

<details>
<summary><b>Next.js (App Router)</b></summary>

`cookies()` from `next/headers` doesn't accept raw `Set-Cookie` strings directly — you have to attach them to a `NextResponse` instead.

```typescript
import { NextResponse } from 'next/server';

export async function POST(req: Request) {
  const body = await req.json();
  const { data, cookies } = await client.membership.signInWithPassword(body);

  const res = NextResponse.json({ ok: true, user: data });
  for (const cookie of cookies) res.headers.append('Set-Cookie', cookie);
  return res;
}
```

To read cookies on incoming requests, pass `req.cookies` directly to `getMe` / `updateMe` / `changePassword` — the SDK handles the rest.

</details>

<details>
<summary><b>Hono</b></summary>

```typescript
import { Hono } from 'hono';
const app = new Hono();

app.post('/api/login', async (c) => {
  const body = await c.req.json();
  const { data, cookies } = await client.membership.signInWithPassword(body);

  for (const cookie of cookies) c.header('Set-Cookie', cookie, { append: true });
  return c.json({ ok: true, user: data });
});
```

</details>

<details>
<summary><b>Fastify</b></summary>

```typescript
import Fastify from 'fastify';
const app = Fastify();

app.post('/api/login', async (req, reply) => {
  const { data, cookies } = await client.membership.signInWithPassword(req.body);

  const existing = reply.getHeader('Set-Cookie');
  const merged = existing ? [...[].concat(existing), ...cookies] : cookies;
  reply.header('Set-Cookie', merged);

  return { ok: true, user: data };
});
```

</details>

<details>
<summary><b>Node's raw <code>http</code> module</b></summary>

```typescript
import { createServer } from 'node:http';

createServer(async (req, res) => {
  if (req.method === 'POST' && req.url === '/api/login') {
    const body = await readJson(req);
    const { cookies } = await client.membership.signInWithPassword(body);

    res.setHeader('Set-Cookie', cookies); // node http takes an array directly
    res.setHeader('Content-Type', 'application/json');
    res.end(JSON.stringify({ ok: true }));
  }
}).listen(3000);
```

</details>

### The `domain` option

The `domain` option on `createClient` (and the per-call `options.domain` override) is the **host your end-user's browser is visiting** — the site where membership cookies need to land. Levo uses this value to scope the `Set-Cookie` `Domain=` attribute on every auth response.

**Format.** Bare hostname works, full origin also works — Levo's server is lenient. Both of these are equivalent:

```typescript
domain: 'example.com'         // canonical, shortest
domain: 'https://example.com' // also accepted
```

Bare hostname is what we recommend — it's what ends up in the cookie `Domain=` attribute anyway.

**Local development.** For dev, set it to the host your frontend actually runs on, including the port. The common pattern:

```typescript
domain: 'acme.localhost:3000' // <your-site-slug>.localhost:<your-frontend-port>
```

Requirements for this to work:

- You've registered a site with domain `acme.localhost:3000` (or similar) inside your Levo workspace. The domain lookup at the server is an exact-match check against your workspace's configured sites.
- Your frontend is actually served at that host. Modern browsers (Chrome 104+, Safari 15+) resolve `*.localhost` → `127.0.0.1` automatically. Firefox still wants a `/etc/hosts` entry (`127.0.0.1 acme.localhost`) unless `network.dns.native-is-localhost` is enabled.

> **Silent failure if you forget to set `domain`.** The SDK won't throw. The auth endpoint still succeeds, tokens come back, you forward the cookies to the browser — but because the `Origin` header was empty, the server set the cookie's `Domain=` attribute to Levo's own API host. The browser stores the cookie scoped to `public-api.levo.so`, not your site. The next request from the browser doesn't include the cookie, and the user appears signed-out. There is no error in your logs. **Always configure `domain` for Membership flows, in every environment.**

---

## 📬 Notifications

### sendEmail

**Send a transactional email** — either your own raw HTML, or a rich-text body wrapped in Levo's email template. Returns `true` on success.

Plain HTML:

```typescript
await client.notification.sendEmail({
  to: ['jane@acme.test'],
  subject: 'Welcome to Acme',
  content: '<p>Thanks for joining — we will reply within a day.</p>',
});
```

Rich text wrapped in Levo's template, with sender customization:

```typescript
await client.notification.sendEmail({
  to: ['jane@acme.test'],
  subject: 'Welcome to Acme',
  content: '<p>Thanks for joining — we will reply within a day.</p>',
  key: 'richtext',
  from_name: 'Acme Support',
  cc: ['sales@acme.test'],
  bcc: ['audit@acme.test'],
  reply_to: 'support@acme.test',
});
```

**Limits that only apply to `sendEmail`** (on top of the [global rate limits](#rate-limits-and-quotas)):

| Limit | Value |
| --- | --- |
| Subject length | **255 characters** — silently truncated beyond that |
| Recipient count (`to[]`) | No hard cap — keep it reasonable |
| Monthly email volume | No hard cap on current plan tiers |

> **Subject truncation is silent.** If you send a 500-character subject, the inbox shows 255. Catch it in your copy, not in QA.

---

## 🚦 Rate limits and quotas

Everything the SDK can hit goes through the same public API, so the limits here apply **globally** — not per endpoint, not per module. A busy `findMany` loop counts against the same budget as an email blast or a transaction commit.

### Request rate

There is a **relaxed soft rate limit per client** — enough for typical interactive workloads (web apps, dashboards, form handlers) without any tuning. Every SDK call counts toward it: `findMany`, `findUnique`, `create`, `edit`, `sendEmail`, `publish`, bulk operations, `with_transaction` start/commit — everything.

If you're building a background job, bulk import, or scheduled sync that you think will sustain more than a few requests per second, **email [support@levo.so](mailto:support@levo.so) with a rough request-per-minute estimate.** Rate lifts are routine for legitimate use cases — we just want a heads-up so we can raise the ceiling on your API key specifically.

### Request body size

Keep individual requests **under 1 MB**. Larger payloads are rejected at the edge with a `LevoError`. This matters most on:

- `create` / `edit` with large rich-text or JSON blobs
- `editBulk` with a large `data` patch applied to many rows
- `sendEmail` with large inline HTML content

If you need to ship more than 1 MB in one logical operation, split it — the SDK has no multi-part or streaming upload today.

### Authentication

Every call requires an API key, passed once to `createClient({ key })`. The SDK attaches it to every outbound request — you never have to touch auth headers yourself.

If the key is missing, revoked, or belongs to a suspended workspace, you'll get a `LevoError` at the first call with code `common.request.UNAUTHORIZED` — not a silent 401.

### Handling 429s

**The SDK does not currently retry or queue.** When you hit the rate limit, the server returns a `LevoError` with code `common.request.TOO_MANY_REQUESTS` that bubbles up to your code. If you're building something bursty, wrap your calls yourself:

```typescript
import { LevoError } from '@levo-so/client';

async function withBackoff<T>(fn: () => Promise<T>, tries = 4): Promise<T> {
  for (let attempt = 1; attempt <= tries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      const isRateLimited =
        error instanceof LevoError &&
        error.details.code === 'common.request.TOO_MANY_REQUESTS';
      if (!isRateLimited || attempt === tries) throw error;

      // Exponential backoff with jitter: 0.5s, 1s, 2s, 4s (+ up to 250ms jitter)
      const delay = 2 ** (attempt - 1) * 500 + Math.random() * 250;
      await new Promise((r) => setTimeout(r, delay));
    }
  }
  throw new Error('unreachable');
}

// Usage
await withBackoff(() => client.content.create('case_studies', data));
```

Client-side retry with jittered backoff, `Retry-After` support, and an opt-in request queue are on the roadmap for a future minor release. When they ship, the wrapper above becomes redundant.

### Request body size errors

If you exceed the 1 MB body cap, the server returns a `LevoError` with code `common.request.BAD_REQUEST`. Catch it the same way you catch any other error, and split your payload:

```typescript
try {
  await client.content.create('case_studies', { body: hugeHtml });
} catch (error) {
  if (error instanceof LevoError && error.details.code === 'common.request.BAD_REQUEST') {
    // split the payload or stream it to object storage first, then link from the content
  }
  throw error;
}
```

See the [full error code reference](#common-error-codes) at the end of this document for every code you might encounter.

---

## ⚛️ Transactions

`with_transaction` runs a sequence of Content operations atomically. If the callback throws, every mutation inside is rolled back. If it returns, everything commits.

**Every inner call must receive the `{ transaction }` option** — calls without it hit the live database and are invisible to the transaction. This is the #1 source of transaction bugs.

```typescript
const published = await client.with_transaction(async (transaction) => {
  const draft = await client.content.findUnique('case_studies', '7302960573923055239', { transaction });
  if (!draft) throw new Error('draft not found');

  const copy = await client.content.create(
    'case_studies',
    { title: draft.title, body: draft.body },
    { transaction },
  );

  const ok = await client.content.publish('case_studies', copy._id, { transaction });

  // Inside the transaction: the new doc is visible
  const inside = await client.content.findUnique('case_studies', copy._id, { transaction });
  console.log(inside?._status); // 'published'

  // Outside the transaction: the doc does not exist until commit
  const outside = await client.content.findUnique('case_studies', copy._id);
  console.log(outside); // null

  return ok;
});
```

**Notes**

- Throwing from the callback rolls back and re-throws — wrap the `with_transaction` call in your own try/catch to handle failures.
- Transactions currently cover Content operations only. Membership and Notification calls run outside any active transaction.
- Transactions hold server-side state until commit or abort — keep the callback short, don't `await` on slow user input mid-transaction.
- **Server-side timeout is 5 minutes.** If your callback doesn't commit or abort within 5 minutes of starting, the transaction is force-aborted and any subsequent call with that `transaction` handle fails. Plan callbacks to finish well under that — most should be well under a second.

### The transaction lifecycle

```
  Your Code                                                     Levo API
     │
     │ await client.with_transaction(async (txn) => {
     │
     │    POST /v1/bevy/content/start-transaction ─────────────▶
     │    ◀────────── { identifier, affinity-cookie } ──────────
     │
     │    findUnique('case_studies', id, { transaction: txn }) ─▶  read inside txn
     │    create('case_studies', data,  { transaction: txn }) ──▶  write staged
     │    increment('stats', id, {...}, { transaction: txn }) ──▶  write staged
     │
     │    (every call carries the affinity cookie — pins
     │     every request in this transaction to the same
     │     backend instance that started it)
     │
     │    return ok;
     │ });
     │
     │                                                      ┌──────────────┐
     │ on callback return ──▶ PUT /commit-transaction ──────▶│ COMMIT       │
     │ ◀────────── ok ──────────────────────────────────────│ all staged   │
     │                                                      │ writes apply │
     │                                                      └──────────────┘
     │
     │                                                      ┌──────────────┐
     │ on callback throw ───▶ PUT /abort-transaction ───────▶│ ROLLBACK     │
     │ ◀────────── ok ──────────────────────────────────────│ discard all  │
     │ (and re-throws the original error)                   │ staged ops   │
     │                                                      └──────────────┘
```

Two subtleties worth knowing:

1. **Affinity cookie.** Transactions are stateful on the server — the same backend instance must handle every request in the transaction. The first call returns an affinity cookie; the SDK attaches it to every subsequent call inside the callback automatically. This is why you can't "smuggle" a `transaction` object from one client instance into another.
2. **Abort is always attempted.** If your callback throws, the SDK calls `/abort-transaction` before re-throwing. If the abort itself fails (network blip), the transaction times out on the server and rolls back anyway — but you should still make sure your error handling surfaces the original exception, not the abort failure.

---

## 🧩 Putting it together

Two end-to-end examples that exercise the most common paths.

### Example 1 — Contact form with dedupe + welcome email

A public contact form on your marketing site submits to `POST /api/inquiry`. Dedupe against existing inquiries, create if new, bump a campaign conversion counter, and fire a welcome email.

> **Prerequisite:** this example assumes a `campaign_stats` collection in your workspace with one row per campaign source, keyed by a field like `slug` that matches your form's `source` values (`homepage`, `pricing`, `docs`, etc.). Pre-create those rows once — `increment` does not create on miss.

```typescript
import { createClient, LevoError } from '@levo-so/client';

const client = createClient({
  key: process.env.LEVO_API_KEY!,
  workspace: process.env.LEVO_WORKSPACE!,
});

// POST /api/inquiry — handle a contact form submission
export async function handleInquiry(input: { email: string; name: string; source: string }) {
  // 1. Dedupe: don't create a second inquiry for the same email
  const existing = await client.content.findFirst('inquiries', {
    where: { email: input.email },
  });
  if (existing) return { ok: true, inquiry_id: existing._id, deduped: true };

  // 2. Create the inquiry
  const inquiry = await client.content.create('inquiries', {
    email: input.email,
    name: input.name,
    source: input.source,
  });

  // 3. Track conversion against the campaign this came from
  await client.content.increment('campaign_stats', input.source, { inquiries: 1 });

  // 4. Fire-and-forget welcome email
  try {
    await client.notification.sendEmail({
      to: [input.email],
      subject: `Welcome to Acme, ${input.name}`,
      content: `<p>Thanks for getting in touch — we'll reply within a day.</p>`,
      key: 'richtext',
      from_name: 'Acme Team',
      reply_to: 'support@acme.test',
    });
  } catch (error) {
    // Don't fail the submission if email hiccups — log and move on
    if (error instanceof LevoError) {
      console.error('welcome email failed', error.details.code);
    } else {
      throw error;
    }
  }

  return { ok: true, inquiry_id: inquiry._id, deduped: false };
}
```

### Example 2 — Same flow, atomic

That first example has a subtle bug: if the `create` succeeds but the `increment` or the email fails, the inquiry exists in your database without a bumped counter and without a welcome. For some products that's fine. For others — especially regulated ones, or ones where the counter drives billing — you want all-or-nothing.

`with_transaction` makes that explicit:

```typescript
export async function handleInquiryAtomic(input: { email: string; name: string; source: string }) {
  return client.with_transaction(async (transaction) => {
    // Dedupe — best effort. For strict uniqueness across concurrent writers, add a
    // unique index on `email` in the inquiries collection schema. The transaction
    // here ensures that if the create ever fails (duplicate key, validation, network),
    // the increment below rolls back with it — you don't end up with orphan counters.
    const existing = await client.content.findFirst(
      'inquiries',
      { where: { email: input.email } },
      { transaction },
    );
    if (existing) return { ok: true, inquiry_id: existing._id, deduped: true };

    // Stage the inquiry
    const inquiry = await client.content.create(
      'inquiries',
      { email: input.email, name: input.name, source: input.source },
      { transaction },
    );

    // Stage the counter bump — only commits if the create above also commits
    await client.content.increment(
      'campaign_stats',
      input.source,
      { inquiries: 1 },
      { transaction },
    );

    // Return the result; the email is sent AFTER the commit because emails
    // can't be rolled back.
    return { ok: true, inquiry_id: inquiry._id, deduped: false };
  }).then(async (result) => {
    if (!result.deduped) {
      await client.notification.sendEmail({
        to: [input.email],
        subject: `Welcome to Acme, ${input.name}`,
        content: `<p>Thanks for getting in touch — we'll reply within a day.</p>`,
        key: 'richtext',
      });
    }
    return result;
  });
}
```

Two principles worth internalizing from this example:

1. **Only database-like work belongs in a transaction.** Emails, webhooks, Stripe charges, Slack messages — none of those can be rolled back. Stage the database work first, commit, then fire the side effects.
2. **Move side effects to the `.then()`.** If the transaction aborts, the side effects never run. That's the goal.

---

## 🏷️ Common error codes

Every `LevoError` you catch has a `details.code` string you can switch on. These are the ones you're most likely to see while integrating — verified against the current server source. Not exhaustive; the error namespace is organized as `<domain>.<resource>.<CONSTANT>`, so related errors live near each other.

**Global / request layer**

| Code | When it fires |
| --- | --- |
| `common.request.BAD_REQUEST` | Malformed request — invalid JSON body, bad query params, payload over the 1 MB cap |
| `common.request.UNAUTHORIZED` | API key missing, invalid, revoked, or belongs to a suspended workspace |
| `common.request.FORBIDDEN` | Authenticated but not allowed to perform this action on this resource |
| `common.request.NOT_FOUND` | The route itself doesn't exist (typo in path, wrong API version) |
| `common.request.TOO_MANY_REQUESTS` | Rate limit hit — back off and retry, or email support for a lift |

**Content (`bevy`)**

| Code | When it fires |
| --- | --- |
| `bevy.content.NOT_FOUND` | Mutation (`edit`, `remove`, `publish`, `unpublish`, `increment`) against a non-existent `_id` |
| `bevy.content.INVALID_INPUT` | Payload fails collection schema validation — missing required fields, wrong types, unknown keys |
| `bevy.content.TRANSACTION_NOT_FOUND` | `with_transaction` inner call with an expired or unknown transaction handle (most often: 5-minute timeout, or forwarded across client instances) |

**Membership (`membership`)**

| Code | When it fires |
| --- | --- |
| `membership.auth.INVALID_CREDENTIALS` | Wrong email/username/password combo on `signInWithPassword` |
| `membership.auth.NOT_SIGNED_IN` | `getMe` / `updateMe` / `changePassword` called with a missing or invalid token |
| `membership.otp.INVALID_OTP` | Wrong OTP code on `signInWithOtp` |
| `membership.otp.OTP_EXPIRED` | OTP code used past its validity window |
| `membership.otp.TOO_MANY_REQUESTS` | User requested OTPs faster than the rate limit allows |

**Typical handling pattern**

```typescript
try {
  await client.content.edit('case_studies', id, { title });
} catch (error) {
  if (!(error instanceof LevoError)) throw error;

  switch (error.details.code) {
    case 'bevy.content.NOT_FOUND':
      return { ok: false, reason: 'gone' };
    case 'bevy.content.INVALID_INPUT':
      return { ok: false, reason: 'invalid', details: error.details.description };
    case 'common.request.TOO_MANY_REQUESTS':
      // back off and retry — see Rate limits and quotas above
      throw error;
    default:
      // unknown LevoError — log and bubble up
      throw error;
  }
}
```

---

## 📡 Telemetry

**This SDK collects no telemetry.** No usage metrics, no error reports, no analytics, no identifying information — nothing phones home. The source is MIT; you can audit it yourself. The only outbound network traffic the SDK ever makes is:

1. **API calls you make** to `public-api.levo.so` (or your `endpoint` override).
2. **A one-time npm registry lookup** from the `generate-types` CLI — it checks whether a newer version of `@levo-so/client` exists on npm. It sends only the package name. No identifying data.

If any of this ever changes, it will be a major version bump, loudly announced in the release notes.

---

## 💬 Support

Need help? Two places to get it:

- **Chat with us** through the [Levo dashboard](https://app.levo.so) — the chat widget in the bottom-right is the fastest way to reach an engineer.
- **Email** [support@levo.so](mailto:support@levo.so) — for anything you'd rather not type into a chat window, or for escalations.

---

## 📄 License

MIT — see [LICENSE](./LICENSE).
