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:
| Field | Type | Notes |
|---|---|---|
items | array, required | 1–20,000 of {"data", "filename"?, "caption"?}; data ≤ 2,953 bytes — see Styling for per-item captions and filenames |
format | string | png (default), svg, or both |
style | object | See Styling — colours, transparency, logo, caption |
sendEmail | boolean | Email 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.
| Field | Type | Notes |
|---|---|---|
ecLevel | string | L / M (default) / Q / H. Use Q or H with a logo |
scale | integer | Pixels per module, 1–40 — long data at large scales is capped at 4,096 px per side |
margin | integer | Quiet-zone modules, 0–16 |
foreground | string | Hex colour (e.g. #1a1a2e) or transparent |
background | string | Hex colour or transparent. Foreground and background can't both be transparent |
logo | object | Centre logo, see below |
caption | object | Text under the code, see below |
Centre logo
| Field | Type | Notes |
|---|---|---|
dataUrl | string, required | A PNG data: URL (data:image/png;base64,…), at most 512 KB. Remote URLs are rejected |
sizeRatio | number | Logo 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
| Field | Type | Notes |
|---|---|---|
text | string | Up to 120 characters, rendered beneath the code. Optional — a caption with only fontSize/colour renders nothing itself and styles per-item captions |
colour | string | Hex colour; defaults to the foreground colour |
fontSize | integer | 6–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.
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
| Endpoint | Limit (per key) |
|---|---|
| Create job | 10 / minute |
| Status & download | 60 / minute |
Over the limit, responses are 429 with a Retry-After header.
Errors
Errors are JSON: {"error": "snake_case_code", …}.
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_input | Validation failed; an issues array pinpoints the fields |
| 400 | data_too_long / qr_too_large | An item exceeds QR capacity, or data × scale exceeds the pixel cap |
| 400 | invalid_json_line | NDJSON line is not valid JSON |
| 400 | invalid_meta_line | NDJSON first line is missing or isn't a valid job meta object |
| 400 | too_many_items | NDJSON body exceeds the item cap |
| 401 | invalid_api_key | Missing, malformed, or revoked key |
| 402 | insufficient_credits | Includes required and balance; top up on the account page |
| 404 | not_found | No such job in your organisation |
| 409 | not_ready | ZIP requested before the job completed |
| 413 | payload_too_large | Body over 25 MB (JSON) / 100 MB (NDJSON) |
| 429 | rate_limited | Honour Retry-After |