Home / Blog / Amazon Product API on Cloudflare Workers (No npm Dependencies)

Amazon Product API on Cloudflare Workers (No npm Dependencies)

Deploy an Amazon product lookup as a Cloudflare Worker by reusing the TypeScript quickstart's client.js and retry.js unmodified: Workers only meter CPU time, not the wait on fetch(), so SellerMagnet's up to 120-second scrape allowance never risks the Free plan's 10 ms CPU budget. The real ceiling is the 50-subrequest-per-invocation cap, which a 4-attempt retry respects by design.

November 10, 2026
6 min read
SellerMagnet Team
Share & Bookmark
Terminal showing a Cloudflare Worker fetch handler calling the SellerMagnet product lookup API

To call an Amazon product API from a Cloudflare Worker, write a fetch(request, env, ctx) handler that imports the SellerMagnet TypeScript quickstart's own client.js and retry.js unchanged, enable the nodejs_compat compatibility flag with a compatibility_date of 2025-04-01 or later so process.env is populated from the Worker's bindings, and store the key as a secret. No npm install, no bundler plugin and no AWS-style deployment package: Workers ship the file as-is.

Key Takeaways

  • Cloudflare meters CPU time, not wall-clock duration; waiting on fetch() is free, so the API's up to 120-second scrape allowance never risks the Workers Free plan's 10 ms CPU budget.
  • The real ceiling is subrequests: 50 fetch() calls per invocation on Free, 10,000 by default on Paid. A 4-attempt billing-aware retry per ASIN stays far under either.
  • With nodejs_compat and a 2025-04-01+ compatibility_date, process.env is populated from Worker secrets/vars, so the TypeScript quickstart's client.js runs completely unmodified.
  • A malformed ASIN or marketplace ID is rejected inside the Worker before any subrequest is made, so it never spends a credit.
  • This is the opposite constraint shape from the AWS Lambda version of this proxy, where the full wait counts against both the function's timeout and API Gateway's fixed 29-second cap.

Why doesn't the 120-second scrape allowance break the Workers Free plan?

Cloudflare's own documentation states it plainly: CPU time measures how long the CPU spends executing the Worker's code, and waiting on network requests such as fetch() does not count toward it. The Workers Free plan allows 10 ms of CPU time per request, but a lookup that sits idle for up to 120 seconds waiting on SellerMagnet's own scrape allowance spends essentially none of that budget computing; it is almost entirely idle wait. That is the opposite situation from AWS Lambda, where the function's configured timeout (and, behind API Gateway, a fixed 29-second integration timeout that cannot be raised) counts the full wait, which is why the Lambda version of this proxy needs a Function URL instead.

Cloudflare Workers resource limits that matter for an API proxy (checked live, Nov 2026).
ResourceWorkers FreeWorkers PaidWhy it matters here
CPU time / request10 ms5 min (default 30 s, configurable)fetch() wait is free on both
Subrequests / invocation5010,000 (up to 10M configurable)a 4-attempt retry uses <= 4
Requests / day100,000no limit (pay per request)not a concern for one lookup

Reusing client.js and retry.js unmodified

wrangler.jsonc sets compatibility_flags: ["nodejs_compat"] and a compatibility_date of 2025-04-01 or later. From that date, Cloudflare auto-populates process.env from the Worker's secrets and vars the first time process is touched, so the TypeScript quickstart's client.js, which reads process.env.SELLERMAGNET_API_KEY, needs no edits at all. The key itself is set with wrangler secret put SELLERMAGNET_API_KEY, never written into the config file.

wrangler.jsonc - compat flags, no API key stored in the file

{
  "name": "amazon-lookup-worker",
  "main": "worker.js",
  "compatibility_date": "2025-04-05",
  "compatibility_flags": ["nodejs_compat"],
  "vars": {},
  "limits": { "cpu_ms": 50 }
}

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

The Worker's own fetch handler

