QR codes are everywhere because they solve one problem well: getting a string from paper or a screen into a phone without anyone typing it. The string is usually a URL, and the useful projects are the ones where that URL does something: checks a guest in, opens a menu, joins a wifi network.
You will build the ticketing half of an event check-in system. Each ticket gets a URL that is signed so it cannot be guessed or forged, each URL is turned into a QR code image, the image is saved to disk, and a page displays it. At the door, scanning the code hits your server, which verifies the signature and marks the guest as arrived. Along the way you will deal with the one thing about this API that catches people out: the image link it gives you does not last.
01What the API returns
QR Code Generator (qrcodegenerator) is a POST endpoint that takes a JSON body. One parameter is required: value, the text to encode. It does not return image bytes or a base64 string. It returns metadata and a link:
| Field | What it is |
|---|---|
id | Identifier for this image, also used as the download filename |
format | Image format, png by default |
type | The kind of data encoded, text by default |
size, margin | Dimensions in pixels |
expires | Unix timestamp in milliseconds when the download link stops working |
downloadURL | A signed URL for the image file |
The reference also lists a correction field for the error-correction level. Treat it as optional; not every response includes it.
curl -X POST "https://api.apiverve.com/v1/qrcodegenerator" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"value":"https://example.com/checkin?t=T-10482","margin":10}'Compare expires with the current time: the link is temporary, so save the image.
When we called it, expires was almost exactly 48 hours after the request. Do not hardcode that; read the field. The point is the shape of the contract: the API generates the image and hosts it briefly; keeping it is your job.
Most styling options are premium. On plans that include them you can set size (50 to 2048 pixels), format (png and jpeg, with svg and webp marked as premium vector formats), color and backgroundColor, dot and corner styles, a gradient, and a logo in the centre. The type parameter is premium too, and its advanced values wifi and vcard are called out specifically. On every plan you get value and margin. Premium parameters sent on a lower plan are ignored rather than rejected, so code written against a paid plan still runs; it just produces the default black-on-white PNG.
The catalog lists this API at 10 credits per call.
02Step 1: generate and save, in Node and Python
Generation is one request. Saving is a second, plain GET to downloadURL. That second request goes to a storage host, not to APIVerve, so do not send your API key with it. The URL already carries its own signature, and your key has no business leaving your server for anywhere else.
import { writeFile } from 'node:fs/promises';
const BASE = 'https://api.apiverve.com/v1';
export async function createQr(value, options = {}) {
const res = await fetch(`${BASE}/qrcodegenerator`, {
method: 'POST',
headers: { 'x-api-key': process.env.APIVERVE_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ value, ...options }),
signal: AbortSignal.timeout(15000),
});
const body = await res.json().catch(() => null);
if (!res.ok || body?.status !== 'ok') {
const outOfCredits = res.status === 429 && res.headers.get('x-api-remaining-credits') === '0';
throw new Error(outOfCredits ? 'Out of credits' : body?.error || `HTTP ${res.status}`);
}
return body.data;
}
export async function saveQr(qr, path) {
if (Date.now() >= qr.expires) throw new Error(`Download link for ${qr.id} has expired`);
const res = await fetch(qr.downloadURL, { signal: AbortSignal.timeout(15000) }); // no API key here
if (!res.ok) throw new Error(`Download failed: HTTP ${res.status}`);
await writeFile(path, Buffer.from(await res.arrayBuffer()));
return path;
}import os, time
import requests
BASE = "https://api.apiverve.com/v1"
def create_qr(value, **options):
res = requests.post(
f"{BASE}/qrcodegenerator",
json={"value": value, **options},
headers={"x-api-key": os.environ["APIVERVE_API_KEY"]},
timeout=15,
)
body = res.json()
if res.status_code != 200 or body.get("status") != "ok":
raise RuntimeError(body.get("error") or f"HTTP {res.status_code}")
return body["data"]
def save_qr(qr, path):
if time.time() * 1000 >= qr["expires"]:
raise RuntimeError(f"Download link for {qr['id']} has expired")
img = requests.get(qr["downloadURL"], timeout=15) # no API key here
img.raise_for_status()
with open(path, "wb") as f:
f.write(img.content)
return path
if __name__ == "__main__":
qr = create_qr("https://example.com/checkin?t=T-10482")
print(save_qr(qr, f"{qr['id']}.{qr['format']}"))In Python, json= serialises the body and sets the Content-Type header for you. In Node you set both yourself. Note the units in the expiry check: expires is milliseconds, which matches JavaScript's Date.now() but not Python's time.time(), which is seconds. Mixing those up gives you a check that is wrong by a factor of a thousand, and in this case one that would never fire.
You store the downloadURL in your database and use it as the img src on ticket pages. What goes wrong?
03Step 2: tickets that cannot be forged
A check-in QR code is only as trustworthy as the URL inside it. If the URL is https://example.com/checkin?t=T-10482, anyone can print T-10483 and walk in. The fix is to sign the ticket ID with a secret only your server knows, using an HMAC, and put the signature in the URL. The scanner's phone opens the link, your server recomputes the signature, and a forged ticket fails the comparison.
Set up the project first.
mkdir checkin && cd checkin
npm init -y
npm pkg set type=module
npm install express@4
mkdir qr
export APIVERVE_API_KEY=YOUR_API_KEY
export TICKET_SECRET="a-long-random-string-you-keep-private"
export PUBLIC_URL="http://localhost:3000"import { createHmac, timingSafeEqual } from 'node:crypto';
const secret = () => {
if (!process.env.TICKET_SECRET) throw new Error('TICKET_SECRET is not set');
return process.env.TICKET_SECRET;
};
export function sign(ticketId) {
return createHmac('sha256', secret()).update(ticketId).digest('base64url').slice(0, 22);
}
export function verify(ticketId, sig) {
const expected = Buffer.from(sign(ticketId));
const given = Buffer.from(String(sig ?? ''));
return given.length === expected.length && timingSafeEqual(given, expected);
}
export function checkinUrl(ticketId) {
const u = new URL('/checkin', process.env.PUBLIC_URL || 'http://localhost:3000');
u.searchParams.set('t', ticketId);
u.searchParams.set('s', sign(ticketId));
return u.toString();
}Why sign instead of storing a random token per ticket in a database? Both work. A random token needs a lookup to verify; a signature needs only the secret, which matters at a venue door with patchy signal. You still keep a database of who has arrived, but deciding whether a code is genuine no longer depends on reaching it.
Twenty-two base64url characters is 132 bits of signature, far beyond guessing, and short enough to keep the QR code small. A shorter string means fewer modules in the code, which scans more reliably from a phone held at arm's length. timingSafeEqual compares in constant time so the response time does not leak how many characters of a guess were right.
04Step 3: generate, save and serve
Now the pieces come together. Issuing a ticket builds the signed URL, generates the code, and saves the image under qr/. The ticket page embeds your saved copy. The check-in route verifies the signature and records the arrival.
import express from 'express';
import { existsSync } from 'node:fs';
import { createQr, saveQr } from './qr.js';
import { checkinUrl, verify } from './tickets.js';
const app = express();
app.use('/qr', express.static('qr', { maxAge: '30d', immutable: true }));
const arrived = new Map(); // ticketId -> ISO time. Use your database in real life.
async function issue(ticketId) {
const file = `qr/${ticketId}.png`;
if (existsSync(file)) return file; // generate once, serve forever
const qr = await createQr(checkinUrl(ticketId), { margin: 10 });
return saveQr(qr, file);
}
app.get('/tickets/:id', async (req, res) => {
const id = req.params.id;
if (!/^T-\d{1,8}$/.test(id)) return res.status(400).send('Bad ticket id');
try {
await issue(id);
} catch (err) {
console.error('QR generation failed:', err.message);
return res.status(502).send('Could not create the ticket code. Try again shortly.');
}
res.send(`<!doctype html><meta charset="utf-8"><title>Ticket ${id}</title>
<main style="font-family:system-ui;text-align:center;padding:24px">
<h1>Ticket ${id}</h1>
<img src="/qr/${id}.png" alt="Check-in code for ticket ${id}" width="300" height="300">
<p>Show this code at the door.</p>
</main>`);
});
app.get('/checkin', (req, res) => {
const { t, s } = req.query;
if (typeof t !== 'string' || !verify(t, s)) return res.status(403).send('Invalid ticket');
if (arrived.has(t)) return res.status(409).send(`Ticket ${t} already checked in at ${arrived.get(t)}`);
arrived.set(t, new Date().toISOString());
res.send(`Welcome. Ticket ${t} checked in.`);
});
app.listen(3000, () => console.log('Open http://localhost:3000/tickets/T-10482'));Start it with node server.js and open the ticket page. The first load spends one call and writes qr/T-10482.png; every later load is a static file. Scan the code with your phone while on the same network as your machine (set PUBLIC_URL to your machine's LAN address so the phone can reach it), or simply copy the URL from the code into a browser. The first visit checks in; the second reports a duplicate; change one character of s and you get Invalid ticket.
Why does each check-in URL include an HMAC signature of the ticket ID?
05Encoding wifi credentials
The other classic use is a code on the wall that joins guests to your wifi. Phones recognise a standard text format for this:
WIFI:T:WPA;S:<network name>;P:<password>;;Setting the API's type to wifi is premium, but the image a QR code holds is just a string. Building the string yourself and sending it as value with the default type produces a code that phone cameras offer to join. Escape the characters the format treats as special, which are backslash, semicolon, comma, colon and double quote, by putting a backslash in front of each.
import { createQr, saveQr } from './qr.js';
const esc = (s) => s.replace(/([\\;,:"])/g, '\\$1');
const value = `WIFI:T:WPA;S:${esc('Cafe Guest')};P:${esc('espresso;2026')};;`;
const qr = await createQr(value);
console.log(await saveQr(qr, 'qr/wifi.png'));Run it with node wifi.js. Print the result, test it with two different phones before you laminate it, and remember that anyone who photographs the poster has your password, which is exactly what a guest network is for.
06Harden it
Generate once, keep forever. The ticket server checks for an existing file before calling the API. A ticket's code never changes, so there is no reason to pay for it twice. If you run on a platform without a persistent disk, write the bytes to object storage instead and serve them from there.
Save immediately. Download the image in the same request that generates it. A queue that generates today and downloads next week will find the link dead.
Embed without a file when you need to. For an email, you can read the saved PNG and inline it as a data URI: data:image/png;base64,${buffer.toString('base64')}. Some email clients block data URIs, so a hosted copy on your own domain is the more reliable choice.
Keep the secret out of the code. TICKET_SECRET signs every ticket you have ever issued. Rotate it and every printed ticket stops verifying, so treat it like a password and plan rotation before the event, not during it.
Limit what anonymous users can trigger. In this demo anyone can request /tickets/T-anything and spend a call. In a real system, tickets are issued when an order is paid, and the page only displays codes that already exist.
Making requests covers POST bodies and timeouts across the catalog.
07Ideas to extend it
- Batch issue. Generate codes for an entire guest list ahead of time with a small concurrency limit, so the door never waits on a network call.
- Branded codes. On a plan with styling, add your
colorand alogo, then test that the code still scans from a distance. - Offline door app. Since the signature can be checked with only the secret, a door laptop can verify tickets without internet and sync arrivals afterwards.
- Read the code back. The QR Code Reader decodes an uploaded image, which makes a good automated test that each saved file contains the URL you meant.
So far each project has used one or two APIs side by side. The last one chains several, where each step's output is the next step's input. Continue to Chain APIs into a pipeline.