Supabase Setup

Configure Supabase auth, database tables, storage, and Edge Functions for the Android template.

This page gets the Android template to a working Supabase backend: auth callbacks, PostgREST tables, the entitlement read model, FCM test push, and the RevenueCat webhook.

The Kotlin template currently ships only the device_tokens, subscriptions, and payments migrations. todos and profiles are used by the app but are not present in soar-kotlin/supabase/migrations/, so this page includes the missing SQL explicitly.

Create the project

Create a Supabase project

Create a project in the Supabase dashboard and copy the project URL plus the publishable key.

Add the mobile keys

Put the client-safe values in the Android template's local.properties:

SUPABASE_URL=https://your-project-ref.supabase.co
SUPABASE_PUBLISHABLE_KEY=your_publishable_key

These values become BuildConfig.SUPABASE_URL and BuildConfig.SUPABASE_PUBLISHABLE_KEY. They are not server secrets.

Allow auth redirects

In Supabase Auth settings, add this redirect URL:

soar://auth-callback

SupabaseClientProvider installs Auth with scheme = "soar" and host = "auth-callback", so email verification, OTP, recovery, and OAuth callbacks need that URL to be allow-listed.

Apply the schema

From soar-kotlin/supabase, link the CLI to your project:

supabase login
supabase link --project-ref <ref>

Then apply the schema in this order.

Missing profiles table

Run this SQL in the dashboard SQL editor, or copy it into a new migration in soar-kotlin/supabase/migrations/ before supabase db push:

