Skip to content

Astrology API in Supabase: Edge Functions, SQL and RLS

19 min read
•Torsten Brinkmann
astrologySupabaseEdge FunctionsRow Level SecurityPostgres

Astrology API in Supabase: the key as an Edge Function secret, birth data under RLS, natal charts cached in Postgres, daily horoscopes read in SQL.

TL;DR

  • To call an astrology API from Supabase, store the API key as an Edge Function secret, call the API from an Edge Function that verifies the Supabase session, and keep birth profiles and consent in your own Postgres tables under Row Level Security; shared daily content can also be read in plain SQL through the Supabase OpenAPI foreign data wrapper.
  • A natal chart is deterministic: the same date, time and place sent to POST /astrology/natal-chart three times returned three byte-identical 32,453-byte responses, so it is computed once per person and every repeat read comes from Postgres with no API request.
  • Resolve the birth city with GET /location/search and pass its IANA timezone: New York returns America/New_York, which the chart resolved to UTC-5 for a January 1990 birth, while the same city record carries a current utcOffset of -4.
  • Supabase publishable and secret keys are not JWTs, so the function sets verify_jwt = false, checks the apikey header itself and verifies the session token with getClaims before it reads a row.

You are building an astrology feature on Supabase and you have three things to protect: the API key, the birth data of your users, and your request quota. The usual first attempt calls the API from the browser, which exposes the key, or stores birth data somewhere the vendor can see it. Neither is necessary.

This guide is the architecture you can copy. The birth profile and the consent timestamp live in your Postgres. A Supabase Edge Function holds the RoxyAPI secret key and calls the Astrology API on behalf of a signed-in user. A natal chart is cached in a table, because the same inputs always produce the same chart. Daily horoscopes can be read straight from SQL. A page with no backend at all can use a widget instead.

Every SQL and TypeScript block below was executed; the hosted deploy commands were not. What ran against a live API and what was checked against the Supabase documentation only is stated just before the FAQ.

What does the architecture look like when Supabase calls an astrology API?

The RoxyAPI secret key never leaves the server, and the profile and consent never leave your database. The browser talks only to Supabase, with the signed-in session. An Edge Function verifies that session, reads the birth profile under it, returns a cached chart when one exists, and otherwise calls the API once and stores the result. RoxyAPI receives the birth date, time and coordinates for the calculation and nothing else: no email, no user id, no name.

PieceLives inWho can read it
User accountSupabase Auth (auth.users)Supabase Auth
Birth profile and consent timestampYour birth_profile tableThe owner only, enforced by Row Level Security
Cached natal chartYour natal_chart tableThe owner reads it; only the Edge Function writes it
RoxyAPI secret key (sk_)Supabase project secretsThe Edge Function only
Supabase publishable key (sb_publishable_)Your app, and every functionPublic by design; Row Level Security decides what it reads
Supabase secret key (sb_secret_)Injected into every functionThe Edge Function only; it bypasses Row Level Security
Shared daily horoscopeYour daily_reading table, or a foreign tableAnyone, read-only
RoxyAPI publishable key (pk_)Page source, for widgetsPublic by design, locked to your origin

The adjacent question, where conversation memory and reading history should live, is covered in building a memory layer for an AI astrology companion. This post is the Supabase wiring underneath it.

Ready to build this? The Astrology API returns a full natal chart with interpretations in one call, and one key covers 258+ endpoints across 18+ domains. See pricing.

Put the birth profile in a table keyed to auth.users, enable Row Level Security, and let each person read and write only their own row. Record consent as a required timestamp, so a profile without consent cannot exist. The chart cache gets a select policy and no write policy, which means a browser holding the Supabase publishable key can read its own chart but cannot write a fake one. Deleting the account deletes both rows.

create table public.birth_profile (
  user_id      uuid primary key references auth.users on delete cascade,
  birth_date   date not null,
  birth_time   time not null,
  birth_city   text not null,
  consented_at timestamptz not null,
  updated_at   timestamptz not null default now()
);

