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,
"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, required | Up to 120 characters, rendered beneath the code |
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 —
and works without a job-level caption. 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}
status is processing → zipping → complete (or failed). 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 and the free tier
Jobs of up to 100 items are free. Larger jobs cost one credit per item, debited from the
organisation's balance when the job is accepted — buy credits on the
account page. A job that can't be paid for is rejected with
402 insufficient_credits and costs nothing.
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 | 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 |