Home / Blog / Amazon Product API on Vercel Functions: the Cron Job Is a Public URL

Amazon Product API on Vercel Functions: the Cron Job Is a Public URL

Run an Amazon Buy Box price-watch on a Vercel Function triggered by a Vercel Cron Job. client.js and retry.js need zero changes from the TypeScript quickstart. The real traps: a Cron Job path is a public URL unless the handler itself checks the CRON_SECRET bearer token Vercel sends, Hobby-plan cron jobs fire at most once a day with imprecise timing, and Vercel can miss or duplicate a cron delivery, so the handler must be safe to repeat.

November 13, 2026
6 min read
SellerMagnet Team
Share & Bookmark
Terminal showing a Vercel Function invoked by a Vercel Cron Job calling the SellerMagnet product lookup API

To call an Amazon product API from a Vercel Function, write a Next.js App Router route that reuses the TypeScript quickstart's own client.js and retry.js completely unchanged, then trigger it on a schedule with a Vercel Cron Job. The part that decides whether that schedule is safe is not the code reuse, it is two platform facts: a Cron Job path is a plain public URL that anyone can call unless the handler itself checks the CRON_SECRET bearer token, and Vercel's own docs say a cron delivery can be missed or duplicated with no platform-level retry, so the handler has to be safe to run twice for the same tick.

Key Takeaways

  • A Cron Job path is a public URL: Vercel's own migration guide states "anyone can call it unless you check CRON_SECRET." Vercel echoes that secret back on the Authorization request header (Bearer scheme) on every cron invocation, but the handler must compare it itself - a missing or wrong header gets no special treatment from the platform.
  • Hobby-plan cron jobs are capped at once per day, and even then Vercel "cannot assure a timely cron job invocation": a 1am expression can fire anywhere between 1:00 and 1:59am. Pro/Enterprise allow once-per-minute scheduling with minute-level precision.
  • Cron delivery can be missed or duplicated, and Vercel does not retry a failed invocation itself - a stronger, more explicit at-least-once-or-never contract than any sibling serverless platform earlier in this series. The handler stays safe to repeat because it compares against the LAST STORED baseline rather than trusting each tick to be exactly-once.
  • Vercel Functions use the Node.js runtime by default, so process.env reads a dashboard-configured variable exactly as the TypeScript quickstart expects; client.js and retry.js run unmodified, unlike the Deno (Supabase) or single-file (Pipedream) adaptations earlier in this series.
  • The price baseline lives in a Redis database installed from the Vercel Marketplace (e.g. Upstash) and linked to the project, read with the @upstash/redis SDK over HTTP (Redis.fromEnv()); @vercel/kv is deprecated and no longer provisionable for new projects. Same "create in the dashboard, read via process.env" pattern as CRON_SECRET and the SellerMagnet API key.

Why is a Cron Job path a public URL?

Vercel's own Cron Jobs documentation is explicit about what triggering a function on a schedule does NOT do: it does not make the endpoint private. "A Cron Job path is a public URL, so anyone can call it unless you check CRON_SECRET." Setting a CRON_SECRET environment variable on the project makes Vercel echo it back on the Authorization request header (Bearer scheme) on every invocation IT makes of that cron job - but that header arriving correctly on Vercel's own calls says nothing about a different caller hitting the same URL directly. isAuthorizedCronCall() below is the one line standing between the public internet and a billed SellerMagnet call.

Vercel's documented plan differences for Cron Jobs and Function duration (vercel.com/docs, checked live Nov 2026).
LimitHobbyPro / Enterprise
Cron frequencyonce per dayonce per minute
Cron timing precisionanywhere within the scheduled hourwithin the scheduled minute
Function max duration (Fluid compute)300s default and max300s default, 800s max (1800s beta)

client.js - unmodified from the TypeScript quickstart

// client.js - one fetch-based client, X-Api-Key header, 120 s timeout.

// Overridable only for pointing the client at a local test server; a real
// program never needs to set SELLERMAGNET_API_BASE.
const API_BASE = process.env.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": process.env.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.

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 KV call

isAuthorizedCronCall() compares the incoming Authorization header against Bearer ${CRON_SECRET} exactly - not just the raw secret, since Vercel sends the Bearer prefix on every real invocation. A missing header, a wrong secret, or the raw secret without the prefix all fail the same way: a 401 before checkBatch() makes a single SellerMagnet or KV call, proven below by a test where the call count stays at zero on a wrong secret.

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

