API documentation

Public API v1 · OpenAPI spec

The BulkQR API generates QR codes in bulk: create a job with up to a million items, poll its status, and download a ZIP of the results. Everything the generator page can do with images, the API can do headlessly.

Authentication

Requests are authenticated with an organisation API key sent as a bearer token. Owners and admins create keys on the account page; a key is shown once at creation and can be revoked at any time. Keys grant full job access for the organisation. Store them like passwords, and roll them by creating a replacement key, switching your integration over, then revoking the old one (both keys work during the overlap).

Authorization: Bearer bqk_your_key_here

Quickstart

Create a job:

curl -X POST https://bulkqr.com/api/v1/jobs \
  -H "Authorization: Bearer $BULKQR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"items": [{"data": "https://example.com/a"}, {"data": "https://example.com/b"}]}'

The response points at signed status and download URLs (valid 90 days, no auth needed):

{
  "jobId": "…",
  "total": 2,
  "creditsCharged": 2,
  "statusUrl": "/d/…/status?exp=…&t=…",
  "downloadUrl": "/d/…?exp=…&t=…"
}

Poll statusUrl (or GET /api/v1/jobs/{jobId} with your key) until "status": "complete", then fetch downloadUrl (or GET /api/v1/jobs/{jobId}/download) for the ZIP.

Create a job: POST /api/v1/jobs

JSON bodies carry up to 20,000 items:

FieldTypeNotes
itemsarray, required1–20,000 of {"data", "filename"?, "caption"?}; data ≤ 2,953 bytes. See Styling for per-item captions and filenames
formatstringpng (default), svg, or both
styleobjectSee Styling (colours, transparency, logo, caption)
sendEmailbooleanEmail the download link to the organisation owner. Default false for API jobs

Streaming (NDJSON) for large jobs

Up to 1,000,000 items / 100 MB with Content-Type: application/x-ndjson: the first line is the job meta, each following line one item.

{"format": "png", "style": {"scale": 8}}
{"data": "https://example.com/1"}
{"data": "https://example.com/2"}

Styling

The API accepts everything the generator page offers. All fields are optional and apply to every code in the job.