create table public.natal_chart (
  user_id    uuid primary key references public.birth_profile on delete cascade,
  input_key  text not null,
  place      jsonb not null,
  chart      jsonb not null,
  created_at timestamptz not null default now()
);

alter table public.birth_profile enable row level security;
alter table public.natal_chart enable row level security;

create policy "own profile" on public.birth_profile
  for all to authenticated
  using ((select auth.uid()) = user_id)
  with check ((select auth.uid()) = user_id);

create policy "read own chart" on public.natal_chart
  for select to authenticated
  using ((select auth.uid()) = user_id);

The policies follow the Supabase recommendations: name the authenticated role so the check never runs for signed-out requests, and wrap auth.uid() in a select so Postgres can evaluate it once per statement instead of once per row. The profile stores the city name the person typed, not coordinates. The Edge Function resolves the city, which is the one step that needs the API key.

Signed-out requests see an empty result, not an error. With no policy for the anon role, a query from a signed-out visitor returns zero rows. Treat an empty profile as "not saved yet" in your UI rather than as a failure.

How does a Supabase Edge Function call the astrology API?

Set the RoxyAPI key once with supabase secrets set, read it with Deno.env.get in one shared helper, and import the typed TypeScript SDK from npm through one deno.json import map. Edge Functions run Deno, which resolves npm: specifiers natively, so there is no build step. The function below verifies the caller session, returns the cached chart when the inputs match, and otherwise resolves the city and casts the chart. The three helpers are the ones the open source Supabase starter ships, unchanged.

import { corsHeaders, preflight } from '../_shared/cors.ts';
import { errorResponse, roxyClient } from '../_shared/roxy.ts';
import { adminClient, requireProjectKey, userClient } from '../_shared/supabase.ts';

const json = (body: unknown, status = 200) =>
  new Response(JSON.stringify(body), {
    status,
    headers: { ...corsHeaders, 'Content-Type': 'application/json' },
  });

Deno.serve(async (req) => {
  const refused = preflight(req) ?? requireProjectKey(req);
  if (refused) return refused;

  const user = await userClient(req);
  if (!user) return json({ error: 'Sign in first', code: 'unauthorized' }, 401);

  const { data: profile } = await user.db
    .from('birth_profile')
    .select('birth_date, birth_time, birth_city')
    .eq('user_id', user.userId)
    .maybeSingle();
  if (!profile) return json({ error: 'Save a birth profile first', code: 'no_profile' }, 404);

  const inputKey = `${profile.birth_date}|${profile.birth_time}|${profile.birth_city}`;
  const { data: cached } = await user.db
    .from('natal_chart')
    .select('input_key, chart')
    .eq('user_id', user.userId)
    .maybeSingle();
  if (cached?.input_key === inputKey) return json(cached.chart);

  const roxy = roxyClient();
  const { data: found, error: cityError } = await roxy.location.searchCities({
    query: { q: profile.birth_city, limit: 1 },
  });
  if (cityError) return errorResponse(cityError, corsHeaders);

  const place = found.cities[0];
  if (!place) return json({ error: 'Birth city not found', code: 'city_not_found' }, 404);

  const { data: chart, error } = await roxy.astrology.generateNatalChart({
    body: {
      date: profile.birth_date,
      time: profile.birth_time,
      latitude: place.latitude,
      longitude: place.longitude,
      timezone: place.timezone,
    },
  });
  if (error) return errorResponse(error, corsHeaders);

  await adminClient()
    .from('natal_chart')
    .upsert({ user_id: user.userId, input_key: inputKey, place, chart });

  return json(chart);
});

The function lives at supabase/functions/natal-chart/index.ts, the three helpers in supabase/functions/_shared/, and the import map at supabase/functions/deno.json, which config.toml points the function at.

