AffinEQ

Product

Platform overview Integrations Verified, not claimed What isn't built yet

Solutions

Mobile-first startups Agencies building for SMEs Fintechs & regulated teams What people build

Migrate

How a move works From Supabase From Appwrite From Firebase From AWS Amplify From any Postgres Pricing

Developers

Documentation Next.js quickstart JavaScript quickstart Changelog Support Contribute Platform status

Company

Why we exist Customers Blog Partners Contact Sign in Start building
Documentation / Reference

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

Early access. Sign-up needs an invite for now. Ask for yours and we will answer personally.
  1. 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.
  2. Create a project in your organisation. It is its own Postgres database with its own keys.
  3. Make a table in the Table editor, or run SQL in the SQL editor.
  4. Copy your publishable key from the project’s Settings → API keys. It starts aq_pub_.
  5. 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",
});
Real codes go through your own SMS provider. Under Authentication → Providers → Phone, choose Mobitech (Kenya), Africa’s Talking or Twilio and add its API key; we send the codes and handle the rest, and a test button checks the key. You pay the provider directly, so it is open on every plan. Test numbers need no provider at all.

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.

Do not skip the proxy. Refresh tokens rotate, and a reused one revokes the whole session. A Server Component cannot save a new one, so the server client never refreshes by default; the proxy does it. Read the package’s README before changing how a session is stored. It is on npm: 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 timeoutMs for 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

A complete docs siteNextVersioned, searchable, with an example on every page.
API referenceNextThe dashboard’s API page already documents your own tables live; the platform’s REST, Auth and Storage reference is still to be written out.
Flutter and mobile guidesLaterWith the Flutter SDK.
Firebase and Appwrite migration guidesLaterAfter the Supabase path has been proven on more real apps.

Found a gap or something wrong? Tell us. A correction to these docs is one of the best ways to contribute.

Type to search. Nothing you type leaves this page.