FieldTypeNotes
ecLevelstringL / M (default) / Q / H. Use Q or H with a logo
scaleintegerPixels per module, 1–40. Long data at large scales is capped at 4,096 px per side
marginintegerQuiet-zone modules, 0–16
foregroundstringHex colour (e.g. #1a1a2e) or transparent
backgroundstringHex colour or transparent. Foreground and background can't both be transparent
logoobjectCentre logo, see below
captionobjectText under the code, see below
FieldTypeNotes
dataUrlstring, requiredA PNG data: URL (data:image/png;base64,…), at most 512 KB. Remote URLs are rejected
sizeRationumberLogo edge as a fraction of the QR edge, 0.05–0.3 (default 0.2)

A logo hides part of the code, so raise error correction (ecLevel: "Q" or "H") and keep the ratio modest. The defaults stay comfortably scannable.

Captions

FieldTypeNotes
textstringUp to 120 characters, rendered beneath the code. Optional. A caption with only fontSize/colour renders nothing itself and styles per-item captions
colourstringHex colour; defaults to the foreground colour
fontSizeinteger6–64 (default 16), scaled with the image

Per-item text: items[].caption overrides the job caption for that code. It works without a job-level caption and inherits the job caption's colour and fontSize. Filenames follow the same per-item pattern: items[].filename names the file (up to 120 characters), otherwise a name is derived from the data. Names are sanitised and truncated to 100 characters in the ZIP. JSON jobs suffix a name only when it collides with another item's; streaming (NDJSON) jobs append the item's index to every filename.

Example

curl -X POST https://bulkqr.com/api/v1/jobs \
  -H "Authorization: Bearer $BULKQR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [{"data": "https://example.com", "caption": "Table 1"}],
    "format": "both",
    "style": {
      "ecLevel": "Q",
      "background": "transparent",
      "logo": {"dataUrl": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGN4xqANAALhARJlKxA9AAAAAElFTkSuQmCC"}
    }
  }'

That data URL is a valid 1×1 PNG, handy for trying the API before you export a real logo.

Dynamic codes

A dynamic code encodes a short HTTPS://Q9X.ME/ID address (upper case, which keeps the code small) instead of your URL, so you can change where it points after it is printed. Send "mode": "dynamic" to POST /api/v1/jobs with JSON items of {"destination", "label", "tags", "filename"} (up to 10,000). Artwork is rendered at QR version 1 with error correction M, and a centre logo is limited to 15% of the width (sizeRatio defaults to 0.15). Dynamic jobs cost no credits and are not available over NDJSON.

curl https://bulkqr.com/api/v1/jobs \
  -H "Authorization: Bearer $BULKQR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"mode": "dynamic", "items": [{"destination": "https://example.com/menu", "label": "Table 1"}]}'

{"jobId": "…", "total": 1, "creditsCharged": 0, "downloadUrl": "…", "statusUrl": "…",
 "links": [{"id": "7K2M9", "url": "HTTPS://Q9X.ME/7K2M9", "destination": "https://example.com/menu"}]}

Do not put secrets in destination URLs. A destination is read by anyone who scans the code, and it is visible to anyone who can enumerate q9x.me addresses. Use an address that is safe to share publicly.

An organisation has a small free allowance of dynamic links. Beyond it, links are paid and need an active subscription, which you start in the console: the API never starts a checkout. Without one the request fails with 402 subscription_required (402 billing_action_required if the subscription is past due, 409 cancel_pending if it is set to cancel) and nothing is created.

Repoint: PATCH /api/v1/links/{id}

curl -X PATCH https://bulkqr.com/api/v1/links/7K2M9 \
  -H "Authorization: Bearer $BULKQR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"destination": "https://example.com/new-menu"}'

{"id": "7K2M9", "destination": "https://example.com/new-menu", "propagatesWithinSeconds": 60}

The change is live everywhere within 60 seconds. A link in another organisation or an unknown ID is 404; an archived link is 409 not_active; a link disabled by the platform is 409 blocked.

Status: GET /api/v1/jobs/{jobId}

{"id": "…", "status": "processing", "total": 50000, "completed": 31200, "failed": 0, "creditsCharged": 50000}

status is processing → zipping → complete (or failed). creditsCharged is the credits debited when the job was created. downloadUrl appears once complete.

Download: GET /api/v1/jobs/{jobId}/download

Streams the ZIP; Range requests are supported. Results are retained for 90 days.

Credits

Every job costs one credit per item, debited from the organisation that owns the key when the job is accepted. The exact amount is returned as creditsCharged on the create response and on job status. A job that cannot be paid for is rejected with 402 insufficient_credits and is never created; the 402 body carries required (what the job needed) and balance (what you have), so you can size a top-up. New accounts receive 500 credits once the owner verifies their email address. Small batches can be generated free in your browser on the generator page; the API always uses the server.

Check your balance: GET /api/v1/credits

Pre-flight a batch and alert before you run dry (rate limit 60 / minute per key):

curl https://bulkqr.com/api/v1/credits \
  -H "Authorization: Bearer $BULKQR_KEY"

{"balance": 4830}

Rate limits

EndpointLimit (per key)
Create job10 / minute
Repoint link60 / minute
Status & download60 / minute

Over the limit, responses are 429 with a Retry-After header.

Errors

Errors are JSON: {"error": "snake_case_code", …}.

StatusCodeMeaning
400invalid_inputValidation failed; an issues array pinpoints the fields
400data_too_long / qr_too_largeAn item exceeds QR capacity, or data × scale exceeds the pixel cap
400invalid_json_lineNDJSON line is not valid JSON
400invalid_meta_lineNDJSON first line is missing or isn't a valid job meta object
400too_many_itemsNDJSON body exceeds the item cap
401invalid_api_keyMissing, malformed, or revoked key
402insufficient_creditsIncludes required and balance; top up on the account page
402subscription_required / billing_action_requiredDynamic job needs paid links: no active subscription, or it is past due
403dyn_not_enabledDynamic codes are not enabled for your account yet
404not_foundNo such job or link in your organisation
409cancel_pending / blocked / not_activeSubscription set to cancel; link disabled by the platform; link archived or pending
409not_readyZIP requested before the job completed
413payload_too_largeBody over 25 MB (JSON) / 100 MB (NDJSON)
429rate_limitedHonour Retry-After