worker.js is the only new file. It parses asin and marketplaceId from the request's query string, rejects a malformed value before any subrequest is made (so a bad shape never spends a credit), then calls the quickstart's own lookupProduct through withRetry. lookupProduct and decodePrice come straight from the same quickstart, so a missing featured offer still decodes to null, not 0.

worker.js - the only new file: a thin fetch handler

// worker.js - Cloudflare Worker entry point: GET /lookup?asin=...&marketplaceId=...
// Imports client.js/retry.js/types.js/lookup.js UNCHANGED from the TypeScript quickstart
// (amazon-product-api-typescript.py); nothing in those four files is edited for Workers.
// process.env.SELLERMAGNET_API_KEY (read inside the unmodified client.js) is populated by the
// Workers runtime itself because wrangler.jsonc sets nodejs_compat + compatibility_date >=
// 2025-04-01 and declares SELLERMAGNET_API_KEY as a secret binding (facts-additions.md #3).

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

const ASIN_RE = /^[A-Z0-9]{10}$/;
const MARKETPLACE_RE = /^A[A-Z0-9]{9,12}$/; // Amazon marketplace IDs, e.g. ATVPDKIKX0DER

/**
 * @param {Request} request
 * @param {Record<string, string>} env - Workers bindings (used only to prove they exist;
 *   the actual key read happens inside the unmodified client.js via process.env).
 * @param {ExecutionContext} ctx
 * @returns {Promise<Response>}
 */
export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    const asin = (url.searchParams.get("asin") ?? "").toUpperCase();
    const marketplaceId = url.searchParams.get("marketplaceId") ?? "";

    // Reject a malformed ASIN or marketplace ID before any subrequest is made: a bad shape
    // can never succeed, so failing closed here never spends a credit (same convention as
    // every other quickstart's pre-flight check).
    if (!ASIN_RE.test(asin)) {
      return Response.json({ error: `"${asin}" is not a 10-character ASIN - rejected, no credit spent` },
                            { status: 400 });
    }
    if (!MARKETPLACE_RE.test(marketplaceId)) {
      return Response.json({ error: `"${marketplaceId}" is not a marketplace ID - rejected, no credit spent` },
                            { status: 400 });
    }

    try {
      // withRetry caps at 4 attempts: at most 4 of the Workers Free plan's 50
      // subrequests/invocation (facts-additions.md #2), so one lookup never risks that cap.
      const product = await withRetry(() => lookupProduct(asin, marketplaceId));
      return Response.json({ asin: product.asin, title: product.title, link: product.link,
                              price: product.buyBoxInfo.price, currencyCode: product.buyBoxInfo.currencyCode });
    } catch (err) {
      if (err instanceof ApiError) {
        // Billed 404 (not found on this marketplace) is a real answer, not a server error.
        return Response.json({ error: err.message }, { status: err.status === 404 ? 404 : 502 });
      }
      throw err;
    }
  },
};

Which errors should the Worker retry?

Exactly the same rule as every other quickstart in this series: retry 429, 500 (once) and 502/503 (capped at 4 attempts total), and never retry 400, 401, 403 or 404, since none of those can succeed on a second try and a 404 is a real, billed answer. A 500 without a Retry-After header is refunded, so the one retry it gets is effectively free; 502 and 503 are billed on every attempt, which is exactly why the cap exists.

Status codes, billing and the Worker's reaction (checked live, Nov 2026).
StatusMeaningBilledWorker action
200SuccessYesUse data
400 (bad asin/marketplace)Rejected before any callNoFix the input
400 (gate)Marketplace not coveredNoFix the call
401 / 403Key or creditsNoStop
404Not on this marketplaceYesStore as missing
429Concurrency capNoWait Retry-After
500Internal errorNo, refundedRetry once
502 / 503Upstream / breakerYesWait, then retry, capped at 4
Table comparing Cloudflare Workers Free and Paid plan CPU time and subrequest limits for an Amazon API proxy
CPU time is nearly free for an I/O-bound lookup; subrequests are the real budget.
Sequence diagram of a Cloudflare Worker retrying a free 429 after Retry-After and recording a billed 404 without retrying
Wait and retry a free 429; record a billed 404 and stop, same as every other quickstart.

