Skip to content
Developer

Client SDKs

Official backlex clients for eleven languages — one API, one wire format, idiomatic in each.

The TypeScript SDK (@backlex/client) is the reference client. Alongside it, backlex ships native clients for ten more languages, so an app in (almost) any stack talks to backlex the same way.

Every client wraps the same REST + SSE surface — CRUD, a fluent query builder, auth (server key / workspace app-mode token capture / cookie session), realtime (SSE), storage, and a uniform error type. Nothing language-specific ever hits the wire.

Languages

LanguagePackage dirTransportRuntime deps
TypeScriptpackages/clientfetch + EventSourcenone
Pythonsdks/pythonhttpxhttpx
Gosdks/gonet/httpnone (stdlib)
Rustsdks/rustureq (pluggable)serde_json, ureq
Javasdks/javajava.net.httpJackson
Kotlinsdks/kotlinjava.net.httpJackson
Swiftsdks/swiftURLSessionnone (Foundation)
Dart / Fluttersdks/dartdart:io HttpClientnone (SDK)
C# / .NETsdks/dotnetHttpClientnone (BCL)
Rubysdks/rubynet/httpnone (stdlib)
PHPsdks/phpcurlnone (ext-curl)

All clients are Apache-2.0, verified with offline contract + HTTP-layer tests, and published (0.0.1) — all ten: Python (PyPI), .NET (NuGet), Ruby (RubyGems), Dart (pub.dev), Rust (crates.io), Go (backlex-go), Swift (backlex-swift), PHP (Packagist), Java + Kotlin (Maven Central, com.backlex:backlex / com.backlex:backlex-kotlin). The release runbook lives in sdks/PUBLISHING.md.

Client and server usage

backlex ships one SDK per language — not a client/server split. The same client works in a browser or mobile app (end-user scoped) and on a trusted server (key scoped); which one you’re in is decided by how you authenticate, and the permission DSL enforces the boundary server-side either way.

There are three auth modes:

  1. Server-to-server (trusted backend) — pass a static API key (pak_…). It’s sent as a bearer on every call, and the call is bound by that key’s granted permissions plus its per-key guards (tool allow-lists, a read-only flag). Use it from your backend, CI, or scripts — never ship a pak_ key in a browser/mobile bundle.
  2. App mode (end-user client) — pass a workspace slug. auth.* then targets that workspace’s own end-user auth pool, and the session token returned by sign-in/up is captured and replayed as a bearer. This is the browser/mobile path: each user acts as themselves, constrained by their role’s permissions. Persist the token (auth.getToken() / auth.token) and restore it next launch.
  3. Cookie session (same-origin browser) — omit both; the client rides the cookie session (the admin SPA path).
// server — elevated, bound by the key's permissions + guards
const api = createClient({ url, apiKey: process.env.BACKLEX_API_KEY });
// browser / mobile app — end-user scoped
const app = createClient({ url, workspace: "myapp" });
await app.auth.signIn({ email, password }); // token captured + replayed
// same-origin browser (admin SPA) — cookie session
const web = createClient({ url });

The option names are idiomatic per language (apiKey / api_key, workspace, token) — see each SDK’s README.

Why there’s no separate “admin SDK”: the permission DSL and per-key guards run on the server, so the same client is safe in both contexts — a pak_ key with a read-only flag and a tool allow-list is the guard, not a separate package. Privileged management operations (schema, users, roles) are reachable today via the raw request() escape hatch; a typed admin module is on the roadmap.

Anonymous & public reads

A public collection (one whose built-in public role has a read permission) needs no credential at all — construct the client with neither apiKey nor a token. On a multi-tenant deployment, pass the tenant option so the server knows which workspace to read; the client sends it as the X-Backlex-Tenant header. (A single-tenant self-host resolves the default tenant automatically, so the option is optional there.)

const pub = createClient({ url, tenant: "myapp" }); // anonymous, tenant-scoped
const { data } = await pub.from("posts").list({ filter: { published: { _eq: true } } });

The tenant option exists in every SDK (tenant / Tenant / WithTenant / .tenant(...)); the server ignores it for app-mode bearer sessions, since their tenant comes from the session.

Query extras & aggregates

The query builder also exposes the rest of the list API: expand (inline single-hop relations), locale (collapse i18n_text fields to one locale, or "*" for the full map), and search (free-text across readable text fields). And every collection has aggregate — a single-function count/sum/avg/min/max over one column, optionally grouped:

const top = await client.from("orders").query()
.expand("customer").locale("en").search("urgent").list();
const byStatus = await client.from("orders")
.aggregate({ agg: "sum", field: "total", groupBy: "status" });
// → { data: [ { label: "paid", value: 9300 }, { label: "pending", value: 410 } ] }

expand and locale also work when reading a single item — one(id) takes an optional query so you can inline a relation or pick a locale without dropping to the list endpoint:

const order = await client.from("orders").one("ord_42", { expand: "customer", locale: "en" });

Password reset & token refresh

Beyond sign-in/up, every SDK’s auth exposes:

  • requestPasswordReset(email, redirectTo?) — send the reset email.
  • resetPassword(newPassword, token) — complete it with the token from the email.
  • refresh() (app mode) — mint a fresh short-lived access JWT from the stored session token. The SDK’s own requests keep using the session token; reach for refresh() when a downstream service needs a proper access token. (Sessions themselves last the workspace’s session_lifetime; once that elapses the user re-authenticates.)

