npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@levo-so/client

v0.0.27

Published

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

Readme


Table of Contents

Getting started

Content

Membership

Notifications

  • sendEmail — transactional email, plain HTML or rich-text template

Operational

Advanced

Reference


📋 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

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.
  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 .gitignored.

# .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:

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 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.

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

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:

// 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.

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 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

{ 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

{ 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

{ 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

{ 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

{ 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:

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

Geo — within

{
  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

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

Logical combinators — AND / OR

{ 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

{
  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

{
  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.

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

Returns:

{
  "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).

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.

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: ... }.

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.

const inquiry = await client.content.create('inquiries', {
  email: '[email protected]',
  name: 'Jane Doe',
  source: 'homepage',
});

Returns:

{
  "_id": "7302960573923055239",
  "email": "[email protected]",
  "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.

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.

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).

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.

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.

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:

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.

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:

{
  "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).

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: ... }.

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.

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.

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).
  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 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.

const { data: user, cookies, token } = await client.membership.signInWithPassword({
  email: '[email protected]',
  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:

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.

const { data: user, cookies, token } = await client.membership.signupWithPassword({
  email: '[email protected]',
  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.

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.

// 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.

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.

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.

await client.membership.requestOtp({
  content: '[email protected]',
  medium: 'email',
});

const { data: user, cookies, token } = await client.membership.signInWithOtp({
  content: '[email protected]',
  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.

await client.membership.requestMagicLink({
  content: '[email protected]',
  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.

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

Returns:

{
  "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:

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 });
});

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

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.

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 });
});
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 };
});
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);

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:

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:

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 *.localhost127.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:

await client.notification.sendEmail({
  to: ['[email protected]'],
  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:

await client.notification.sendEmail({
  to: ['[email protected]'],
  subject: 'Welcome to Acme',
  content: '<p>Thanks for joining — we will reply within a day.</p>',
  key: 'richtext',
  from_name: 'Acme Support',
  cc: ['[email protected]'],
  bcc: ['[email protected]'],
  reply_to: '[email protected]',
});

Limits that only apply to sendEmail (on top of the global rate limits):

| 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 [email protected] 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:

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:

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 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.

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.

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: '[email protected]',
    });
  } 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:

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

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 — the chat widget in the bottom-right is the fastest way to reach an engineer.
  • Email [email protected] — for anything you'd rather not type into a chat window, or for escalations.

📄 License

MIT — see LICENSE.