AQRHub
There's A QR code for that.™

API documentation

Programmatic access for creating dynamic QR codes.

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_items or batch_too_large.
  • Duplicate client_id in the same batch returns 400 with code duplicate_client_id naming 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/png or image/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: 422 idempotency_key_reused.
  • Same Idempotency-Key while the original is still in flight (rare): 409 request_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-Key and 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 Authorization header 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_url and 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_id you 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.