Home / Blog / Amazon Product API on Supabase Edge Functions: Two Limits, Not One

Amazon Product API on Supabase Edge Functions: Two Limits, Not One

Run an Amazon Buy Box price-watch on Supabase Edge Functions, invoked on a schedule by pg_cron. retry.js needs no change at all; client.js needs exactly one line, reading the API key with Deno.env.get instead of process.env. The real traps: a scheduled call gets a silent 401 unless verify_jwt is explicitly off, and Supabase meters wall-clock duration and CPU time as two separate limits, so a CPU-cheap batch can still run out of real seconds.

November 13, 2026
6 min read
SellerMagnet Team
Share & Bookmark
Terminal showing a Supabase Edge Function invoked by pg_cron calling the SellerMagnet product lookup API

To call an Amazon product API from a Supabase Edge Function, write a Deno-runtime handler that reuses the TypeScript quickstart's own retry.js unchanged and client.js with one line adapted for Deno's secret-reading API. The two traps that decide whether a scheduled run actually works are platform-specific: Edge Functions reject a non-user caller with a silent 401 unless JWT verification is explicitly turned off for that function, and Supabase meters wall-clock duration and CPU time separately, so a batch that looks cheap by one measure can still run out of the other.

Key Takeaways

  • Edge Functions require a valid user JWT by default (verify_jwt = true). A scheduled call from pg_cron/pg_net sends the project's own secret key on the apikey header, not a user JWT, so it gets a platform-level 401 unless supabase/config.toml sets verify_jwt = false for that specific function.
  • Turning off verify_jwt removes the PLATFORM's check, not a caller check: the handler itself must still read the apikey header and compare it against a stored secret, exactly as isAuthorizedCronCall() does here, before touching SellerMagnet or the database.
  • Supabase meters wall-clock duration (150s free plan / 400s paid) and CPU time (2s, excluding async I/O) as two SEPARATE limits, a different shape from the single-knob or CPU-only duration limits covered for the sibling serverless platforms earlier in this series. A loop of awaited fetch() calls stays near 0ms of CPU time no matter how long it runs, so only a real elapsed-time check inside the batch, not either platform limit, keeps a long ASIN list from being cut off mid-request.
  • Secrets are read with Deno.env.get('NAME'), Supabase's documented way, not process.env; client.js changes exactly that one line from the TypeScript quickstart, retry.js needs no change since it has no environment dependency at all.
  • The price baseline lives in a table in the project's own database, read and written through two plain fetch() calls to its REST interface (no extra client library, no second storage product), injected as a small rest client so the same handler code is tested without any network access.

Why does a scheduled price-watch get a silent 401?

Supabase's own docs state the default plainly: "By default, Edge Functions require a valid JWT in the authorization header." A pg_cron job calling the function through pg_net sends the project's secret (or publishable) key on the apikey header instead - the documented pattern for "Cron jobs, workers, pg_net, and other Edge Functions" is the 'secret' auth mode, which requires verify_jwt = false for that function in supabase/config.toml. Skip that one line and every scheduled run gets a 401 before this file's code ever executes, which looks exactly like a broken schedule rather than an auth setting.

Supabase's documented auth modes and the verify_jwt setting each one needs (supabase.com/docs/guides/functions/auth, checked live Nov 2026).
Caller / modeverify_jwtWho it is for
'user' (default)truea signed-in user's own JWT on Authorization
'secret' (this post)falsethe project's own secret key on the apikey header - cron, pg_net, other functions
'publishable'falsethe project's publishable key on apikey - a public browser client
'none'falseany caller; the handler verifies the caller itself (e.g. a signed webhook)

client.js - one line adapted for Deno: Deno.env.get instead of process.env

// client.js - one fetch-based client, X-Api-Key header, 120 s timeout.
// Adapted from the TypeScript quickstart's client.js for the Deno runtime: Supabase Edge
// Functions read secrets with Deno.env.get(), not process.env (supabase.com/docs/guides/
// functions/secrets: "Read an environment variable with Deno.env.get"). Everything else -
// the ApiError shape, the retry-after parsing, the 120 s AbortController timeout - is byte
// for byte the same as the quickstart's version.

const API_BASE = Deno.env.get("SELLERMAGNET_API_BASE") ?? "https://sellermagnet-api.com/api/";

/**
 * @typedef {Object} ApiErrorShape
 * @property {number} status
 * @property {string} message
 * @property {number} retryAfter - seconds, 0 when the header was absent.
 */