create table if not exists public.profiles (
  id uuid primary key references auth.users(id) on delete cascade,
  full_name text,
  bio text,
  phone text,
  location text,
  avatar_url text,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

alter table public.profiles enable row level security;

drop policy if exists "Users can view own profile" on public.profiles;
create policy "Users can view own profile"
  on public.profiles for select
  to authenticated
  using ((select auth.uid()) = id);

drop policy if exists "Users can update own profile" on public.profiles;
create policy "Users can update own profile"
  on public.profiles for update
  to authenticated
  using ((select auth.uid()) = id)
  with check ((select auth.uid()) = id);

drop policy if exists "Users can insert own profile" on public.profiles;
create policy "Users can insert own profile"
  on public.profiles for insert
  to authenticated
  with check ((select auth.uid()) = id);

create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
  insert into public.profiles (id, full_name)
  values (new.id, new.raw_user_meta_data ->> 'full_name')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created on auth.users;
create trigger on_auth_user_created
  after insert on auth.users
  for each row execute function public.handle_new_user();

The app reads and updates full_name, bio, phone, location, and avatar_url. SupabaseAuthRemoteDataSource.deleteAccount() also nulls exactly those fields during the current client-side delete flow.

Missing todos table

Run or migrate this SQL:

create table if not exists public.todos (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references auth.users(id) on delete cascade,
  title text not null,
  completed boolean not null default false,
  sort_order integer not null default 0,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

alter table public.todos
  add column if not exists sort_order integer not null default 0;

create index if not exists todos_user_id_idx on public.todos (user_id);
create index if not exists todos_user_sort_idx
  on public.todos (user_id, sort_order, created_at desc);

create or replace function public.set_updated_at()
returns trigger
language plpgsql
as $$
begin
  new.updated_at = now();
  return new;
end;
$$;

drop trigger if exists set_todos_updated_at on public.todos;
create trigger set_todos_updated_at
  before update on public.todos
  for each row execute function public.set_updated_at();

alter table public.todos enable row level security;

drop policy if exists "Users can view their todos" on public.todos;
create policy "Users can view their todos"
  on public.todos for select
  to authenticated
  using ((select auth.uid()) = user_id);

drop policy if exists "Users can insert their todos" on public.todos;
create policy "Users can insert their todos"
  on public.todos for insert
  to authenticated
  with check ((select auth.uid()) = user_id);

drop policy if exists "Users can update their todos" on public.todos;
create policy "Users can update their todos"
  on public.todos for update
  to authenticated
  using ((select auth.uid()) = user_id)
  with check ((select auth.uid()) = user_id);

drop policy if exists "Users can delete their todos" on public.todos;
create policy "Users can delete their todos"
  on public.todos for delete
  to authenticated
  using ((select auth.uid()) = user_id);

SupabaseTodosRemoteDataSource selects by user_id, orders by sort_order ascending then created_at descending, and writes title, completed, and sort_order.

Template fix recommended: add first-class profiles and todos migrations to soar-kotlin/supabase/migrations/ so supabase db push creates every table the Android app uses.

Shipped migrations

The template does ship:

  • 20260508000001_create_device_tokens.sql: FCM token rows, unique on (user_id, token), with select/insert/update/delete RLS for the owner.
  • 20260702000000_billing_entitlements.sql: shared subscriptions and payments tables plus billing enums. Clients can select their own rows; writes are reserved for service-role functions.

Apply them with:

supabase db push --project-ref <ref>

Most DDL uses if not exists or policy replacement, so reapplying to a shared project is intended to be boring.

Storage bucket

The upload sample uses a private bucket named sample-uploads. Create it and the folder-scoped policies from Storage before testing uploads. The Kotlin app does not use the Expo sample's sample_uploads metadata table.

Edge Functions

supabase/config.toml pins the two function auth modes:

[functions.revenuecat-webhook]
verify_jwt = false

[functions.send-test-push]
verify_jwt = true

Deploy them from the template's supabase/ folder:

supabase functions deploy revenuecat-webhook --no-verify-jwt --project-ref <ref>
supabase functions deploy send-test-push --project-ref <ref>
supabase functions list --project-ref <ref>

revenuecat-webhook is called by RevenueCat without a Supabase user JWT, so it uses a static Authorization header. send-test-push is called by the signed-in Android user, keeps JWT verification on, and reads device_tokens through that user's RLS context. _shared/ is bundled into those functions; do not deploy it by itself.

Edge secrets

Prop

Type

Set secrets with:

supabase secrets set REVENUECAT_WEBHOOK_AUTH=<long-random-string> --project-ref <ref>
supabase secrets set FIREBASE_SERVICE_ACCOUNT_JSON='<service-account-json>' --project-ref <ref>

Do not set SUPABASE_URL, SUPABASE_ANON_KEY, or the service-role key in your function .env; Supabase injects those at runtime.

Local function dev

Serve functions locally:

supabase functions serve --env-file supabase/functions/.env

Run the helper tests:

deno test --allow-env supabase/functions/_shared/revenuecat.test.ts
deno test supabase/functions/_shared/fcm.test.ts

Smoke-test the RevenueCat webhook:

curl -i \
  -X POST http://localhost:54321/functions/v1/revenuecat-webhook \
  -H "Authorization: ${REVENUECAT_WEBHOOK_AUTH}" \
  -H "Content-Type: application/json" \
  --data '{
    "api_version": "1.0",
    "event": {
      "type": "INITIAL_PURCHASE",
      "store": "PLAY_STORE",
      "app_user_id": "11111111-1111-1111-1111-111111111111",
      "product_id": "pro_monthly",
      "period_type": "NORMAL",
      "purchased_at_ms": 1782940800000,
      "expiration_at_ms": 1785532800000,
      "transaction_id": "gpa.test",
      "original_transaction_id": "gpa.test"
    }
  }'

For deployed verification, replace the host with:

https://<ref>.supabase.co/functions/v1/revenuecat-webhook

Verify

  • soar://auth-callback is in the Supabase redirect allow-list.
  • A new sign-up creates an auth user and a matching profiles row.
  • Todos CRUD persists and survives app relaunch.
  • public.device_tokens, public.subscriptions, and public.payments exist with RLS enabled.
  • supabase functions list --project-ref <ref> shows revenuecat-webhook and send-test-push.
  • The RevenueCat webhook returns {"ok":true} for a valid smoke event.

On this page