Two clients, on purpose. userClient verifies the session token with getClaims and returns null unless it verifies, so a signed-out or forged caller never reaches a query; its client carries the publishable key and that token, so Row Level Security decides which profile it sees. adminClient uses the Supabase secret key, which Postgres sees as the service_role role: it bypasses policies and is the only thing allowed to fill the cache. The platform injects both keys into every function as SUPABASE_PUBLISHABLE_KEYS and SUPABASE_SECRET_KEYS, each a JSON map by key name, and the helper reads the one named default. Neither key is a JWT, so config.toml turns off the platform JWT check and requireProjectKey checks the apikey header instead; supabase.functions.invoke sends the publishable key there and the session on Authorization. Secret names starting with SUPABASE_ are reserved, which is why the RoxyAPI key is ROXY_API_KEY. cors.ts answers the browser preflight and allows any origin, so narrow it to your site before you ship. Try the chart request itself in the natal chart API reference, live in the browser with no signup.

Why can a natal chart be cached, and what does a repeat call cost?

A natal chart depends only on the birth date, time and place, so the answer never changes and a cached copy is exactly as correct as a fresh one. Sent the same body three times, POST /astrology/natal-chart returned three responses with the same SHA-256 hash. Served from the table instead, a repeat costs no API request at all, because the function returns before the SDK is called.

Measured through the function above, for a New York birth on 1990-01-15 at 14:30:

MeasurementValue
Natal chart response size32,453 bytes
Same chart stored as jsonb (pg_column_size)9,892 bytes
Same body sent three timesThree identical SHA-256 hashes
Repeat call through the functionServed from natal_chart, zero API requests
Cached chart compared with a fresh one (keys sorted)Identical

The input_key is what keeps the cache honest. If a person corrects their birth time, the key changes, the next call misses, and the chart is recomputed and replaced. Nothing needs to expire.

Pass the IANA timezone, never the current offset. The city record for New York carries timezone: "America/New_York" and utcOffset: -4, the offset in effect today. The chart resolved the IANA name to UTC-5 for a January birth, which is correct. Sending -4 would shift the chart by an hour and move the Ascendant.

Numerology from a name and birth date, a kundli or a panchang for a fixed date are cacheable the same way. A random tarot draw or a coin-cast I Ching reading is not, because the randomness is the feature.

Can you query an astrology API straight from SQL in Supabase?

Yes, for content that does not vary per person. The OpenAPI foreign data wrapper that Supabase maintains reads the RoxyAPI spec, and a foreign table then turns an endpoint into something you select from. A value in the path, such as the zodiac sign, is filled from the where clause. It needs no function and no deploy, which makes it the shortest path to a daily horoscope on a landing page.

create extension if not exists wrappers with schema extensions;

create foreign data wrapper wasm_wrapper
  handler wasm_fdw_handler
  validator wasm_fdw_validator;

select vault.create_secret('sk_your_key_here', 'roxy_api_key');

create server roxy_server
  foreign data wrapper wasm_wrapper
  options (
    fdw_package_url 'https://github.com/supabase/wrappers/releases/download/wasm_openapi_fdw_v0.2.1/openapi_fdw.wasm',
    fdw_package_name 'supabase:openapi-fdw',
    fdw_package_version '0.2.1',
    fdw_package_checksum '12c902f3089e18142a1d8d35c66b9ceb85c193224229687bd929aff6b44cddde',
    base_url 'https://roxyapi.com/api/v2',
    spec_url 'https://roxyapi.com/api/v2/openapi.json',
    api_key_id '<the id returned by vault.create_secret>',
    api_key_header 'X-API-Key',
    api_key_prefix ''
  );

Take the current package version and checksum from the wrapper catalog before you copy them, because both move. The key sits in Vault, and the server names it by the id vault.create_secret returns rather than holding it in plain text. Column names translate across conventions, so luckyNumber reads into lucky_number, and attrs holds the whole response row. The daily_reading table is the shared cache from the starter: one row per sign per day, a public read policy, and no write policy.

Three limits decide what belongs here. Foreign tables are read-only. A body-taking endpoint gets one fixed request_body per table, so one table is one chart, not one chart per visitor. Every select on a foreign table is a live API request, which is why the third tab copies the reading of the day into a table.

Where do a publishable key and widgets fit on a Supabase site?