/** Thrown for any non-2xx response or a failed JSON decode. */
export class ApiError extends Error {
  /** @param {ApiErrorShape} shape */
  constructor({ status, message, retryAfter }) {
    super(`${status}: ${message}`);
    this.name = "ApiError";
    this.status = status;
    this.retryAfter = retryAfter;
  }
}

/**
 * Send one GET to the API and return the decoded `data` field.
 * @template T
 * @param {string} endpoint - e.g. "amazon-product-lookup"
 * @param {Record<string, string>} params
 * @returns {Promise<T>}
 */
export async function get(endpoint, params) {
  const url = new URL(API_BASE + endpoint);
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);

  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 120_000); // server allows up to ~120 s
  let response;
  try {
    response = await fetch(url, {
      headers: { "X-Api-Key": Deno.env.get("SELLERMAGNET_API_KEY") ?? "" },
      signal: controller.signal,
    });
  } finally {
    clearTimeout(timeout);
  }

  const retryAfter = Number.parseInt(response.headers.get("retry-after") ?? "0", 10) || 0;
  let body;
  try {
    body = await response.json(); // a proxy's error page is HTML, not JSON
  } catch {
    throw new ApiError({ status: response.status, message: response.statusText, retryAfter });
  }
  if (response.ok && body.success) return body.data;
  throw new ApiError({ status: response.status, message: body.message ?? response.statusText, retryAfter });
}

retry.js - unmodified from the TypeScript quickstart

// retry.js - retries that respect billing: 429/500(once)/502/503 only.
// Unmodified from the TypeScript quickstart: it has no env or platform dependency at all.

import { ApiError } from "./client.js";

const RETRYABLE = new Set([429, 500, 502, 503]);

/**
 * @template T
 * @param {() => Promise<T>} call
 * @param {number} [attempts]
 * @returns {Promise<T>}
 */
export async function withRetry(call, attempts = 4) {
  let seen500 = false;
  for (let attempt = 1; ; attempt++) {
    try {
      return await call();
    } catch (err) {
      if (!(err instanceof ApiError)) throw err;
      const give500Twice = err.status === 500 && seen500;
      if (!RETRYABLE.has(err.status) || give500Twice || attempt === attempts) throw err;
      seen500 = seen500 || err.status === 500;
      const backoff = 2 ** attempt * 100; // ms, small for the test harness
      const wait = Math.max(backoff, err.retryAfter * 1000);
      await new Promise((resolve) => setTimeout(resolve, wait));
    }
  }
}

Checking the caller before any SellerMagnet or database call

Turning off verify_jwt removes the platform's own check, nothing more; the function becomes reachable by anyone who knows its URL unless the handler checks the caller itself. isAuthorizedCronCall() compares the apikey header against a secret stored in CRON_SECRET (read with Deno.env.get, same as the API key) and rejects a mismatch or a missing header with a 401, before checkBatch() makes a single SellerMagnet or database call - proven below by a test where the SellerMagnet call count stays at zero on a wrong secret.

baseline.js - the price baseline via two plain fetch() calls to PostgREST

// baseline.js - the last-seen Buy Box price per ASIN/marketplace, kept in a Postgres table
// read through Supabase's own PostgREST REST API (no supabase-js dependency: two plain
// fetch calls, consistent with "fake only HTTP" testing). A `rest` client is injected so
// tests can swap in a fake store, the same injection shape the Azure Functions post in this
// series uses for its TableClient.

const TABLE = "price_baseline";

/**
 * Build a tiny PostgREST client. SUPABASE_SECRET_KEY is read with Deno.env.get, never
 * process.env (see client.js); it bypasses Row Level Security, so this file is only ever
 * imported inside the Edge Function, never shipped to a browser.
 * @param {string} supabaseUrl
 * @param {string} secretKey
 */
