Reference. Everything here works today.
Taken from the SDK and the CLI as they exist, with the gaps marked. If you would rather start from your framework, the quickstarts are shorter.
Get started
- Sign in at affineq.com/dashboard with email and password, Google or GitHub. Phone sign-in to the dashboard needs real SMS, which is not switched on yet.
- Create a project in your organisation. It is its own Postgres database with its own keys.
- Make a table in the Table editor, or run SQL in the SQL editor.
- Copy your publishable key from the project’s Settings → API keys. It starts
aq_pub_. - Read your table over plain HTTP. No SDK needed:
curl "https://api.affineq.com/rest/v1/todos?select=*" \ -H "apikey: aq_pub_..."
That is the whole setup. The key names the project, so there is no per-project URL to configure.
The JavaScript SDK
@affineq/js is MIT-licensed and works in Node 18+, every modern browser, Deno, Bun and edge runtimes, with no runtime dependencies. Install it from npm: npm install @affineq/js (and @affineq/ssr for server-rendered apps).
import { createClient } from "@affineq/js";
const affineq = createClient("aq_pub_...");
const { data, error } = await affineq.from("todos").select();
Every call resolves to { data, error, count, status } and never throws. An error carries a code, a message, and a resolution: what to do next.
Keys
- Publishable (
aq_pub_…): safe in a browser or a mobile app. It acts as the anonymous role, and row-level security decides what it can see. - Secret (
aq_sec_…): servers only. It bypasses row-level security. Never ship it to a client.
Data
Your project’s Postgres, as an API. The query builder speaks the PostgREST dialect.
await affineq.from("orders").select("id, total, customer(name)")
.eq("status", "paid")
.order("created_at", { ascending: false })
.limit(20);
await affineq.from("todos").insert({ title: "buy milk" });
await affineq.from("todos").update({ done: true }).eq("id", 1);
await affineq.from("todos").upsert({ id: 1, title: "milk" }, { onConflict: "id" });
await affineq.from("todos").delete().eq("id", 1);
await affineq.rpc("add", { x: 40, y: 2 }); // database functions
Writes return nothing unless you chain .select(): small responses by default, because bytes cost money on many of the links this runs over. Embedding follows foreign keys: select("*, customer(*)").
Row-level security is yours to write. Policies read the way you already know: TO authenticated USING (auth.uid() = user_id).
Authentication
Your app’s users, under affineq.auth. Phone first: a code by SMS, or a fixed test code while you build (Authentication → Providers → Test OTPs).
await affineq.auth.signInWithOtp({ phone: "+254712345678" });
const { data, error } = await affineq.auth.verifyOtp({
phone: "+254712345678", token: "123456", type: "sms",
});
Also email and password, magic links and anonymous users, each a switch in the dashboard:
await affineq.auth.signUp({ email, password, options: { data: { name: "Amina" } } });
await affineq.auth.signInWithPassword({ email, password });
await affineq.auth.signInWithOtp({ email }); // code and magic link
await affineq.auth.signInAnonymously();
await affineq.auth.resetPasswordForEmail(email); // reset
await affineq.auth.signOut(); // or { scope: "global" }
Social sign-in (Google, GitHub, Apple, Facebook) uses PKCE once the provider is on, with its client ID and secret entered under Authentication → Providers:
await affineq.auth.signInWithOAuth({
provider: "google",
options: { redirectTo: "https://app.example.com/welcome" },
});
Once signed in, every from() call carries the user’s token. The session persists and refreshes itself.
const { data: { session } } = await affineq.auth.getSession();
affineq.auth.onAuthStateChange((event, session) => { /* SIGNED_IN, SIGNED_OUT, TOKEN_REFRESHED, PASSWORD_RECOVERY */ });
Email codes and links depend on mail delivery being switched on for the environment. Password sign-in and test phone numbers do not.
Storage
Buckets and files under affineq.storage, decided by the same policies as your tables (on storage.objects).
await affineq.storage.from("avatars").upload(`${user.id}/photo.jpg`, file);
const { data: { publicUrl } } = affineq.storage.from("photos").getPublicUrl("cover.jpg");
const { data: signed } = await affineq.storage.from("receipts").createSignedUrl("2026/09/r-001.pdf", 3600);
await affineq.storage.from("avatars").list(user.id);
await affineq.storage.from("avatars").remove([`${user.id}/old.jpg`]);
Files over 5 MB upload in parts and resume: if the connection drops, the next attempt asks the server which parts arrived and sends the rest. Pass { resumable: true } to force it for smaller files, and onProgress to show a bar. You can also point a project at a bucket you already own.
Where files live
A project chooses where its files are stored under Storage → Settings, and that page says plainly “Your files are stored in” the provider and the place. Two choices are live today: AffinEQ’s own server (United Kingdom, the default) and your own S3-compatible bucket. Locations at Cloudflare R2, Amazon S3, Microsoft Azure, Google Cloud and Backblaze B2 are built and switch on as each provider’s account is connected. Each managed project gets its own bucket (on Azure, its own container). Choose the location before the first upload; moving files between locations later is coming, and is not built yet.
List every location, and whether it is available:
GET https://api.affineq.com/v1/storage/locations
Read, or choose, a project’s location. These are management API calls, authenticated with a personal access token or a dashboard session:
GET /v1/projects/{ref}/storage/location
PUT /v1/projects/{ref}/storage/location
{ "location": "r2-eu" }
The location ids:
affineq-uk: AffinEQ’s own server, United Kingdom (live, the default).r2-eu: Cloudflare R2, European Union, with no download fees.aws-af-south-1: Amazon S3, Cape Town, South Africa.aws-eu-west-1: Amazon S3, Ireland.azure-southafricanorth: Microsoft Azure Blob Storage, Johannesburg, South Africa.azure-westeurope: Microsoft Azure Blob Storage, West Europe.gcs-africa-south1: Google Cloud Storage, Johannesburg, South Africa.gcs-europe-west1: Google Cloud Storage, Belgium.b2-eu-central-003: Backblaze B2, Amsterdam.
Only locations the listing marks as available can be chosen. A PUT on a project that already holds files is refused with 409 and the code storage_not_empty, because the location is chosen before the first upload. Cape Town and Johannesburg are the options for data that must stay in Africa. A bucket you bring yourself can sit wherever you keep it.
Live
Change subscriptions, broadcast and presence under affineq.channel(). Switch a table on once, with the switch under Live in the dashboard or select live.enable('orders') in SQL. Row-level security decides what each subscriber sees.
const orders = affineq
.channel("orders")
.on("postgres_changes", { event: "UPDATE", table: "orders", filter: "status=eq.paid" }, markPaid)
.subscribe((status) => console.log(status)); // SUBSCRIBED
const room = affineq.channel("room:1", { config: { presence: { key: user.id } } })
.on("broadcast", { event: "cursor" }, ({ payload }) => draw(payload))
.subscribe();
await room.send({ type: "broadcast", event: "cursor", payload: { x, y } });
The stream is server-sent events, which works through proxies that break WebSockets. A drop reconnects with backoff and the server replays what was missed. If you were away longer than the server keeps (an hour of changes, five minutes of broadcasts), a reset event tells you to refetch what you show.
Workers
Server-side code close to your data, in TypeScript, Python, Java, Dart or PHP, written in the dashboard or deployed from your repo. Call one from the client, whatever it is written in:
const { data, error } = await affineq.workers.invoke("on-order-paid", {
body: { orderId: 42 },
});
A signed-in user’s token reaches the function as ctx.auth. A worker with “Verify JWT” on refuses anonymous callers. Workers can also run on a table change or an auth event (hooks), on a schedule (cron), and read project-wide secrets as ctx.env. Secrets are sealed: you can list their names, never read them back. The runtime gives each run a fresh sandbox with a deadline, and the console output of every run is under Workers → Runs.
// affineq/functions/on-order-paid/index.ts
export default async function (req: Request, ctx: Ctx): Promise<Response> {
const row = ctx.event?.record; // a table change fired us
if (row?.status !== "paid") return Response.json({ skipped: true });
// ctx.env.AFFINEQ_SECRET_KEY bypasses row-level security
return Response.json({ ok: true });
}
In Python, Java, Dart or PHP
Choose the language when you create the function (or affineq functions new <name> --language python). The entry file says which it is, and a function can have more files beside it. Python, Java, Dart and PHP are built when you deploy: their dependencies are installed then, and code that does not compile is refused with the compiler’s own message while the version already deployed keeps serving. Each gets the request and a context with env, auth and event, and returns JSON, text or a response; what it prints is the run’s log.
# main.py — packages in requirements.txt
from affineq import Response
def handler(req, ctx):
order = req.json()
print("order", order["id"], "for", (ctx.auth or {}).get("sub"))
return Response.json({"ok": True, "total": order["qty"] * 250}, status=201)
// Main.java — Maven coordinates in dependencies.txt; Json.parse/stringify built in
import affineq.*;
import java.util.Map;
public class Main {
public static Object handle(Request req, Context ctx) {
Map<String, Object> order = req.jsonObject();
System.out.println("order " + order.get("id"));
return Map.of("ok", true, "secret_set", ctx.env("PAYSTACK_SECRET") != null);
}
}
// main.dart — compiled to native code; packages in pubspec.yaml
import 'affineq.dart';
Future<Object?> handler(Request req, Context ctx) async {
final order = req.json() as Map;
print('order ${order['id']}');
return {'ok': true, 'user': ctx.auth?['sub']};
}
<?php // main.php — packages in composer.json
use Affineq\Response;
function handler($req, $ctx) {
$order = $req->json();
error_log("order " . $order['id']);
return Response::json(['ok' => true, 'user' => $ctx->auth['sub'] ?? null], 201);
}
Give a function 128, 256 or 512 MB (Settings beside the code, or --memory); past it, Python raises MemoryError, Java OutOfMemoryError, and Dart and PHP stop with an out-of-memory error the run log shows. A Java function is served by a warm process between calls (about 1 ms a call after the first): its static fields survive between calls of one version, as on AWS Lambda, but do not rely on them.
Run them on your machine with affineq functions serve: the same paths on localhost, with your secrets from affineq/functions/.env, rebuilt when a file changes. It uses your Python, Java, Dart, PHP or Deno, and the platform’s Docker image when one is missing.
Each run is a fresh process under its own throwaway user, with no way to raise its privileges, limits on memory, processes and CPU time, and a network that reaches the internet but nothing of ours; it talks to your project through the API, with the keys in ctx.env. A Python run starts in about 30 ms, Dart in about 10, PHP in about 25, and Java in about 60 the first time and 1 after.
Not yet: multi-file TypeScript bundles with import maps, and WebSockets inside a function.
Server-side apps
@affineq/ssr keeps the session in cookies instead of local storage, so a Next.js Server Component, a Route Handler and the browser all see the same sign-in. It has three pieces: a browser client, a server client built per request, and a proxy (middleware) that is the one place a session is renewed.
npm install @affineq/ssr.The CLI
The affineq command line talks to the same management API as the dashboard, authenticated by a personal access token (Account → Access tokens), so it can never do something the dashboard cannot explain. It is not distributed yet; ask us for a build.
affineq login # paste a token affineq link # choose this directory's project affineq functions new on-order-paid affineq functions new send-receipt --language python # or java, dart, php affineq functions serve # run them here, rebuilt as you edit affineq functions deploy --all affineq functions logs on-order-paid affineq secrets set --env-file .env # project-wide, sealed affineq db push # apply pending migrations, in order affineq db push --dry-run affineq types -o src/database.types.ts
A migration is a file in affineq/migrations named <timestamp>_<name>.sql. What has run is recorded in the project’s own database with a checksum, so a restored backup knows where it is, and a file edited after it ran is an error, not a surprise. In CI, set AFFINEQ_TOKEN instead of logging in.
On real networks
- Reads retry on network errors and 5xx with exponential backoff and jitter. Writes never replay on a 5xx, so a retry cannot double-charge.
- A read that lands while the schema reloads after you create a table is retried for you.
- Every request has a 15-second timeout; raise
timeoutMsfor slow links.
createClient(key, { timeoutMs: 30_000, retries: 3, headers: { "x-app": "shop/1.2" } });
Coming from Supabase
The query builder, the auth calls and row-level security have the same shape. In code, the change is the import and the key, and createClient(url, key) works too. Your data moves as a copy: an importer reads a dump of your database and loads an empty project in one transaction with every row count verified, and a comparison tool tells you whether the two still agree while you run them side by side. Supabase edge functions run on Workers without a rewrite, and cron jobs and webhooks arrive switched off until you cut over. Today this is run alongside you; tell us what you are moving.
Not documented yet
Found a gap or something wrong? Tell us. A correction to these docs is one of the best ways to contribute.