On the wire · 04:56:41 UTC Live 566 · Prematch 14,380 · Markets 56,915 Vol. I · No. 5 · Friday 13 November 2026 wss://pinnfeed.io/v1/stream
PinnFeed
Tutorial · Supabase · REST

Pinnacle odds into Supabase with an Edge Function

Updated · PinnFeed · 3 min read

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.
  • apikey vs Authorization. Supabase's current docs send the new sb_secret_ keys on apikey; they are not JWTs, so do not rely on Authorization: Bearer for them. If your project still uses the legacy service_role JWT (Supabase is deprecating legacy keys by the end of 2026), keep verify_jwt on and send that key on both apikey and Authorization: Bearer. Read it from Vault in the same way.
  • Vault limit. pg_net writes the decrypted key to net.http_request_queue until 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.

The WebSocket plan: Builder, €99/mo.

Real-time Pinnacle odds pushed over one WebSocket, all 13 sports, every market, no-vig fair odds and closing lines. 3 days free, cancel in one click.

Form K-1 · Free API key

Get your free API key.

One field. No password, no card: enter your email, click the link we send, and your key is on screen.

By continuing you agree to the Terms and Privacy Policy.

One free key per email. Link valid for 20 minutes.