export function makeRestClient(supabaseUrl, secretKey) {
  const headers = { apikey: secretKey, Authorization: `Bearer ${secretKey}`, "Content-Type": "application/json" };
  return {
    /** @returns {Promise<number|null>} */
    async readBaseline(asin, marketplaceId) {
      const url = `${supabaseUrl}/rest/v1/${TABLE}?asin=eq.${asin}&marketplace_id=eq.${marketplaceId}&select=price`;
      const res = await fetch(url, { headers });
      if (!res.ok) throw new Error(`baseline read failed: ${res.status}`);
      const rows = await res.json();
      const price = rows[0]?.price;
      return typeof price === "number" && Number.isFinite(price) && price > 0 ? price : null;
    },
    /** @returns {Promise<void>} */
    async writeBaseline(asin, marketplaceId, price) {
      const url = `${supabaseUrl}/rest/v1/${TABLE}`;
      const res = await fetch(url, {
        method: "POST",
        headers: { ...headers, Prefer: "resolution=merge-duplicates" }, // upsert on the (asin, marketplace_id) unique key
        body: JSON.stringify({ asin, marketplace_id: marketplaceId, price }),
      });
      if (!res.ok) throw new Error(`baseline write failed: ${res.status}`);
    },
  };
}
Table comparing wall-clock duration and CPU time limits for a Supabase Edge Function against the single-knob limits of the sibling serverless posts in this series
Supabase is the only platform in this series metering BOTH wall-clock duration and CPU time at once.

priceWatch.js - checkOne(), the wall-clock-budgeted checkBatch(), and the HTTP handler

// priceWatch.js - the price-watch logic behind a Supabase Edge Function, invoked on a
// schedule by pg_cron + pg_net (supabase.com/docs/guides/functions/schedule-functions).
//
// AUTH TRAP: Edge Functions require a valid user JWT by default (verify_jwt = true -
// supabase.com/docs/guides/functions/function-configuration). pg_cron's net.http_post call
// sends the project's own secret key on the `apikey` header, not a user JWT, so the deployed
// function's entry in supabase/config.toml must set `verify_jwt = false` for this function,
// and the handler (see requireCronSecret below) checks that header itself - otherwise every
// scheduled run gets a silent 401 before this file's code ever runs.
//
// BUDGET TRAP: Supabase meters wall-clock duration (150 s free plan / 400 s paid) AND CPU
// time (2 s, excluding await) SEPARATELY. A loop that only awaits fetch() stays near 0 ms of
// CPU time no matter how many ASINs it processes, so CPU time never catches a batch that is
// too big - only the wall-clock duration does. checkBatch stops and returns a partial result
// once WALL_CLOCK_BUDGET_MS of real elapsed time has passed, instead of assuming CPU time
// will ever intervene.

import { lookupProduct } from "./lookup.js";
import { withRetry } from "./retry.js";
import { ApiError } from "./client.js";

const MARKETPLACE_ID = "ATVPDKIKX0DER";
const DROP_THRESHOLD = 0.03; // report a Buy Box drop of 3% or more
export const WALL_CLOCK_BUDGET_MS = 140_000; // stay under the 150s free-plan duration limit

/**
 * Checks the Authorization the scheduler sent against the stored secret. Returns true only
 * for an exact match; false for a missing/placeholder/wrong header. The caller must still
 * set verify_jwt = false in config.toml for this function, or the platform itself answers
 * 401 before this check ever runs.
 * @param {string|null} apikeyHeader
 * @param {string} cronSecret
 */
export function isAuthorizedCronCall(apikeyHeader, cronSecret) {
  return typeof apikeyHeader === "string" && cronSecret.length > 0 && apikeyHeader === cronSecret;
}

/**
 * @param {ReturnType<import("./baseline.js").makeRestClient>} rest
 * @param {string} asin
 * @param {{log:(s:string)=>void, error:(s:string)=>void}} log
 * @returns {Promise<{asin:string, from:number, to:number, dropPct:number}|null>}
 */
export async function checkOne(rest, asin, log) {
  let current;
  try {
    current = await withRetry(() => lookupProduct(asin, MARKETPLACE_ID));
  } catch (err) {
    // Exhausted retries (or a non-retryable status): absorb it here and move on to the next
    // ASIN. Nothing about this platform reruns the whole function on an uncaught exception
    // (that is an Azure Timer-trigger-specific trap, not a Supabase one), but a thrown error
    // would still abort the rest of the batch for no reason, so it is still caught per ASIN.
    log.error(`${asin}: ${err instanceof ApiError ? err.message : String(err)}`);
    return null;
  }

  const price = current.buyBoxInfo.price; // null when there is no featured offer (N/A)
  const baseline = await rest.readBaseline(asin, MARKETPLACE_ID);

  let alert = null;
  if (price != null && baseline != null) {
    const dropPct = Math.round(((baseline - price) / baseline) * 1000) / 10;
    if (dropPct >= DROP_THRESHOLD * 100) {
      alert = { asin, title: current.title, from: baseline, to: price, dropPct };
    }
  }

  // Write the new baseline AFTER reading it and AFTER deciding on the alert, so a crash
  // before this point never loses the last good baseline.
  if (price != null) await rest.writeBaseline(asin, MARKETPLACE_ID, price);

  return alert;
}