A page that should show a reading with no backend at all, such as a marketing page or a static site hosted next to your Supabase app, does not need an Edge Function. It uses a RoxyAPI publishable key, which starts with pk_, is safe in page source, and can be locked to your origin. It is a different key from the Supabase sb_publishable_ key. A RoxyAPI secret key must never reach the browser, whether through a client-visible environment variable or a table the browser can select from.

The fastest route is a ready-made embeddable widget: paste one snippet and a live natal chart or daily horoscope renders on any surface that accepts HTML. The widgets guide in the docs covers creating the key and choosing the widget.

The rule is short. Anything personal goes through the Edge Function, so birth data stays under your policies and the RoxyAPI secret key stays in project secrets. Anything shared and anonymous can render from a widget. If you want the rendering without the widget, the same visual layer ships as MIT web components at RoxyAPI UI, fed with the JSON your function already returns.

What ran, and what was checked against documentation only

Everything that touches RoxyAPI was run against the production API. Supabase platform behaviour was either run on a local Supabase stack or checked against the current Supabase documentation.

PartHow it was verified
Location search, natal chart, daily horoscope callsRun against the production API, status 200, fields read from the live OpenAPI spec
Schema and policiesRun on a local Supabase stack (Postgres 17.6): the owner reads their row, another user and a signed-out visitor read none, a cross-user profile write and a browser write to the cache are refused
Edge FunctionType-checked with deno check, then served with supabase functions serve against the local stack and the live API: no apikey 401, signed out and forged token 401, no profile 404, first call 200, repeat served from the cache
Foreign data wrapper, Vault key, daily tableRun on the same stack with wrappers 0.6.2 against the production API; an insert into the foreign table is refused
supabase secrets set, functions deploy, functions invoke on a hosted projectSupabase documentation only; no hosted project was deployed

FAQ

How do I call an astrology API from a Supabase Edge Function?

Store the RoxyAPI key with supabase secrets set ROXY_API_KEY=..., add npm:@roxyapi/sdk@1 to the functions deno.json import map, and create the client with createRoxy, reading the key with Deno.env.get. Resolve the birth city with roxy.location.searchCities, then pass its latitude, longitude and IANA timezone to roxy.astrology.generateNatalChart. With the Supabase publishable and secret keys, set verify_jwt = false and check the apikey header and the session in the function. The RoxyAPI key never reaches the browser.

Does RoxyAPI store the birth data of my users?

No. RoxyAPI is stateless and keeps no birth profiles, readings or user records. In the Supabase architecture above, the profile, the consent timestamp and the saved chart all live in your own Postgres under Row Level Security, and the API receives only the date, time and coordinates needed for the calculation.

Can I cache natal charts in my Supabase database?

Yes. A natal chart for a fixed date, time and place never changes, so store it once in a table keyed to the user and serve every repeat read from Postgres. Keep a key built from the inputs beside it, so a corrected birth time triggers one recalculation instead of serving a stale chart.

Can I query RoxyAPI from Postgres with plain SQL?

Yes, through the Supabase OpenAPI foreign data wrapper pointed at the RoxyAPI spec. It suits daily horoscopes and reference catalogues, with the sign or other path value filled from the where clause. Foreign tables are read-only, carry one fixed request body each and call the API on every select, so per-user charts belong in an Edge Function and daily content is worth copying into a table.

Can I show a horoscope on a Supabase site without a backend?

Yes. Create a RoxyAPI publishable key, which starts with pk_, is safe in page source and can be locked to your origin, and paste a RoxyAPI widget onto the page. It renders a live natal chart or daily horoscope with no Edge Function. Use the Edge Function path for anything personal, so birth data stays under your own policies.

Conclusion

On Supabase, an astrology feature is a few tables, one Edge Function and a secret: the person and their consent stay in your Postgres, the key stays in project secrets, and a chart is computed once. Start from the Astrology API, or fork the MIT Supabase starter, which ships the same helpers, config and SQL path with a shared daily cache and saved charts, and compare plans on pricing.