A warehouse asks you for shelf labels. Marketing wants a QR code on every product insert. Finance wants asset tags for 3,000 laptops. Each request sounds like "call the generator in a loop", and each one goes wrong in a different way if that is all you do: the warehouse scanners cannot read QR, the insert codes point at a URL that will be dead in a year, and the asset tags get regenerated (and paid for) every time the script is rerun.
The Fundamentals project generated one signed QR code per event ticket. This lesson is about the decisions that come before the loop and the checks that come after it.
01Pick the symbology before you write any code
The symbology is decided by who scans it and with what, not by what looks modern. Get this wrong and no amount of code fixes it, because the labels are already printed.
| QR code | Code 128 | Code 39 | |
|---|---|---|---|
| Shape | 2D square | 1D linear | 1D linear |
| Read by | Phone cameras, 2D imagers | Any 1D laser or imager | Any 1D laser or imager, including very old ones |
| Characters | Any text, including UTF-8 | All 128 ASCII characters | Uppercase A-Z, 0-9, space and - . $ / + % |
| Practical length | Hundreds of characters | Roughly 20-30 before the label gets too wide | Roughly 10-15; it is the least dense |
| Typical home | Marketing, tickets, contact cards, Wi-Fi | Shipping, inventory, asset tags | Legacy industrial and government systems |
Three rules come out of that table.
Laser scanners cannot read QR codes. A handheld laser scanner sweeps one line across the label. A QR code has no meaningful single line. If the warehouse has laser guns, the answer is Code 128, however much you like QR.
Phones are bad at linear barcodes. Camera apps on most phones will open a URL from a QR code without any extra app, but treat a 1D barcode as text at best. If a consumer is meant to scan it, use QR.
Code 39 is for compatibility, never for preference. It exists because some older systems only understand Code 39. Its character set has no lowercase letters and no underscore. When we sent part_7781 with type: code39, the API refused it with "Invalid barcode data or type". When we sent part-7781 it succeeded, but Code 39 cannot represent lowercase, so whatever the scanner gives back will not be the lowercase string you stored. Normalise identifiers to uppercase before you generate Code 39, and store them that way.
One more boundary: the Barcode Generator produces code128 and code39 only. Retail product codes (EAN-13, UPC-A) require a number assigned by GS1 to your company, and the generator rejects ean13 as a type. The Barcode Reader, on the other hand, decodes EAN and UPC, which matters when you are receiving goods rather than labelling them.
A warehouse uses handheld laser scanners on its picking line. You need a label that encodes bin location IDs like A-12-07. Which symbology should you generate?
02Encode the payload, not just the value
A QR code holds a string. What the scanning phone does with that string depends entirely on its format, and the generator does not validate the format for you. On most plans the type parameter is premium, and even on plans that have it, building the string yourself keeps you in control of exactly what is encoded.
URLs you can change later
A printed QR code lives for years. If it encodes https://shop.example.com/campaigns/autumn-2026/landing?utm_source=insert, you have frozen that exact path into paper. Encode a short URL on a domain you control instead, such as https://example.com/q/A7K2, and redirect it server-side. When the campaign page moves, you change the redirect, not the print run. Short URLs also produce codes with fewer modules (the black squares), and fewer modules means larger modules at the same print size, which is what makes a code scan from arm's length on a crumpled flyer.
Contact cards (vCard)
A vCard QR code saves a contact straight into the phone's address book. The format is line-based, and three details trip people up: lines are separated by CRLF (\r\n), the N field is Family;Given in that order, and commas, semicolons and backslashes in values must be backslash-escaped.
const esc = (s = '') => String(s).replace(/([\\;,])/g, '\\$1').replace(/\r?\n/g, '\\n');
export function vcard({ given, family, org, title, phone, email, url }) {
return [
'BEGIN:VCARD',
'VERSION:3.0',
`N:${esc(family)};${esc(given)}`,
`FN:${esc(`${given} ${family}`)}`,
org && `ORG:${esc(org)}`,
title && `TITLE:${esc(title)}`,
phone && `TEL;TYPE=WORK,VOICE:${phone}`,
email && `EMAIL:${email}`,
url && `URL:${url}`,
'END:VCARD',
].filter(Boolean).join('\r\n');
}
export const geo = (lat, lng) => `geo:${lat},${lng}`;
export const mailto = (to, subject) => `mailto:${to}?subject=${encodeURIComponent(subject)}`;
export const sms = (number, body) => `SMSTO:${number}:${body}`;Keep vCards lean. Every field you add makes the code denser. Name, one phone, one email and a URL is the sweet spot for a business card. A photo, which vCard technically allows, will produce a code no phone can read from a card.
curl -X POST "https://api.apiverve.com/v1/qrcodegenerator" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"value":"BEGIN:VCARD\r\nVERSION:3.0\r\nN:Lee;Dana\r\nFN:Dana Lee\r\nTEL;TYPE=WORK,VOICE:+15555550142\r\nEMAIL:dana@example.com\r\nEND:VCARD","margin":10}'Download the image and scan it with your phone camera: it should offer to add a contact.
The Fundamentals lesson covered the Wi-Fi format (WIFI:T:WPA;S:...;P:...;;). Two additions for production signage: add H:true; when the network's name is hidden, and use T:nopass; with no P: for an open network. Test with both an iPhone and an Android phone before printing, because they disagree on edge cases more often than you would expect.
Quiet zone and margin
Every code needs blank space around it (the quiet zone) so the scanner can find its edges. The QR generator's margin parameter, available on every plan, sets it in pixels; the default is 10. If your label template places the code tight against a border or other artwork, raise the margin rather than trusting the template. A QR code that does not scan is almost always too small, too low-contrast, or crowded at the edge.
03Generate barcodes, and normalise the two responses
The two generators look alike but their responses differ in small ways that break shared code. Here is what each returned when we called them:
| Field | QR Code Generator | Barcode Generator |
|---|---|---|
| Identifier | id: a bare UUID | imageName: a UUID with .png |
| Format | format: "png" | format: ".png", with a leading dot |
| Type | type: "text" | type: "CODE128" or "CODE39", uppercase |
| Link lifetime | expires about 48 hours out, in milliseconds | expires about 24 hours out, in milliseconds |
Read the expires field rather than relying on those durations, and note that the barcode link dies twice as fast. If you join id + '.' + format for both, you will produce something..png for barcodes. Normalise once, at the boundary:
const BASE = 'https://api.apiverve.com/v1';
async function post(api, body) {
const res = await fetch(`${BASE}/${api}`, {
method: 'POST',
headers: { 'x-api-key': process.env.APIVERVE_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(20000),
});
const json = await res.json().catch(() => null);
if (!res.ok || json?.status !== 'ok') {
const err = new Error(json?.error || `HTTP ${res.status}`);
err.status = res.status;
err.outOfCredits = res.status === 429 && res.headers.get('x-api-remaining-credits') === '0';
err.retryAfter = Number(res.headers.get('retry-after')) || 0;
throw err;
}
return json.data;
}
export async function generate(kind, payload) {
if (kind === 'qr') {
const d = await post('qrcodegenerator', { value: payload, margin: 16 });
return { ext: d.format.replace(/^\./, ''), url: d.downloadURL, expires: d.expires };
}
const type = kind === 'code39' ? 'code39' : 'code128';
const data = type === 'code39' ? payload.toUpperCase() : payload;
const d = await post('barcodegenerator', { data, type, displayValue: true });
return { ext: d.format.replace(/^\./, ''), url: d.downloadURL, expires: d.expires };
}displayValue prints the human-readable text under the bars. Leave it on for anything a person might need to key in when the scanner fails, which on a warehouse floor is every day.
curl -X POST "https://api.apiverve.com/v1/barcodegenerator" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"data":"BIN-A-12-07","type":"code128","displayValue":true}'Compare format and type with the QR generator's response: same idea, different spelling.
04Batch generation without paying twice
Both generators cost 10 credits per image, so 3,000 asset tags is 30,000 credits. That is fine once. It is not fine every time someone reruns the script because row 2,412 failed. Three habits make a batch safe to rerun.
Name each file after its content. Hash the kind and payload into the filename. Before calling the API, check whether that file already exists. A rerun then costs nothing for the codes already made, and two rows with the same payload share one image.
Limit concurrency. Fire 3,000 requests at once and you will hit the per-minute rate limit in the first second. A small pool, four to eight in flight, finishes nearly as fast and keeps you inside the window.
Retry only what can succeed. A 429 with x-api-remaining-credits: 0 means the month's credits are gone; retrying will never work, so stop the whole batch. Any other 429 is the rate limit: wait for retry-after. A 5xx or a network timeout is worth a few retries. A 400 means the payload is wrong for the symbology and will fail identically forever, so record it and move on.
import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { mkdir, writeFile, readFile } from 'node:fs/promises';
import { generate } from './codes.js';
const OUT = 'labels';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const key = (kind, payload) => createHash('sha256').update(`${kind}\n${payload}`).digest('hex').slice(0, 20);
async function makeOne({ kind, payload }) {
const file = `${OUT}/${key(kind, payload)}.png`;
if (existsSync(file)) return { payload, file, cached: true };
for (let attempt = 1; ; attempt++) {
try {
const img = await generate(kind, payload);
const res = await fetch(img.url, { signal: AbortSignal.timeout(20000) }); // storage link: no API key
if (!res.ok) throw Object.assign(new Error(`download HTTP ${res.status}`), { status: 502 });
await writeFile(file, Buffer.from(await res.arrayBuffer()));
return { payload, file, cached: false };
} catch (err) {
if (err.outOfCredits) throw err; // stop everything
const retryable = !err.status || err.status === 429 || err.status >= 500;
if (!retryable || attempt === 4) return { payload, error: err.message };
await sleep(err.retryAfter ? err.retryAfter * 1000 : 500 * 2 ** attempt);
}
}
}
async function pool(items, size, fn) {
const results = new Array(items.length);
let next = 0;
const worker = async () => {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i]);
}
};
await Promise.all(Array.from({ length: size }, worker));
return results;
}
await mkdir(OUT, { recursive: true });
const rows = JSON.parse(await readFile('labels.json', 'utf8')); // [{ "kind": "code128", "payload": "BIN-A-12-07" }, ...]
const results = await pool(rows, 6, makeOne);
await writeFile(`${OUT}/manifest.json`, JSON.stringify(results, null, 2));
const made = results.filter((r) => r.file && !r.cached).length;
console.log(`${made} generated (${made * 10} credits), ${results.filter((r) => r.cached).length} reused, ${results.filter((r) => r.error).length} failed`);Save it next to codes.js in a project with "type": "module" in package.json, create labels.json, and run node batch.js with APIVERVE_API_KEY set. Run it twice: the second run reports everything reused and spends nothing.
Your batch of 3,000 labels dies at row 2,412 with a 429. The response header x-api-remaining-credits is 0. What should the script do?
05Read the codes back before they go to print
A code you have not scanned is a code you are hoping about. The QR Code Reader and Barcode Reader each take an image and return the decoded text. The Barcode Reader also returns format (the symbology it recognised) and isRetail. Both accept the image as a multipart file upload in a field named image; let your HTTP client build the multipart body, because the Content-Type header has to carry a boundary you cannot write by hand.
import { readFile } from 'node:fs/promises';
export async function readCode(file, kind) {
const api = kind === 'qr' ? 'qrcodereader' : 'barcodereader';
const form = new FormData();
form.append('image', new Blob([await readFile(file)], { type: 'image/png' }), 'code.png');
const res = await fetch(`https://api.apiverve.com/v1/${api}`, {
method: 'POST',
headers: { 'x-api-key': process.env.APIVERVE_API_KEY }, // no Content-Type: fetch adds the boundary
body: form,
signal: AbortSignal.timeout(30000),
});
const json = await res.json();
if (json.status !== 'ok') throw new Error(json.error || `HTTP ${res.status}`);
return json.data.text;
}
export async function verify(file, kind, expected) {
const text = await readCode(file, kind);
const want = kind === 'code39' ? expected.toUpperCase() : expected;
return { file, ok: text === want, text };
}import os
import sys
import requests
def read_code(path, kind):
api = "qrcodereader" if kind == "qr" else "barcodereader"
with open(path, "rb") as f:
res = requests.post(
f"https://api.apiverve.com/v1/{api}",
headers={"x-api-key": os.environ["APIVERVE_API_KEY"]},
files={"image": ("code.png", f, "image/png")},
timeout=30,
)
body = res.json()
if body.get("status") != "ok":
raise RuntimeError(body.get("error") or f"HTTP {res.status_code}")
return body["data"]["text"]
if __name__ == "__main__":
path, kind, expected = sys.argv[1:4]
text = read_code(path, kind)
want = expected.upper() if kind == "code39" else expected
print("OK" if text == want else f"MISMATCH: got {text!r}")Uploads on Free and Hobby plans are capped at 400 KB, which a generated PNG fits comfortably; a phone photo of a printed label may not, so resize photos before sending them.
What to verify, and how much
Reading back costs another 10 credits per code, which doubles the cost of the batch. Whether that is worth it depends on what a bad code costs you.
- Verify every code when the payload is built from data you do not fully control (customer-entered names in vCards, free-text part numbers), or when a bad label means a reprint of thousands.
- Verify a sample when every payload comes from one template. If the template is right for 50 random rows, it is right for the rest; check the first, the last, the longest payload and a random handful.
- Verify the printed output, not just the file, for anything going to a commercial printer. Photograph a proof and run it through the reader. Scaling, ink spread and low-contrast brand colours break codes that were perfect as PNGs.
A mismatch tells you something specific. For QR, it usually means an escaping bug in the payload builder. For Code 39, it usually means case. Either way you found it before the print run, which is the whole point.
You generated a Code 39 label for the identifier 'part-7781' and the Barcode Reader returns 'PART-7781'. What is the correct conclusion?
06Production notes
Keep the payload table, not just the images. The manifest written by the batch maps every payload to its file. Keep it with the label job, so a reprint a year from now reuses the same images instead of guessing which file was which.
Don't put secrets or personal data in a code you can't take back. Anything encoded is readable by anyone who can photograph it. A parcel label with a customer's phone number in a QR code is a privacy leak on every doorstep. Encode an opaque ID or a signed URL and look the details up server-side.
Download inside the expiry window, every time. The batch saves each image in the same step that generates it. Splitting generation and download into separate jobs is how a queue ends up full of dead links, especially for barcodes with their shorter lifetime.
Next you will put money on paper: Generate invoices covers numbering, tax lines and storing a PDF you may have to produce for an auditor years from now.