Passwordless: email one-time codes

When the workspace enables the email-otp provider, sign-in is a two-step flow — mail a code, then exchange it for a session. In app mode the returned token is captured and replayed exactly like a password sign-in:

await client.auth.sendVerificationOTP({ email: "user@acme.com" }); // type defaults to "sign-in"
// …user reads the code from their inbox…
const { user } = await client.auth.signInEmailOTP({ email: "user@acme.com", otp: "123456" });

sendVerificationOTP also drives the "email-verification" and "forget-password" flows via its type argument. (For the click-a-link variant, use signInMagicLink.)

Managing active sessions

Every auth exposes the session list and three revoke verbs — enough to build a “signed-in devices” screen with a remote sign-out button:

const sessions = await client.auth.listSessions(); // one row per device/login
await client.auth.revokeSession({ token: sessions[0].token }); // kill one
await client.auth.revokeOtherSessions(); // sign out everywhere else
await client.auth.revokeSessions(); // sign out everywhere (incl. here)

Publishing versioned items

Collections with draft/publish versioning expose two helpers on every collection handle. They map to the same POST /api/items/<slug>/<id>/publish endpoint — unpublish just flips it back with ?unpublish=1:

await client.from("posts").publish("post-id"); // draft → published
await client.from("posts").unpublish("post-id"); // published → draft

One wire format, eleven languages

The query builder in every SDK compiles to the identical canonical JSON Condition grammar the server already speaks — $and / $or / $not maps, leaf { "field": { "_op": value } } entries, dotted relation paths, and $now relative dates. The server required zero changes to support all of them.

The same fluent query, in a few languages:

# Python
client.from_("orders").query() \
.where(lambda f: f.and_(
f.eq("status", "active"),
f.gte("total", 100),
f.rel("customer", lambda c: c.eq("tier", "gold")),
)) \
.order_by("-placed_at").limit(50).list()
// Go
backlex.From[Order](client, "orders").Query().
Where(backlex.And(
backlex.Eq("status", "active"),
backlex.Gte("total", 100),
backlex.Rel("customer", backlex.Eq("tier", "gold")),
)).
OrderBy("-placed_at").Limit(50).List()
// Swift
try await client.from("orders", as: Order.self).query()
.where(Filter.and(
Filter.eq("status", "active"),
Filter.gte("total", 100),
Filter.rel("customer", Filter.eq("tier", "gold"))
))
.orderBy("-placed_at", "id").limit(50).list()
// Rust
client.from("orders").query()
.filter(f::and(vec![
f::eq("status", json!("active")),
f::gte("total", json!(100)),
f::rel("customer", vec![f::eq("tier", json!("gold"))]),
]))
.order_by(&["-placed_at"]).limit(50).list()?;

All four serialize to byte-identical JSON:

{"$and":[{"status":{"_eq":"active"}},{"total":{"_gte":100}},{"customer.tier":{"_eq":"gold"}}]}

Idiomatic, not generated

Each SDK is a hand-written ergonomic layer — it feels native in its language, not like a generated stub:

  • ConstructioncreateClient(...) (TS) · Client.builder(...).build() (Kotlin, Rust) · BacklexClient.builder(...) (Java) · new Client(url, [...]) (PHP) · Client(url, apiKey:) (Swift, Dart, Python).
  • Reserved-word operatorsand/or/not/in become and_/or_/not_/in_ (Python, Ruby, PHP), And/Or (Go, C#), or the where-clause is spelled filter(...) where where is reserved (Rust).
  • Async vs blockingasync/await in TS, C#, Swift, Dart; blocking in Go, Java, Kotlin, Ruby, PHP, Rust. Realtime returns an unsubscribe handle backed by a background thread/task — except PHP, which has no threads and exposes a blocking subscribe.
  • Errors — every client raises/returns a typed error (BacklexError / BacklexException / Backlex::Error / *backlex.Error) carrying status, code, and details from the { "error": {...} } envelope.

Typed models (hybrid codegen)

The hand-written layer is small and stable. For typed models of the system API and your collections, generate them from the OpenAPI spec the server already ships (apps/web/src/server/lib/openapi-static.generated.json):

Terminal window
openapi-generator generate \
-i apps/web/src/server/lib/openapi-static.generated.json \
-g <python|go|rust|java|kotlin|swift5|dart|csharp|ruby|php> \
-o sdks/<lang>/generated

Generated models live beside (not inside) the hand-written package, so the ergonomic surface stays clean while models track the spec. (The TypeScript SDK pairs with backlex gen-types instead — see SDK & CLI.)

WebAssembly

There is no separate WASM SDK. The Rust client’s filter core (condition builders

  • normalization) is pure serde_json with no IO, so it compiles to wasm32-unknown-unknown as-is. Swap its pluggable Transport for a fetch-backed one to run the whole client in the browser or at the edge from a single Rust core.

Per-SDK docs

Each client has its own README with a quickstart, auth/realtime/storage examples, and a TS-parity table: Python · Go · Rust · Java · Kotlin · Swift · Dart · .NET · Ruby · PHP.