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.
| Caller / mode | verify_jwt | Who it is for |
|---|---|---|
| 'user' (default) | true | a signed-in user's own JWT on Authorization |
| 'secret' (this post) | false | the project's own secret key on the apikey header - cron, pg_net, other functions |
| 'publishable' | false | the project's publishable key on apikey - a public browser client |
| 'none' | false | any 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}`);
},
};
}

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

| Status | Meaning | Billed | Reaction |
|---|---|---|---|
200 | Success | Yes | Use data |
401 / 403 | Key or credits | No | Stop |
404 | Not on this marketplace | Yes | Absorbed, logged, never re-thrown |
429 | Concurrency cap | No | Wait Retry-After, retried |
500 | Internal error | No, refunded | Retry once |
502 / 503 | Upstream / breaker | Yes | Wait, 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.