// priceWatch.js - the price-watch logic behind a Vercel Function, invoked on a schedule by
// a Vercel Cron Job (vercel.com/docs/cron-jobs).
//
// AUTH TRAP: a Cron Job path is a public URL - "anyone can call it unless you check
// CRON_SECRET" (vercel.com/docs/schedules/migrate-from-cron-jobs, "Making the route
// private"). Vercel sends the project's CRON_SECRET environment variable back on the
// Authorization request header (Bearer scheme) on every cron invocation
// (vercel.com/docs/cron-jobs/manage-cron-jobs, "Securing cron jobs"). The handler below
// checks that header itself before any SellerMagnet or KV call - nothing on the platform
// does this for you.
//
// RELIABILITY TRAP: "Cron delivery can be missed or duplicated, and Vercel does not retry
// a failed invocation. Make the sync safe to repeat and reconcile changes since its last
// successful run." (vercel.com/kb/guide/background-workers-on-vercel). On the Hobby plan
// this is sharper still: a cron job can only fire once a day, and even then "Vercel cannot
// assure a timely cron job invocation" - an expression for 1am can land anywhere between
// 1:00 and 1:59am (vercel.com/docs/cron-jobs/usage-and-pricing). checkBatch is naturally
// safe to run twice for the same tick: it compares against the LAST STORED baseline, so a
// duplicate or re-run invocation with an unchanged price never re-fires the same alert.

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

/**
 * Checks the Authorization header Vercel sends on every cron invocation
 * (Bearer scheme) against the stored secret. Returns true only for
 * an exact `Bearer <secret>` match; false for a missing, malformed or wrong header.
 * @param {string|null} authHeader
 * @param {string} cronSecret
 */
export function isAuthorizedCronCall(authHeader, cronSecret) {
  return typeof authHeader === "string" && cronSecret.length > 0 && authHeader === `Bearer ${cronSecret}`;
}

/** Baseline key, one per ASIN + marketplace pair. */
function baselineKey(asin, marketplaceId) {
  return `price-watch:${marketplaceId}:${asin}`;
}

/**
 * @param {{get:(k:string)=>Promise<number|null>, set:(k:string,v:number)=>Promise<unknown>}} kv
 * @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(kv, 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 rather than aborting the whole batch for one bad lookup.
    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 key = baselineKey(asin, MARKETPLACE_ID);
  const baseline = await kv.get(key);

  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, and so a DUPLICATE invocation of
  // the same tick (missed-then-retried, or Vercel's own at-least-once cron behaviour) reads
  // the price it just wrote and correctly sees no further change.
  if (price != null) await kv.set(key, price);

  return alert;
}

/**
 * Runs checkOne() over a watch list. No wall-clock budget loop is needed here: with Fluid
 * compute (the current default on every plan), a Vercel Function's own maxDuration is 300s
 * by default even on Hobby, and a function that runs past it gets a clean 504
 * (FUNCTION_INVOCATION_TIMEOUT) rather than a half-finished partial batch to reason about -
 * a different shape of limit than the Supabase (wall-clock-vs-CPU-time) or Pipedream
 * (workflow-timeout) posts earlier in this series.
 * @param {{get:(k:string)=>Promise<number|null>, set:(k:string,v:number)=>Promise<unknown>}} kv
 * @param {string[]} asins
 * @param {{log:(s:string)=>void, error:(s:string)=>void}} log
 */
export async function checkBatch(kv, asins, log) {
  const alerts = [];
  const checked = [];
  for (const asin of asins) {
    const alert = await checkOne(kv, 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 };
}

/**
 * The Vercel Function's HTTP handler (wired to a Next.js App Router route.js GET export).
 * Rejects a call whose Authorization header does not match `Bearer ${CRON_SECRET}` before
 * doing any SellerMagnet or KV work.
 * @param {Request} req
 * @param {{get:(k:string)=>Promise<number|null>, set:(k:string,v:number)=>Promise<unknown>}} kv
 * @param {string} cronSecret
 * @param {string[]} asins
 */
export async function handleRequest(req, kv, cronSecret, asins) {
  const authHeader = req.headers.get("authorization");
  if (!isAuthorizedCronCall(authHeader, cronSecret)) {
    return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 });
  }
  const result = await checkBatch(kv, asins, console);
  return new Response(JSON.stringify(result), { status: 200, headers: { "Content-Type": "application/json" } });
}
Table comparing how each serverless platform in this series handles a retried or duplicated scheduled invocation
Vercel is the most explicit of this series about cron delivery being missed or duplicated, never retried by the platform itself.

Wiring it to a Next.js App Router route and a Vercel Cron Job

The handler above is deployed as a Next.js App Router GET export under app/api/price-watch/route.js, and invoked on a schedule by adding a matching entry to vercel.json's crons array. Neither file makes a network call worth faking in a test harness; both only wire together the functions already proven above.

app/api/price-watch/route.js - App Router entry point (illustrative wiring, not separately tested)

// app/api/price-watch/route.js - Next.js App Router entry point (illustrative wiring, not
// separately tested: it only wires together the functions already proven in priceWatch.js).
//
// Deploying this file under app/api/price-watch/route.js and adding a matching entry to
// vercel.json's "crons" array is what makes it a Vercel Cron Job target. Reading
// CRON_SECRET and SELLERMAGNET_API_KEY from process.env works unchanged because Vercel
// Functions use the Node.js runtime by default - the same client.js and retry.js from the
// TypeScript quickstart run here with ZERO lines changed, unlike the Deno (Supabase) or
// flattened-single-file (Pipedream) versions of this same price-watch earlier in the series.
//
// @vercel/kv is deprecated (vercel.com/docs/redis: "Vercel KV is no longer available");
// the baseline store is a Redis integration installed from the Marketplace (e.g. Upstash),
// read with the @upstash/redis SDK over HTTP - the linking step auto-populates the
// connection environment variables Redis.fromEnv() reads, the same pattern as CRON_SECRET.