Real output from the tested harness (fakefetch.mjs, illustrative data)

GET /lookup?asin=B0CL61F39H -> 200 {"asin":"B0CL61F39H","title":"PlayStation(R)5 console (slim)","link":"https://www.amazon.com/dp/B0CL61F39H","price":444.99,"currencyCode":"USD"}  (subrequests used: 1)
GET /lookup?asin=B0NOOFFER1 -> 200 {"asin":"B0NOOFFER1","title":"No featured offer","link":"x","price":null,"currencyCode":"USD"}  (N/A decoded to null, not 0)
GET /lookup?asin=bad-asin -> 400 {"error":"\"BAD-ASIN\" is not a 10-character ASIN - rejected, no credit spent"}  (subrequests used: 0, rejected before any call)
GET /lookup?asin=B0UNKNOWN1 -> 404 {"error":"404: Product not found"}  (billed, not retried, subrequests: 1)
GET /lookup?asin=B0RETRY42X -> 200 {"asin":"B0RETRY42X","title":"t","link":"x","price":19.99,"currencyCode":"USD"}  (429 then 200, subrequests: 2, free retry)
GET /lookup?asin=B0ALWAYSFL -> 502 {"error":"503: breaker open"}  (capped at 4 of 4 attempts, 50-subrequest Free budget intact)

Frequently Asked Questions

Does SellerMagnet's 120-second scrape allowance overrun a Worker's CPU time limit?

No. Cloudflare only meters CPU time, the time the Worker spends computing; time spent waiting on fetch() does not count. A lookup that waits up to 120 seconds for SellerMagnet still uses well under 10 ms of actual CPU time, so it fits even the Workers Free plan.

What is the real limit a retry loop has to respect on Cloudflare Workers?

The subrequest cap: 50 fetch() calls per invocation on the Free plan, 10,000 by default on Paid. A single lookup's 4-attempt retry cap uses at most 4, well under either limit; a Worker that fans out to many ASINs per request must add up attempts times ASINs against this cap instead.

Do I need to edit the TypeScript quickstart's client.js to use it on Workers?

No. With the nodejs_compat compatibility flag and a compatibility_date of 2025-04-01 or later, Cloudflare populates process.env from the Worker's bindings and secrets, so the unmodified client.js (which reads process.env.SELLERMAGNET_API_KEY) works as-is.

Where does the API key live, and is it safe to put it in wrangler.jsonc?

As a secret binding, set with wrangler secret put SELLERMAGNET_API_KEY, never written into wrangler.jsonc or the repository. Cloudflare's own docs warn against ever overwriting the whole process.env object, since that would drop other bindings; this Worker only reads it.

How is this different from the AWS Lambda version of this proxy?

On Lambda, the full wait counts against the function's own timeout, and a Lambda behind API Gateway is further capped at a fixed, non-raisable 29-second integration timeout, which is why that guide uses a Function URL. On Workers, waiting on fetch() is free of the CPU-time budget entirely, so the 120-second allowance is a non-issue on either plan.

Can this Worker call another Worker on the same domain instead of the public API?

Not with a plain fetch() to a Custom Domain on the same zone; Cloudflare's own docs say that fails. Worker-to-Worker calls need a Service Binding instead; this guide calls the public sellermagnet-api.com endpoint, so it does not need one.

Bottom line: a Cloudflare Worker can run this proxy with zero new dependencies, reusing the TypeScript quickstart's client.js and retry.js unmodified once nodejs_compat and a current compatibility date are set. CPU time is effectively free for this workload since the wait happens on fetch(), so the limit to design around is subrequests, not duration, which is the opposite of the AWS Lambda version. Every endpoint is documented on the API documentation page, and more runnable snippets are on the code examples page; 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