Skip to content
Developer

Client SDKs

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

The TypeScript SDK (backlex) 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.

That is the whole scope, deliberately. The admin namespaces the TypeScript client carries — flows, templates, payments, booking, signatures and the rest — are not ported: they are requireAdmin surfaces a server-side caller reaches over plain REST, and porting forty of them ten times would be forty times the drift. Reach them with an HTTP call and an API key.

Every client sends the same four request headers, and each is checked against all ten by apps/web/tests/sdk/sdk-header-parity.test.ts:

HeaderWhy it has to be everywhere
Authorizationthe key or session token
X-Backlex-Tenantexplicit tenant scoping for anonymous or cross-tenant reads
X-Backlex-Orgthe active organization, so $org.id in permission rules resolves
traceparentW3C trace context, so the call appears in the admin Traces panel

org and tracing are constructor options in every client (org / tracing, spelled the language’s way), and org is changeable at runtime because an app switches organization after sign-in. Tracing is on by default; turn it off with tracing: false (or the language’s equivalent) and no traceparent is sent.

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)

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.

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.

The query builder also exposes the rest of the list API: expand (inline single-hop relations), locale (collapse localized 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" });

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

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

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)

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

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"}}]}

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

  • Construction — createClient(...) (TS) · Client.builder(...).build() (Kotlin, Rust) · BacklexClient.builder(...) (Java) · new Client(url, [...]) (PHP) · Client(url, apiKey:) (Swift, Dart, Python).
  • Reserved-word operators — and/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 blocking — async/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.

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

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.

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.