import { Redis } from "@upstash/redis";
import { handleRequest } from "../../../priceWatch.js";

export const maxDuration = 300; // seconds; Hobby's own default and max with Fluid compute

const kv = Redis.fromEnv();
const WATCH_LIST = ["B0CL61F39H", "B0C77GVB5Y"];

export async function GET(request) {
  return handleRequest(request, kv, process.env.CRON_SECRET ?? "", WATCH_LIST);
}

vercel.json - the cron schedule (Hobby: at most once per day, imprecise within the hour)

{
  "crons": [
    { "path": "/api/price-watch", "schedule": "0 13 * * *" }
  ]
}
Sequence diagram of a Vercel Cron Job invoking a Vercel Function that checks the Authorization bearer header before calling SellerMagnet
The bearer-token check happens inside the handler, before any SellerMagnet or KV 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 alert=null stored={"price-watch:ATVPDKIKX0DER:B0CL61F39H":444.99}
first-run-no-baseline: PASS
unchanged-no-alert: alert=null
unchanged-no-alert: PASS
real-drop-fires-alert: {"asin":"B0CL61F39H","title":"PS5 console","from":444.99,"to":399,"dropPct":10.3}
real-drop-fires-alert: PASS
small-drop-under-threshold-no-alert: PASS
price-rose-no-alert-baseline-updates: PASS
no-buybox-offer-na-no-alert-baseline-kept: PASS
err: B0UNKNOWN1: 404: Product not found
404-absorbed-not-retried: PASS
429-free-retried-then-ok: PASS
500-refunded-retried-once: PASS
err: B0RETRY502: 503: breaker open
502-503-capped-at-4-absorbed: PASS
err: B0NOKEY001: 401: Invalid or inactive API key.
401-not-retried: PASS
auth-correct-bearer: PASS
auth-wrong-secret: PASS
auth-missing-header: PASS
auth-raw-secret-no-bearer-prefix-rejected: PASS
cron-call-rejected-bad-secret: status=401 body={"error":"unauthorized"} calls=0
cron-call-rejected-bad-secret: PASS
cron-call-accepted-correct-secret: status=200 body={"alerts":[],"checked":["B0CL61F39H"]}
cron-call-accepted-correct-secret: PASS
ALERT B0DUPTICK 444.99 -> 399 (-10.3%)
duplicate-invocation-same-tick: first=[{"asin":"B0DUPTICK","title":"PS5 console","from":444.99,"to":399,"dropPct":10.3}] second=[]
duplicate-invocation-first-fires: PASS
duplicate-invocation-second-does-not-refire: PASS
---
PASS=19 FAIL=0
ALL 19/19 SCENARIOS PASS

Frequently Asked Questions

Does Vercel retry a cron job if the function call fails?

No. Vercel's own guidance states plainly that cron delivery can be missed or duplicated and Vercel does not retry a failed invocation - the handler must be safe to run twice for the same tick, which this post's baseline-compare logic already is.

Why does every cron request need a Bearer token check?

A Cron Job path is a public URL. Vercel sends the project's CRON_SECRET as an Authorization: Bearer header on every invocation it makes, but it is the handler's own job to compare that header against the stored secret before billing any call.

Can a scheduled price-watch run more than once a day on the Hobby plan?

No. Hobby accounts are limited to cron expressions that fire at most once per day, and even a daily expression can land anywhere within the scheduled hour, not at a fixed minute. Per-minute precision needs the Pro plan.

Do client.js and retry.js need any change to run on Vercel?

No. Vercel Functions use the Node.js runtime by default, so process.env reads environment variables exactly as the TypeScript quickstart already expects - unlike the Deno (Supabase) or single-flattened-file (Pipedream) versions earlier in this series, nothing in client.js or retry.js changes for Vercel.

Where does the price baseline live between runs?

In a Redis database installed from the Vercel Marketplace (e.g. Upstash) and linked to the project, read with the @upstash/redis SDK's get/set over HTTP - no extra server or schema to manage for one number per ASIN; @vercel/kv is deprecated and no longer provisionable.

Bottom line: Vercel Functions run this price-watch with zero changes to the TypeScript quickstart's client.js and retry.js, provided the Cron Job's own CRON_SECRET bearer token is checked inside the handler (the platform never rejects a wrong caller for you), the Hobby plan's once-per-day and imprecise-timing limits are accounted for, and the handler stays safe to repeat for a missed-or-duplicated tick by comparing against the last stored baseline. That combination of traps is different in shape from the other serverless and no-code platform guides in this series, including the Supabase Edge Functions 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