/**
 * Runs checkOne() over a watch list, stopping early once WALL_CLOCK_BUDGET_MS of real time
 * has elapsed rather than finishing the whole list - the thing that actually runs out on
 * this platform is wall-clock duration, not CPU time.
 * @param {ReturnType<import("./baseline.js").makeRestClient>} rest
 * @param {string[]} asins
 * @param {{log:(s:string)=>void, error:(s:string)=>void}} log
 * @param {() => number} [now] - injectable clock for the test harness.
 */
export async function checkBatch(rest, asins, log, now = Date.now) {
  const start = now();
  const alerts = [];
  const checked = [];
  let stoppedEarly = false;
  for (const asin of asins) {
    if (now() - start >= WALL_CLOCK_BUDGET_MS) {
      stoppedEarly = true;
      break;
    }
    const alert = await checkOne(rest, asin, log);
    checked.push(asin);
    if (alert) alerts.push(alert);
  }
  for (const alert of alerts) {
    log.log(`ALERT ${alert.asin} ${alert.from} -> ${alert.to} (-${alert.dropPct}%)`);
  }
  return { alerts, checked, stoppedEarly };
}

/**
 * The Edge Function's HTTP handler (wired to Deno.serve in index.ts). Rejects a call whose
 * apikey header does not match CRON_SECRET before doing any SellerMagnet or database work.
 * @param {Request} req
 * @param {ReturnType<import("./baseline.js").makeRestClient>} rest
 * @param {string} cronSecret
 * @param {string[]} asins
 */
export async function handleRequest(req, rest, cronSecret, asins) {
  const apikeyHeader = req.headers.get("apikey");
  if (!isAuthorizedCronCall(apikeyHeader, cronSecret)) {
    return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 });
  }
  const result = await checkBatch(rest, asins, console);
  return new Response(JSON.stringify(result), { status: 200, headers: { "Content-Type": "application/json" } });
}

Wiring it to Deno.serve and pg_cron

The handler above is deployed by exporting a Deno.serve default from the function's index.ts, and invoked on a schedule by a pg_cron job that POSTs to it through pg_net with the project's secret key on apikey - the exact pattern Supabase's own scheduling guide documents for calling an Edge Function periodically. Neither index.ts nor the SQL schedule makes its own network call worth faking in a test harness; both only wire together the functions already proven above.

index.ts - Deno.serve entry point (illustrative wiring, not separately tested)

// index.ts - Supabase Edge Function entry point. Export a default `fetch` handler (per
// Supabase's own guidance, not Deno.serve's older bare form) and delegate to the tested
// handleRequest() from priceWatch.js unchanged.
//
// supabase/config.toml MUST also set verify_jwt = false for this function, or the platform
// rejects pg_cron's apikey-header call with a 401 before this file ever runs:
//
//   [functions.price-watch]
//   verify_jwt = false
//
// Scheduled with pg_cron + pg_net (supabase.com/docs/guides/functions/schedule-functions):
//
//   select cron.schedule('price-watch', '*/30 * * * *', $$
//     select net.http_post(
//       url := (select decrypted_secret from vault.decrypted_secrets where name = 'project_url')
//              || '/functions/v1/price-watch',
//       headers := jsonb_build_object('apikey',
//         (select decrypted_secret from vault.decrypted_secrets where name = 'secret_key'))
//     ) as request_id;
//   $$);

import { handleRequest } from "./priceWatch.js";
import { makeRestClient } from "./baseline.js";

const rest = makeRestClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SECRET_KEY")!);
const cronSecret = Deno.env.get("CRON_SECRET")!;
const watchAsins = (Deno.env.get("WATCH_ASINS") ?? "").split(",").map((s) => s.trim()).filter(Boolean);

Deno.serve((req) => handleRequest(req, rest, cronSecret, watchAsins));
Sequence diagram of pg_cron invoking a Supabase Edge Function that checks the apikey header before calling SellerMagnet
The apikey check happens inside the handler, before any SellerMagnet or database call.
SellerMagnet API status codes, billing and this function's reaction.
StatusMeaningBilledReaction
200SuccessYesUse data
401 / 403Key or creditsNoStop
404Not on this marketplaceYesAbsorbed, logged, never re-thrown
429Concurrency capNoWait Retry-After, retried
500Internal errorNo, refundedRetry once
502 / 503Upstream / breakerYesWait, then retry, capped at 4, then absorbed

