Pinnacle odds into Supabase with an Edge Function
Answer: keep the API key as a Supabase secret, write an Edge Function that calls GET /v1/drops (or /v1/odds) and upserts rows through the admin client from @supabase/server, then let pg_cron + pg_net call it every minute with a secret key kept in Supabase Vault. Edge Functions are short-lived, so this is a REST polling job, not a WebSocket listener. Works on Free (30 s delayed, 2 sports) and in real time on Hobby and up.
1. Table#
create table if not exists pinnacle_drops (
event_id bigint, market_id text, outcome text,
price_from numeric, price_to numeric, drop_pct numeric,
moved_at timestamptz, home text, away text,
primary key (market_id, outcome, moved_at)
);
2. Secret#
supabase secrets set PINNFEED_KEY=ps_live_your_key
3. Function supabase/functions/pinnfeed-drops/index.ts#
import { withSupabase } from 'npm:@supabase/server@1';
// auth: 'secret' accepts only callers that send your project's secret key on the apikey header
export default {
fetch: withSupabase({ auth: 'secret' }, async (_req, ctx) => {
const r = await fetch('https://pinnfeed.io/v1/drops?sport=soccer&minutes=5&min_pct=3&limit=100', {
headers: { 'x-api-key': Deno.env.get('PINNFEED_KEY')! },
});
if (!r.ok) return new Response(await r.text(), { status: r.status });
const { drops } = await r.json();
const rows = drops.map((d: any) => ({
event_id: d.eventId, market_id: d.marketId, outcome: d.outcome,
price_from: d.from, price_to: d.to, drop_pct: d.dropPct,
moved_at: d.movedAt, home: d.home, away: d.away,
}));
const { error } = await ctx.supabaseAdmin.from('pinnacle_drops').upsert(rows, { ignoreDuplicates: true });
return Response.json({ saved: rows.length, error: error?.message ?? null });
}),
};
The function is called by a cron job with a secret key, not by a signed-in user, so turn off the platform JWT check for it in supabase/config.toml and let withSupabase check the key:
[functions.pinnfeed-drops]
verify_jwt = false
Deploy with supabase functions deploy pinnfeed-drops. Check the /v1/drops response once to confirm the timestamp format of movedAt before relying on the primary key.
4. Schedule#
Enable the pg_cron and pg_net extensions. Store the project URL and a Supabase secret key (sb_secret_...) in Supabase Vault once, so they never sit inline in the cron SQL:
select vault.create_secret('https://YOUR_PROJECT.supabase.co', 'project_url');
select vault.create_secret('YOUR_SUPABASE_SECRET_KEY', 'secret_key');
Then schedule the call, reading both values from vault.decrypted_secrets and sending the key on the apikey header:
select cron.schedule('pinnfeed-drops', '* * * * *', $$
select net.http_post(
url := (select decrypted_secret from vault.decrypted_secrets where name = 'project_url')
|| '/functions/v1/pinnfeed-drops',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'apikey', (select decrypted_secret from vault.decrypted_secrets where name = 'secret_key')
),
body := '{}'::jsonb
);
$$);
Notes:
- Use the secret key, not the publishable key. The publishable key is public, so anyone holding it could trigger the function.
apikeyvsAuthorization. Supabase's current docs send the newsb_secret_keys onapikey; they are not JWTs, so do not rely onAuthorization: Bearerfor them. If your project still uses the legacyservice_roleJWT (Supabase is deprecating legacy keys by the end of 2026), keepverify_jwton and send that key on bothapikeyandAuthorization: Bearer. Read it from Vault in the same way.- Vault limit.
pg_netwrites the decrypted key tonet.http_request_queueuntil it sends the request, so any role that can log in to your database can read it while it waits. Keep database logins limited. - The PinnFeed key itself stays in Edge Function secrets (step 2), never in SQL.
Every minute is 1,440 calls a day: fine on Hobby and up (no daily cap), but above Free's 1,000/day, so use */2 * * * * on Free.
Next steps#
Need every price, not just drops? Store /v1/odds snapshots: Postgres storage pattern. Need push instead of polling? Run a small always-on worker with the WebSocket on Builder (€99/mo). 3-day trial; a card is required and it is charged after day 3 unless you cancel. Free key.
Educational content for adults (18+).
Questions people ask#
Can an Edge Function hold the WebSocket open?#
Not reliably; they are request-scoped. Use a long-running process (a VM, Fly, Railway, a home server) for the stream.
Is the Supabase secret key safe here?#
Yes, server side: in Vault for the cron call and checked inside the function. Never expose it, or the PinnFeed key, to the browser.
Can I use Supabase Realtime to push to my app?#
Yes: once rows land in the table, Supabase Realtime can broadcast inserts to your front end.