A barcode lookup in JavaScript is one fetch call: send the UPC, EAN or ISBN to a lookup API with your key in a header, and you get product JSON back. Node.js 18 and later ship fetch built in, so you don't need any dependencies. The real work is everything around that call: keeping the key out of the browser, handling errors by code, backing off on 429s, batching, and caching so you don't pay twice for the same barcode.
This guide builds that stack, a production-ready UPC lookup API client for Node, against the BarcodeReport API, piece by piece. Every snippet is plain modern JavaScript (ES modules), and TypeScript types come at the end.
The ten-line version
Grab a free key at /register (100 lookups a month, 60 requests a minute, no card) and try it:
const res = await fetch(
"https://barcodereport.com/api/search_barcode?barcode=036000291452",
{ headers: { "X-API-Key": process.env.BARCODEREPORT_API_KEY } },
);
const data = await res.json();
console.log(res.status, data.title ?? data.error);
Run it with BARCODEREPORT_API_KEY=... node quick.mjs. A hit returns a flat object: barcode and title are always present, while brand, category, image_url, min_price and the rest can be null. Coverage is deepest on titles, and brand, image and price exist for a smaller share of records, so write your UI to work with only a title.
Send the key in the X-API-Key (or Authorization: Bearer) header. The legacy ?api_key= query parameter still works, but query strings end up in server logs, proxies and browser history.
Never put the API key in the browser
Anything shipped to a browser is public, including environment variables your bundler inlines. If your key lives in a variable named NEXT_PUBLIC_*, VITE_* or REACT_APP_*, it's in your JavaScript bundle, and anyone can copy it and burn your quota.
The fix is a thin proxy. The browser calls your endpoint, and your server adds the key and calls the API:
Browser ──> GET /api/barcode/036000291452 ──> your server (adds X-API-Key) ──> BarcodeReport API
First, the server-side client that every route will share.
// Server-side only: this module reads your secret key.
const API_BASE = "https://barcodereport.com/api";
export class BarcodeApiError extends Error {
constructor(message, status, code) {
super(message);
this.name = "BarcodeApiError";
this.status = status;
this.code = code;
}
}
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Retry-After is either seconds ("30") or an HTTP date. Returns milliseconds.
function retryAfterMs(header) {
if (!header) return undefined;
const seconds = Number(header);
if (Number.isFinite(seconds)) return seconds * 1000;
const at = Date.parse(header);
return Number.isNaN(at) ? undefined : Math.max(0, at - Date.now());
}
// 0.5s, 1s, 2s, 4s, each with up to 100% random jitter
const backoffMs = (attempt) => 500 * 2 ** attempt * (1 + Math.random());
export async function call(path, body, maxRetries = 4) {
const key = process.env.BARCODEREPORT_API_KEY;
if (!key) throw new Error("BARCODEREPORT_API_KEY is not set");
for (let attempt = 0; ; attempt++) {
let res;
try {
res = await fetch(API_BASE + path, {
method: body ? "POST" : "GET",
headers: {
"X-API-Key": key,
Accept: "application/json",
...(body && { "Content-Type": "application/json" }),
},
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(15_000),
});
} catch (err) {
if (attempt >= maxRetries) throw err; // network error or timeout
}
const retryable = !res || res.status === 429 || res.status >= 500;
if (!retryable || attempt >= maxRetries) return res;
const wait = res?.status === 429
? retryAfterMs(res.headers.get("Retry-After")) ?? backoffMs(attempt)
: backoffMs(attempt);
await res?.body?.cancel(); // free the connection before sleeping
await sleep(wait);
}
}
export async function toError(res) {
const data = await res.json().catch(() => ({}));
return new BarcodeApiError(
data.error ?? `HTTP ${res.status}`,
res.status,
data.code ?? `http_${res.status}`,
);
}
/** Resolves to the product, or null if the barcode isn't in the database. */
export async function lookupBarcode(barcode) {
const res = await call(`/search_barcode?barcode=${encodeURIComponent(barcode)}`);
if (res.ok) return res.json();
const err = await toError(res);
if (err.code === "not_found") return null; // a 404 still counts as a lookup
throw err;
}
Two decisions in there are deliberate. A 429 waits for exactly as long as Retry-After says, because guessing shorter just earns another 429. Server errors and network failures get exponential backoff with jitter, so ten workers that fail together don't all retry at the same instant.
Handle errors by code, not status
Every error body is JSON shaped like {"error": "...", "code": "..."}. Switch on code. On the legacy /api/* endpoints, a bad key and an exhausted quota both return 403, so the status alone can't tell you whether to fix your config or wait for your quota to reset.
| HTTP | code |
Meaning | What your code should do |
|---|---|---|---|
| 400 | bad_request |
Malformed request | Fix the input. Don't retry. |
400 (401 on /api/v1) |
missing_key |
No key sent | Fix your config. Don't retry. |
403 (401 on /api/v1) |
invalid_key |
Key wrong or revoked | Alert someone and stop all calls. |
403 (402 on /api/v1) |
quota_exceeded |
Monthly lookups used up | Stop. Check usage_reset_at from GET /api/account. |
| 404 | not_found |
Code isn't in the database | Cache the miss. It still counted as a lookup. |
| 429 | rate_limited |
Too many requests per minute | Wait for Retry-After, then retry. |
| 500 | server_error |
Something broke upstream | Retry a few times with backoff. |
GET /api/account is free to call and returns plan_name, plan_limit, api_usage, usage_remaining and usage_reset_at. Call it before a big job instead of discovering the limit halfway through.
The proxy route
Express
Validate input before calling upstream. A 404 costs a lookup, so rejecting a mistyped barcode locally saves money. The check digit catches every single-digit typo.
export function normalizeBarcode(input) {
const code = String(input ?? "").trim().toUpperCase().replace(/[\s-]/g, "");
if (/^\d{9}[\dX]$/.test(code)) return isValidIsbn10(code) ? code : null;
if (!/^(\d{8}|\d{12,14})$/.test(code)) return null;
return isValidGtin(code) ? code : null;
}
function isValidGtin(code) {
const digits = [...code].map(Number);
const check = digits.pop();
const sum = digits.reverse().reduce((acc, d, i) => acc + d * (i % 2 === 0 ? 3 : 1), 0);
return (10 - (sum % 10)) % 10 === check;
}
function isValidIsbn10(code) {
const sum = [...code].reduce((acc, ch, i) => acc + (ch === "X" ? 10 : Number(ch)) * (10 - i), 0);
return sum % 11 === 0;
}
normalizeBarcode("036000291452") returns the code, normalizeBarcode("036000291453") returns null, and "0-306-40615-2" normalizes to the valid ISBN-10 0306406152. The API matches an ISBN-10 to the same record as its ISBN-13, so you don't need to convert.
import express from "express";
import { rateLimit } from "express-rate-limit";
import { BarcodeApiError } from "./lib/barcodereport.js";
import { cachedLookup } from "./lib/cache.js"; // see the caching section below
import { normalizeBarcode } from "./lib/validate.js";
const app = express();
// Without a limit, your proxy becomes a free, unlimited front door to your quota.
app.use("/api/barcode", rateLimit({ windowMs: 60_000, limit: 30 }));
app.get("/api/barcode/:code", async (req, res) => {
const code = normalizeBarcode(req.params.code);
if (!code) {
res.status(400).json({ error: "Not a valid UPC, EAN, GTIN or ISBN" });
return;
}
try {
const product = await cachedLookup(code);
if (!product) {
res.status(404).json({ error: "Barcode not found", code: "not_found" });
return;
}
res.set("Cache-Control", "public, max-age=86400").json(product);
} catch (err) {
const { status, error } = toClientError(err);
res.status(status).json({ error });
}
});
function toClientError(err) {
if (err instanceof BarcodeApiError) {
switch (err.code) {
case "rate_limited":
return { status: 503, error: "Busy, try again in a minute" };
case "quota_exceeded":
console.error("BarcodeReport quota exhausted"); // page someone
return { status: 503, error: "Lookups temporarily unavailable" };
case "invalid_key":
case "missing_key":
console.error("BarcodeReport key problem:", err.message);
return { status: 500, error: "Server misconfigured" };
}
}
console.error(err);
return { status: 502, error: "Lookup failed" };
}
app.listen(3000);
The browser side never sees the key:
const res = await fetch(`/api/barcode/${encodeURIComponent(scanned)}`);
if (res.status === 404) showNotFound(scanned);
else if (!res.ok) showError((await res.json()).error);
else renderProduct(await res.json());
Serverless (Next.js route handler)
On serverless platforms, an in-memory cache doesn't survive between invocations, so let the CDN do the caching with s-maxage:
import { lookupBarcode, BarcodeApiError } from "@/lib/barcodereport";
import { normalizeBarcode } from "@/lib/validate";
export async function GET(request) {
const code = normalizeBarcode(new URL(request.url).searchParams.get("code"));
if (!code) return Response.json({ error: "Invalid barcode" }, { status: 400 });
try {
const product = await lookupBarcode(code);
if (!product) {
return Response.json({ error: "Barcode not found" }, {
status: 404,
headers: { "Cache-Control": "public, s-maxage=3600" },
});
}
return Response.json(product, {
headers: { "Cache-Control": "public, s-maxage=86400, stale-while-revalidate=604800" },
});
} catch (err) {
const busy = err instanceof BarcodeApiError &&
["rate_limited", "quota_exceeded"].includes(err.code);
console.error(err);
return Response.json({ error: "Lookup failed" }, { status: busy ? 503 : 502 });
}
}
Store BARCODEREPORT_API_KEY in your platform's server-side environment settings, with no public prefix.
Caching: don't pay twice for one barcode
Product data for a given GTIN rarely changes, and a lot of lookup traffic repeats: the same bestsellers, the same warehouse items. A small cache with two TTLs pays for itself quickly.
import { lookupBarcode } from "./barcodereport.js";
const DAY = 24 * 60 * 60 * 1000;
const HIT_TTL = 7 * DAY; // products rarely change
const MISS_TTL = 1 * DAY; // re-check misses daily: new codes get added
const MAX_ENTRIES = 50_000;
const cache = new Map(); // code -> { value, expires }
const inflight = new Map(); // code -> Promise, so 10 identical requests make 1 call
export function cachedLookup(code) {
const hit = cache.get(code);
if (hit && hit.expires > Date.now()) return Promise.resolve(hit.value);
if (inflight.has(code)) return inflight.get(code);
const promise = lookupBarcode(code)
.then((value) => {
cache.delete(code);
if (cache.size >= MAX_ENTRIES) cache.delete(cache.keys().next().value); // oldest
cache.set(code, { value, expires: Date.now() + (value ? HIT_TTL : MISS_TTL) });
return value;
})
.finally(() => inflight.delete(code));
inflight.set(code, promise);
return promise;
}
Caching misses matters as much as caching hits. On the single-lookup endpoint a 404 counts against your quota, and a broken scanner that resubmits the same bad code can drain a plan fast. Once you run more than one server instance, move this Map into Redis with the same TTLs.
Batching: up to 100 codes per request
For catalog imports, POST /api/v1/lookup takes {"barcodes": [...]} with up to 100 codes and returns {count, found, results}. Each result carries requested_barcode, found and, for hits, product. Three rules shape the client:
- One lookup per distinct code. Deduplicate anyway, so your counts and logs make sense.
-
All-or-nothing against quota. If a chunk would exceed your remaining lookups, the whole request is rejected with 402
quota_exceeded. - Slower request rate. Batch and search calls are limited to one-fifth of your plan's per-minute rate, with a minimum of 30 a minute.
| Plan (monthly) | Lookups | Requests/min | Batch requests/min | Max codes/min via batch |
|---|---|---|---|---|
| Free | 100 | 60 | 30 | 3,000 |
| Standard ($24.95) | 10,000 | 300 | 60 | 6,000 |
| Premium ($74.95) | 50,000 | 600 | 120 | 12,000 |
| Professional ($99.99) | 100,000 | 1,200 | 240 | 24,000 |
An async generator fits this well. It paces requests, and the caller saves each chunk as it arrives, so a quota error at chunk 7 doesn't throw away chunks 1 through 6.
export async function* lookupInBatches(barcodes, batchesPerMinute = 30) {
const unique = [...new Set(barcodes)];
const intervalMs = Math.ceil(60_000 / batchesPerMinute);
for (let i = 0; i < unique.length; i += 100) {
const started = Date.now();
const res = await call("/v1/lookup", { barcodes: unique.slice(i, i + 100) });
if (!res.ok) throw await toError(res); // 402 quota_exceeded, 401 key problems
const { results } = await res.json();
yield results;
const wait = intervalMs - (Date.now() - started);
if (i + 100 < unique.length && wait > 0) await sleep(wait);
}
}
import { readFile } from "node:fs/promises";
import { lookupInBatches } from "./lib/barcodereport.js";
import { normalizeBarcode } from "./lib/validate.js";
const lines = (await readFile("codes.txt", "utf8")).split(/\r?\n/);
const codes = [...new Set(lines.map(normalizeBarcode).filter(Boolean))];
let found = 0;
for await (const results of lookupInBatches(codes, 60)) { // Standard: 300/min ÷ 5
for (const r of results) {
if (r.found) found++;
// write r to your database or CSV here
}
}
console.log(`${found} of ${codes.length} found`);
If you only need the matches once, and not an integration, bulk lookup does the same thing without code. Upload a CSV of up to 100,000 barcodes and a free quick check tells you how many match before you pay.
TypeScript types for the response
These types follow the documented field list. A few fields (price, page count, authors) are typed loosely on purpose; tighten them against the API reference once you've seen real responses.
export interface BarcodeProduct {
barcode: string;
title: string;
old_barcode: string | null;
brand: string | null;
category: string | null; // ">"-delimited path, e.g. "Food > Snacks"
description: string | null;
image_url: string | null;
min_price: number | string | null;
price_currency: string | null;
authors: string | string[] | null;
number_of_pages: number | string | null;
publish_date: string | null;
isbn: string | null;
ean_13: string | null;
upc_a: string | null;
gtin_14: string | null;
model_number: string | null;
color: string | null;
size: string | null;
weight: string | null;
product_dimension: string | null;
amazon_asin: string | null;
}
export interface BatchLookupResult {
requested_barcode: string;
found: boolean;
product?: BarcodeProduct | null; // assumed same shape as single lookup
}
export interface BatchLookupResponse {
count: number;
found: number;
results: BatchLookupResult[];
}
export type ApiErrorCode =
| "bad_request" | "missing_key" | "invalid_key" | "quota_exceeded"
| "not_found" | "rate_limited" | "server_error";
export interface ApiErrorBody {
error: string;
code: ApiErrorCode | (string & {});
}
In plain JavaScript you get the same editor help through JSDoc: /** @returns {Promise<import("./types/barcodereport").BarcodeProduct | null>} */. Turning the category path into breadcrumbs is one line: product.category?.split(">").map((s) => s.trim()).
Frequently asked questions
You can, but you shouldn't with a paid key. Anything in browser code, including bundled environment variables, is visible to every visitor, who can copy the key and spend your quota. Put a small server route or serverless function in between: the browser calls your endpoint, and your server adds the API key, validates input, caches results and rate-limits callers.
No. Node.js 18 and later include a global fetch, along with AbortSignal.timeout for request timeouts, which covers everything a lookup client needs. Libraries like axios still work if your codebase already uses them, but for a new integration the built-in fetch keeps your dependency list shorter and behaves the same way as fetch in the browser.
Read the Retry-After header and wait at least that long before retrying the same request. It may contain seconds or an HTTP date, so parse both. Then slow down overall: keep requests under your plan's per-minute limit and remember that batch endpoints usually have a lower rate. Retrying immediately after a 429 only produces more 429s.
BarcodeReport's batch endpoint, POST /api/v1/lookup, accepts up to 100 barcodes per request and charges one lookup per distinct code. The request is all-or-nothing against your quota, and batch calls are limited to one-fifth of your plan's per-minute rate, with a minimum of 30 per minute. For one-off jobs of up to 100,000 codes, the no-code bulk lookup is simpler.
Yes. Product data for a given barcode changes rarely, and lookup traffic repeats heavily, so caching cuts both cost and latency. Cache hits for days and misses for about a day, because new codes get added over time. Cache misses too, since a not-found response on a single lookup still counts against your quota.
Next steps
- Get a free API key: 100 lookups a month, enough to test everything above on your own barcodes.
- API documentation: full field reference, the search endpoint and response headers.
- Barcode lookup in Python: the same patterns if part of your stack runs Python.
- Build a barcode scanner app in the browser: pair camera scanning with the proxy route from this guide.
- How to get a UPC barcode: if you're launching products, this is where your own GTINs come from.