AQRHub Public API v1
Programmatic access for creating dynamic QR codes. Generate codes at volume from a backend, without a browser session and without logging in to the dashboard.
Base URL: https://aqrhub.com/api/v1
The API is HTTPS-only. HTTP requests are not accepted.
Authentication
Every request must carry an API key. Two ways to send it:
Authorization: Bearer aqr_XXXXXXXX_<secret>
or, as a fallback:
X-API-Key: aqr_XXXXXXXX_<secret>
Keys are issued per user account from the dashboard at
/account/api-keys. Each key is a namespace prefix (aqr_ plus 8
alphanumeric chars) joined by an underscore to a high-entropy secret.
The full key is shown to the operator exactly once at creation time.
AQRHub stores only the prefix and an HMAC-SHA256 hash of the secret,
so a lost key cannot be recovered; revoke it and create a new one.
Authorization: Bearer wins if both headers are set.
Plan requirement
The API is a Pro-plan feature. A key belonging to an account on any
other plan (Free, Starter, or a downgraded Pro) receives 403
plan_required on every request.
If a Pro account downgrades to Free, its existing keys immediately stop working. The keys can still be revoked or deleted from the dashboard.
Response shape
Every JSON response uses UTF-8 without a BOM.
Errors always look like:
{
"error": {
"code": "invalid_url",
"message": "Field 'url' must be an http(s) URL with a valid host."
}
}
error.code is a short machine-parseable slug; branch on it. The
error.message is a human-readable explanation and may change
without notice.
Timestamps are ISO 8601, second precision, in UTC. Example:
2026-07-18T12:34:56Z.
Endpoints
POST /api/v1/codes
Create one dynamic QR code.
Request body
{
"url": "https://your-site.example/campaign",
"name": "Autumn promo",
"client_id": "internal-record-42"
}
| Field | Type | Required | Notes |
|---|---|---|---|
url |
string | yes | http or https, up to 2048 chars, passes Safe Browsing |
name |
string | no | Human label, up to 100 chars |
client_id |
string | no | Opaque echo-back value, up to 255 chars |
Response (201 Created)
{
"id": 12345,
"short_url": "a1b2c3d4",
"url": "https://your-site.example/campaign",
"dynamic_url": "https://aqrhub.com/q/a1b2c3d4",
"image": {
"png_url": "https://aqrhub.com/api/v1/codes/12345/image.png",
"svg_url": "https://aqrhub.com/api/v1/codes/12345/image.svg"
},
"created_at": "2026-07-18T12:34:56Z",
"name": "Autumn promo",
"client_id": "internal-record-42"
}
dynamic_url is what a scanner hits. Scans are counted and redirected
to url. If the owner has an active custom domain, dynamic_url
serves from that domain automatically.
name and client_id are echoed only when they were supplied.
Curl
curl -X POST https://aqrhub.com/api/v1/codes \
-H "Authorization: Bearer $AQRHUB_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://your-site.example/campaign","name":"Autumn promo"}'
POST /api/v1/codes/bulk
Create up to 100 dynamic QR codes and receive a JSON response.
Partial success: any per-row failure (bad Safe Browsing, plan
cap tripped) is reported in the errors array while the good rows
still land in results.
Request body
{
"items": [
{ "client_id": "record-1", "url": "https://a.example" },
{ "client_id": "record-2", "url": "https://b.example",
"name": "Business card front" },
{ "client_id": "record-3", "url": "https://c.example" }
]
}
Each item has the same field rules as POST /codes, with one
difference: client_id is REQUIRED and must be unique within the
batch. That is the round-trip key mapping the returned code back to
your record.
Batch-level constraints:
- 1 to 100 items. Empty or over 100 returns 400 with code
invalid_itemsorbatch_too_large. - Duplicate
client_idin the same batch returns 400 with codeduplicate_client_idnaming the collision. - A shape error on any single item (missing url, bad type, oversize field) returns 400 with the row index and NO codes are created.
Response (200 OK)
{
"results": [
{
"client_id": "record-1",
"code": {
"id": 12346, "short_url": "b2c3d4e5",
"url": "https://a.example",
"dynamic_url": "https://aqrhub.com/q/b2c3d4e5",
"image": {
"png_url": "https://aqrhub.com/api/v1/codes/12346/image.png",
"svg_url": "https://aqrhub.com/api/v1/codes/12346/image.svg"
},
"created_at": "2026-07-18T12:34:57Z",
"client_id": "record-1"
}
}
],
"errors": [
{
"client_id": "record-2",
"code": "unsafe_url",
"message": "This URL was flagged as unsafe by Google Safe Browsing."
}
]
}
Guaranteed round-trip: every client_id you sent appears in exactly
one of results or errors. Nothing is dropped and nothing is
duplicated.
Curl
curl -X POST https://aqrhub.com/api/v1/codes/bulk \
-H "Authorization: Bearer $AQRHUB_KEY" \
-H "Content-Type: application/json" \
-d '{"items":[{"client_id":"r1","url":"https://a.example"},{"client_id":"r2","url":"https://b.example"}]}'
POST /api/v1/codes/bulk/zip
Same input shape as POST /codes/bulk. Response is a ZIP archive
(Content-Type: application/zip) containing one PNG and one SVG per
code, named by client_id. This is the endpoint print shops want:
you get the deliverables in one download.
All-or-nothing: if any input row fails validation or Safe
Browsing, the endpoint returns 400 application/json with an
errors array and NO codes are created. A ZIP that silently drops
some codes would break a print run; the fail-loud behavior lets you
correct the batch and retry.
Archive layout
Flat, one PNG and one SVG per code, at the root of the archive:
<sanitized_client_id>.png
<sanitized_client_id>.svg
Filename sanitization
client_id is opaque and may contain anything, but a filename cannot.
The endpoint sanitizes each client_id before writing to the archive:
- Characters outside
[A-Za-z0-9._-]are replaced with_. - Runs of
_and.are collapsed. - Leading dots, dashes, and underscores are stripped (no dotfiles, no path traversal escapes).
- Trailing dots are stripped (Windows filename constraint).
- Truncated to 80 chars.
- Empty result becomes
code.
If two client_id values sanitize to the same base name (for example
a/b and a\b both collapse to a_b), the second one is
disambiguated by appending the AQRHub code id: a_b-<id>.png.
A client_id like ../../etc/passwd sanitizes to etcpasswd, never
escapes the archive, and cannot overwrite files during extraction.
Rendering
Each image is rendered on the fly by the same renderer the single-image endpoint uses. API-generated images are un-watermarked. The zip and the per-code image endpoints produce byte-identical bytes for the same code.
Curl (writes the zip to disk)
curl -X POST https://aqrhub.com/api/v1/codes/bulk/zip \
-H "Authorization: Bearer $AQRHUB_KEY" \
-H "Content-Type: application/json" \
--output aqrhub-codes.zip \
-d '{"items":[{"client_id":"card-jane","url":"https://jane.example"},{"client_id":"card-john","url":"https://john.example"}]}'
GET /api/v1/codes/{id}/image.png
GET /api/v1/codes/{id}/image.svg
Fetch the QR image for one of your codes. Rendered on the fly; nothing is cached server-side. Response body is the raw image bytes.
Content-Type: image/pngorimage/svg+xml.- 404 (
not_found) if the code does not exist OR belongs to another account. Same response for both cases so you cannot enumerate ids across accounts.
Curl
curl -o code.png \
-H "Authorization: Bearer $AQRHUB_KEY" \
https://aqrhub.com/api/v1/codes/12345/image.png
Rate limits
Rate limits are applied per API key, not per IP address.
| Endpoint | Limit |
|---|---|
POST /codes |
120 per minute |
POST /codes/bulk |
20 per minute |
POST /codes/bulk/zip |
20 per minute |
Each bulk call counts as one request regardless of how many items it carries.
Successful and rejected responses both carry these headers:
X-RateLimit-Limit: 20
X-RateLimit-Remaining: 17
X-RateLimit-Reset: 1721312400
Reset is a UTC epoch second.
When you exceed the limit, the response is 429 with the standard
error envelope:
{"error": {"code": "rate_limited", "message": "Rate limit exceeded: 20 per 1 minute."}}
Wait for the window to reset before retrying. Do not spin.
Idempotency
Any create endpoint (single or bulk) accepts an optional
Idempotency-Key header. Setting it makes the call safe to retry
after a network error or timeout.
Idempotency-Key: my-request-uuid
Rules:
- Same
Idempotency-Key+ same request body twice: the second call returns the same success response as the first. No duplicate codes are created. - Same
Idempotency-Key+ a DIFFERENT request body: 422idempotency_key_reused. - Same
Idempotency-Keywhile the original is still in flight (rare): 409request_in_progress. Retry after a moment. - Absent header: every call is treated independently.
Keys are scoped to the API key that sent them and are held for a short window; do not use a single global key across days.
The maximum key length is 128 chars.
Idempotency on POST /codes/bulk/zip
The ZIP endpoint follows the same idempotency contract, with one implementation detail. The zip binary itself is NOT cached in the idempotency ledger. On replay, the endpoint looks up the codes that were created on the first call and rebuilds the archive fresh. This means:
- Codes are created exactly once, even under repeated retries with
the same
Idempotency-Keyand body. - Both retries return a valid ZIP with the same filenames.
- The archive bytes may differ between retries (the codes are the same, but zip metadata like compression internals is not guaranteed byte-identical). Do not compare zip bytes across retries; compare the codes.
Security
- Keys are stored as HMAC-SHA256 hashes. AQRHub never persists the plaintext secret. The full key is shown to the operator once at creation and cannot be retrieved again; a lost key must be revoked and replaced.
- Key verification runs in constant time, so response-time differences cannot be used to guess whether an incorrect secret was close to the real one.
- All API traffic must use HTTPS. Keys travel inside the
Authorizationheader over an encrypted TLS connection. Plain-HTTP requests are refused. - Each key is scoped to a single account. A request authenticated with the key can only read and modify data belonging to that account; there is no cross-account access.
- Per-key rate limits (see above) cap how fast one caller can hit the API, so a compromised key cannot be used to flood the service with unbounded traffic.
- Every destination URL is validated for shape (http or https, up to
2048 chars, well-formed host) and checked against Google Safe
Browsing before a code is created. Flagged URLs return
unsafe_urland no code is stored. - The API accepts no personal or payment data. The only inputs it
stores are the destination URL, an optional label, and an optional
client_idyou provide. Scan analytics are counted in aggregate and are not tied to a scanner's identity.
Keeping your key safe
- Store the key in a secret manager or environment variable. Do not hardcode it in source files.
- Never commit a key to version control, and never paste it into client-side code (browser JavaScript, mobile app bundles, static HTML). A key placed in a browser is public.
- If a key is exposed (accidental commit, screenshot, log line), revoke it from the dashboard immediately and create a new one. Rotating a key takes seconds.
- Use a separate key per system or environment (production, staging, each backend service). You can then revoke one without disrupting the others.
Error codes
Alphabetical:
| Code | HTTP | Meaning |
|---|---|---|
api_key_revoked |
403 | The key was revoked from the dashboard. |
batch_too_large |
400 | More than 100 items in the bulk request. |
duplicate_client_id |
400 | Same client_id appears twice in one batch. |
idempotency_key_reused |
422 | Same Idempotency-Key, different body. |
invalid_api_key |
401 | Missing, malformed, or unrecognized API key. |
invalid_client_id |
400 | Missing (on bulk) or too long. |
invalid_idempotency_key |
400 | Header value is over 128 chars. |
invalid_items |
400 | items is not a non-empty array. |
invalid_json |
400 | Body isn't a JSON object. |
invalid_name |
400 | name is over 100 chars or wrong type. |
invalid_url |
400 | Missing, wrong scheme, malformed, over 2048 chars. |
not_found |
404 | Code id doesn't belong to you (or doesn't exist). |
plan_limit_reached |
403 | Would exceed the account's max code count. |
plan_required |
403 | Account is not on Pro. |
rate_limited |
429 | Too many requests. Back off and retry. |
request_in_progress |
409 | Same Idempotency-Key is still processing. |
unsafe_url |
400 | URL was flagged by Google Safe Browsing. |