On the wire · 04:50:31 UTC Live 550 · Prematch 14,382 · Markets 56,987 Vol. I · No. 5 · Friday 13 November 2026 wss://pinnfeed.io/v1/stream
PinnFeed
Tutorial · Supabase · REST

Cuotas de Pinnacle en Supabase con una Edge Function

Actualizado el · PinnFeed · 4 min de lectura

Respuesta: guarda la clave de la API como secreto de Supabase, escribe una Edge Function que llame a GET /v1/drops (o /v1/odds) y haga upsert de las filas con el cliente admin de @supabase/server, y deja que pg_cron + pg_net la llamen cada minuto con una clave secreta guardada en Supabase Vault. Las Edge Functions duran poco, así que esto es un trabajo de polling REST, no un listener de WebSocket. Funciona en Free (30 s de retraso, 2 deportes) y en tiempo real desde Hobby.

1. Tabla#

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. Secreto#

supabase secrets set PINNFEED_KEY=ps_live_your_key

3. Función 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 });
  }),
};

A la función la llama un cron con una clave secreta, no un usuario con sesión, así que desactiva la comprobación de JWT de la plataforma en supabase/config.toml y deja que withSupabase compruebe la clave:

[functions.pinnfeed-drops]
verify_jwt = false

Despliega con supabase functions deploy pinnfeed-drops. Revisa una vez la respuesta de /v1/drops para confirmar el formato de movedAt antes de fiarte de la clave primaria.

4. Programación#

Activa las extensiones pg_cron y pg_net. Guarda una sola vez en Supabase Vault la URL del proyecto y una clave secreta de Supabase (sb_secret_...), para que nunca aparezcan en el SQL del cron:

select vault.create_secret('https://YOUR_PROJECT.supabase.co', 'project_url');
select vault.create_secret('YOUR_SUPABASE_SECRET_KEY', 'secret_key');

Después programa la llamada leyendo ambos valores de vault.decrypted_secrets y enviando la clave en la cabecera apikey:

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
  );
$$);

Notas:

  • Usa la clave secreta, no la publicable. La clave publicable es pública: cualquiera que la tenga podría disparar la función.
  • apikey frente a Authorization. La documentación actual de Supabase envía las nuevas claves sb_secret_ en apikey; no son JWT, así que no dependas de Authorization: Bearer para ellas. Si tu proyecto aún usa el JWT heredado service_role (Supabase retira las claves heredadas a finales de 2026), deja verify_jwt activado y envía esa clave en apikey y en Authorization: Bearer, leyéndola de Vault del mismo modo.
  • Límite de Vault. pg_net escribe la clave descifrada en net.http_request_queue hasta enviar la petición, así que cualquier rol que pueda iniciar sesión en tu base de datos podría leerla mientras espera. Limita los accesos a la base de datos.
  • La clave de PinnFeed sigue en los secretos de la Edge Function (paso 2), nunca en el SQL.

Cada minuto son 1.440 llamadas al día: bien desde Hobby (sin límite diario), pero por encima de las 1.000 diarias de Free, así que en Free usa */2 * * * *.

Siguientes pasos#

¿Necesitas todos los precios y no solo las bajadas? Guarda snapshots de /v1/odds: patrón de Postgres. ¿Quieres push en vez de polling? Ejecuta un pequeño worker siempre encendido con el WebSocket de Builder (€99/mes). Prueba de 3 días; se pide tarjeta y se cobra después del día 3 salvo que canceles. Clave gratis.

Contenido educativo para mayores de 18 años. Sin pronósticos ni ganancias garantizadas. En España, apuesta solo con operadores con licencia de la DGOJ.

Preguntas frecuentes#

¿Puede una Edge Function mantener abierto el WebSocket?#

No de forma fiable: viven lo que dura la petición. Para el stream usa un proceso de larga duración (una VM, Fly, Railway, un servidor en casa).

¿Es segura aquí la clave secreta de Supabase?#

Sí, en el servidor: en Vault para la llamada del cron y comprobada dentro de la función. Nunca la expongas al navegador, ni tampoco la clave de PinnFeed.

¿Puedo usar Supabase Realtime para enviar datos a mi app?#

Sí: cuando las filas llegan a la tabla, Supabase Realtime puede emitir las inserciones a tu front end.

El plan WebSocket: Builder, 99 €/mes.

Cuotas de Pinnacle en tiempo real por un único WebSocket: los 13 deportes, todos los mercados, cuotas justas sin margen y cuotas de cierre. 3 días gratis y cancelas con un clic.

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.