Guide · 2026-10-12

QR codes for event tickets

Every attendee gets a ticket with their own QR code. Make them all from a CSV in one call, send them as images, PDFs or a link, and check them in at the door with one SQL statement that can't let a ticket in twice.

What goes in the code

Put a URL with a random token in each code, such as https://example.com/t/WrDKo5bCgV2b, and keep everything else in your database.

  • A URL, because any phone camera opens one. An attendee who scans their own ticket lands on their ticket page, and door staff land on your check-in screen.
  • A random token, not a ticket number. Number 1042 invites someone to try 1043. Python's secrets.token_urlsafe(9) gives 12 characters and 72 random bits, which nobody will guess.
  • Nothing personal. Anyone who photographs a ticket can read its code, so leave names and emails out of it.
  • Short. Every character adds to the grid. Fewer, larger modules scan faster in a dark doorway.

The ticket

The event ticket starter is a 1000 × 400 ticket with a qr layer whose data is {{ticket_url}}, so each ticket gets its own code. It takes six variables: event, date, venue, name, seat and ticket_url. Two settings matter for scanning:

  • Error correction. Raise it to Q, which tolerates 25% damage, for codes that will be scanned off cracked, dim or glare-covered phone screens. Use H if a logo will cover the middle.
  • The quiet zone. The starter's margin is 1 module because the ticket around the code is white and acts as the rest of the border. On a coloured ticket, set margin to 4 or put the code on a white panel.

One ticket per row

This script saves the starter as your template, gives each attendee a token, and renders every ticket in one collection. What all tickets share goes in variables. Each CSV row carries only what's different:

import csv, io, os, secrets, time, requests

API = "https://api.imageapis.com"
H = {"X-API-Key": os.environ["IMAGEAPIS_KEY"]}

# 1. the starter, saved as your template, with error correction raised for codes on cracked or dim screens
starter = requests.get(f"{API}/v1/starters/event-ticket", headers=H).json()
for layer in starter["template"]["layers"]:
    if layer["name"] == "qr":
        layer["ec"] = "Q"
tpl = requests.post(f"{API}/v1/templates", headers=H, json=starter["template"]).json()

# 2. an unguessable token per ticket; store it with the attendee in your own database
attendees = [("Dana Reyes", "B-14"), ("Sam Okafor", "B-15"), ("Jo Lindqvist", "C-02")]
tickets = [{"name": name, "seat": seat, "token": secrets.token_urlsafe(9)} for name, seat in attendees]

# 3. one row per ticket; what every ticket shares goes in variables
rows = io.StringIO()
w = csv.writer(rows)
w.writerow(["name", "seat", "ticket_url", "_token"])
for t in tickets:
    w.writerow([t["name"], t["seat"], f"https://example.com/t/{t['token']}", t["token"]])
col = requests.post(f"{API}/v1/collections", headers=H, json={
    "template": tpl["id"],
    "csv": rows.getvalue(),
    "variables": {"event": "Postgres at Scale, Austin", "date": "Tue 4 Nov 2026 · 18:30", "venue": "Moody Theater"},
    "scale": 2,
}).json()

# 4. wait for the collection, then each image is a ticket
while col["status"] in ("pending", "running"):
    time.sleep(2)
    col = requests.get(f"{API}/v1/collections/{col['id']}", headers=H).json()
for img in col["images"]:
    print(img["metadata"]["token"], img["url"])

The _token column comes back as each image's metadata, so you can match images to tickets without relying on row order. scale: 2 keeps the code crisp when the ticket is shown at phone size. A collection takes up to 1,000 rows. It reserves one image per row on your plan before it starts, and if the plan doesn't have that many left it answers 429 and charges nothing. For a few hundred tickets, pass webhook_url rather than polling: the webhooks guide has the payload. The CSV guide covers columns, delimiters and errors.

Sending them

By email, as a PNG, since Gmail and most mail clients don't show SVG:

<img src="https://img.imageapis.com/i/img_….png" width="500" height="200" alt="Ticket for Dana Reyes, seat B-14">

Mind the date. A hosted render is kept 30 days on the free plan and a year on paid plans. If the event is further out than that, either attach the file or send a signed ticket URL, which draws the ticket when the email is opened and doesn't expire:

import hmac, hashlib
from urllib.parse import urlencode

def signed(path, **params):
    msg = f"/s/{BASE}/{path}?{urlencode(params)}"
    return f"https://img.imageapis.com{msg}&sig={hmac.new(SECRET.encode(), msg.encode(), hashlib.sha256).hexdigest()}"

ticket = signed(f"{TEMPLATE}.png", event="Postgres at Scale, Austin", date="Tue 4 Nov 2026 · 18:30", venue="Moody Theater",
                name="Dana Reyes", seat="B-14", ticket_url=f"https://example.com/t/{token}")

BASE and SECRET come from a signed base. A signed URL costs one image the first time it's drawn. It's cached for a week after that, then drawn again the next time someone opens it.

As a PDF, for an attachment: POST /v1/pdfs with the same template and variables makes a one-page PDF at the ticket's own size, at 96 px to the inch. One page is one image.

On a ticket page, the page the code's URL opens: show the code large, on its own, with a signed qr.svg. It's drawn without a browser and stays sharp on any screen. It costs one image the first time each ticket's code is drawn, and comes from the cache after that:

qr = signed("qr.svg", data=f"https://example.com/t/{token}", size=320, ec="Q")

At the door

Staff scan with a phone camera or any scanner app, and the code's URL opens your check-in screen. Mark a ticket used only for signed-in staff. For anyone else, show the ticket page, so an attendee scanning their own ticket doesn't use it up. The check-in is one statement:

UPDATE tickets SET checked_in_at = now()
WHERE token = $1 AND checked_in_at IS NULL
RETURNING name, seat;

One statement means two scanners at two doors can't both admit the same ticket. The second scan waits for the first to finish, then finds checked_in_at already set. A row back means let them in. No row means the token is unknown or already used. Look the token up to tell staff which it is, and when it was used.

What it costs

One image per ticket, the same for a PDF page, and one per signed URL the first time it's drawn. The free plan's 150 images a month covers a meetup. For a conference, plan on a ticket, a ticket page code and maybe a PDF per attendee. To try a single code first, the QR code generator makes one for free.

Get a free key: 150 images a month API reference