Real output from the tested harness (fakefetch.mjs faking globalThis.fetch only, illustrative data)

first-run-no-baseline: calls=1 rest=2 threw=null result=null stored=[["B0CL61F39H",444.99]]
unchanged-no-alert: calls=1 rest=2 threw=null result=null stored=[["B0CL61F39H",444.99]]
real-drop-fires-alert: calls=1 rest=2 threw=null result={"asin":"B0CL61F39H","title":"PS5 console","from":444.99,"to":399,"dropPct":10.3} stored=[["B0CL61F39H",399]]
small-drop-under-threshold: calls=1 rest=2 threw=null result=null stored=[["B0SMALL1",98]]
price-rose-no-alert: calls=1 rest=2 threw=null result=null stored=[["B0RISE1",120]]
no-buybox-offer-na: calls=1 rest=1 threw=null result=null stored=[["B0NA001",50]]
err: B0UNKNOWN1: 404: Product not found
404-absorbed: calls=1 rest=0 threw=null result=null stored=[]
429-then-ok: calls=2 rest=2 threw=null result=null stored=[["B0RATE001",19.99]]
500-refunded-retried-once: calls=2 rest=2 threw=null result=null stored=[["B0ERR500",29.99]]
err: B0RETRY502: 503: breaker open
502-503-capped-absorbed: calls=4 rest=0 threw=null result=null stored=[]
err: B0NOKEY001: 401: Invalid or inactive API key.
401-absorbed: calls=1 rest=0 threw=null result=null stored=[]
auth-correct-secret: true
auth-wrong-secret: false
auth-missing-header: false
cron-call-rejected-bad-secret: calls=0 rest=0 threw=null result={"status":401,"body":{"error":"unauthorized"}} stored=[]
cron-call-accepted-correct-secret: calls=1 rest=2 threw=null result={"status":200,"body":{"alerts":[],"checked":["B0CL61F39H"],"stoppedEarly":false}} stored=[["B0CL61F39H",444.99]]
wall-clock-budget-stops-batch-early: calls=2 rest=4 threw=null result={"alerts":[],"checked":["B0A","B0B"],"stoppedEarly":true} stored=[["B0A",444.99],["B0B",444.99]]
WALL_CLOCK_BUDGET_MS=140000
---
PASS=26 FAIL=0
ALL 26/26 SCENARIOS PASS

Frequently Asked Questions

Why does a scheduled price-watch get a 401 on every run even with a valid secret key?

Edge Functions require a valid user JWT by default (verify_jwt = true). pg_cron's net.http_post call sends the project's secret key on the apikey header, not a user JWT, so the platform rejects it before the function's own code runs - unless supabase/config.toml sets verify_jwt = false for that function.

Does the 2-second CPU time limit stop a batch that calls a slow external API many times?

No. CPU time excludes time spent waiting on fetch(), so a loop of awaited HTTP calls stays near 0 ms of CPU time no matter how long it runs. The limit that actually stops it is the separate wall-clock duration cap (150 s free / 400 s paid), which is why this post's batch checks the real elapsed time itself instead of trusting either limit.

Can client.js be reused unmodified from the TypeScript quickstart?

Almost: retry.js needs no change at all, since it has no environment or platform dependency. client.js needs exactly one line changed - the API key is read with Deno.env.get('SELLERMAGNET_API_KEY') instead of process.env.SELLERMAGNET_API_KEY, Supabase's own documented way to read a secret inside an Edge Function.

Why not use a key-value store for the price baseline?

An Edge Function already has zero-configuration access to its project's own database through a REST interface, so this post stores the baseline there with two plain fetch() calls rather than adding a second storage product just for one number per ASIN.

Bottom line: Supabase Edge Functions can run this price-watch with almost no change to the TypeScript quickstart's retry.js (unchanged) and client.js (one line, Deno.env.get), provided the scheduled caller's apikey header is checked inside the handler after verify_jwt is turned off for that function, and provided the batch watches its own wall-clock budget instead of trusting the separate, much smaller CPU-time limit to ever catch it. That combination of traps is different in shape from the other serverless and no-code platform guides in this series, including the Pipedream workflow version of the same price-watch. Every endpoint is documented on the API documentation page, more runnable snippets are on the code examples page, and a free account includes 150 credits to try it.

Ready to Extract Amazon Data at Scale?

Start building with SellerMagnet API today. Real-time product data, competitive pricing, and review analytics at your fingertips.

150 free API credits • No credit card required • Cancel anytime