API reference

Live, hand-maintained spec rendered with Scalar. Click any endpoint and use Test Request to send a real call.

Quickstart, 60 seconds

  1. 1. Mint a key in Settings → API keys with the links:write scope.
  2. 2. Send your first request:
    curl -X POST https://api.qrindo.com/api/v1/links \
      -H "Authorization: Bearer $QRINDO_KEY" \
      -H "Idempotency-Key: $(uuidgen)" \
      -H "Content-Type: application/json" \
      -d '{ "target_url": "https://example.com/launch", "title": "Launch" }'
  3. 3. Visit the returned short_url to test the redirect, then poll /api/v1/links/{code}/stats?period=7d for analytics.

Core concepts

Qrindo Public API v1.0 · 40 public endpoints · Base URL: https://api.qrindo.com/api/v1

Overview

Bearer-token (API key or OAuth client_credentials) REST API for the Qrindo platform. Designed for partner products that need to mint dynamic links, render QR codes, read analytics, and subscribe to scan events.

Authentication

Every request requires Authorization: Bearer <token>.

Token kind Format Best for
API key qrf_live_… / qrf_test_… Long-lived backend integrations
OAuth access token JWT (RS256) Compliance / rotation-heavy partners

Errors

Errors follow RFC 7807 problem-json.

{
  "type": "https://qrindo.com/errors/rate_limited",
  "title": "Too many requests",
  "status": 429,
  "code": "rate_limited",
  "detail": "Rate limit exceeded: 10000 req/min per API key. Retry in 12s.",
  "instance": "01HRV..."
}

Idempotency

Pass Idempotency-Key: <uuid> on any POST. Retrying with the same key + body replays the cached response. Retrying with a different body returns 409 idempotency_replay_mismatch.

Rate limits

Per-key per-minute, with RFC 9331 headers (RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset).

All endpoints

The interactive reference below reads the same spec and can send real requests.

Identity

GET /me

Inspect current API key

Auth: BearerAuth

Responses

StatusDescription
200OK

POST /links

Create a link

Auth: BearerAuth

Parameters

NameInTypeRequiredDescription
Idempotency-Key header string —

Request body (JSON)

NameTypeRequiredDescription
target_url string (uri) yes
code string — Optional vanity slug. 4-8 chars, [A-Za-z0-9_-].
title string —
active boolean —

Responses

StatusDescription
201Created
400Validation failed
409Code conflict or idempotency mismatch

POST /links/{code}/rotate

Rotate the destination, keep the code

Auth: BearerAuth

Parameters

NameInTypeRequiredDescription
code path string yes

Request body (JSON)

NameTypeRequiredDescription
target_url string (uri) yes

Responses

StatusDescription
200OK

POST /links/batch

Create up to 100 links in one call

Returns 201 if all succeed, 207 (multi-status) on partial failure. Each item reports its own success/error.

Auth: BearerAuth

Parameters

NameInTypeRequiredDescription
Idempotency-Key header string —

Request body (JSON)

NameTypeRequiredDescription
items CreateLink[] yes

Responses

StatusDescription
201All created
207Partial success
401Missing, malformed, expired, or revoked token.

POST /links/import

Bulk import links from CSV

Accepts text/csv or multipart/form-data (a `file` field). CSV columns: target_url,code,title,tags (tags pipe-separated).

Auth: BearerAuth

Responses

StatusDescription
201All imported
207Partial import
413Upload exceeds the size limit.

QR

GET /qr/{codeAndExt}

Render a stored link's QR

Auth: BearerAuth

Parameters

NameInTypeRequiredDescription
codeAndExt path string yes
size query integer —
fg query string —
bg query string —
ec query "L" | "M" | "Q" | "H" —

Responses

StatusDescription
200PNG or SVG bytes

POST /qr/render

Render arbitrary content (no link stored)

Auth: BearerAuth

Request body (JSON)

NameTypeRequiredDescription
content string yes
format "png" | "svg" —
size integer —
fg string —
bg string —
ec "L" | "M" | "Q" | "H" —

Responses

StatusDescription
200Rendered image

POST /qr/batch

Render many stored links into one ZIP

Renders up to 50 of your links as PNG or SVG and streams them back as a single ZIP archive. Codes that don't belong to the workspace are skipped.

Auth: BearerAuth

Request body (JSON)

NameTypeRequiredDescription
codes string[] yes
format "png" | "svg" —
size integer —
fg string —
bg string —

Responses

StatusDescription
200ZIP archive of QR images
404None of the provided codes resolved to a link

Analytics

Events

GET /events

List raw scan events

Auth: BearerAuth

Parameters

NameInTypeRequiredDescription
cursor query string —
limit query integer —
from query string (date-time) —
to query string (date-time) —
code query string —

Responses

StatusDescription
200OK

Public

POST /public/preview

Demo-only: ten-minute disposable short link

Auth: public

Request body (JSON)

NameTypeRequiredDescription
target_url string (uri) yes

Responses

StatusDescription
200Disposable short link + QR
429Per-key minute window exceeded.

GET /public/stats/{code}

Public scan stats for a link (no auth)

Auth: public

Parameters

NameInTypeRequiredDescription
code path string yes

Responses

StatusDescription
200OK
404Resource not found.
429Per-key minute window exceeded.

GET /public/preview/{codeAndExt}

QR image for a demo preview link

Renders the QR for a code created by `POST /public/preview`. Unauthenticated and rate-limited. The preview expires 10 minutes after creation, after which this returns 404.

Auth: public

Parameters

NameInTypeRequiredDescription
codeAndExt path string yes

Responses

StatusDescription
200QR image
404Resource not found.

GET /public/platform-stats

Aggregate platform counters

Lifetime totals powering the marketing site's live numbers. Unauthenticated, rate-limited, and served from a 5-minute cache — `cached: true` marks a response served from it. Scans are lifetime per-link counters, so the number never decreases as old scan events age out of retention.

Auth: public

Responses

StatusDescription
200OK

Webhooks

GET /webhooks

List webhook subscriptions

Auth: BearerAuth

Responses

StatusDescription
200OK
401Missing, malformed, expired, or revoked token.

POST /webhooks

Create a webhook subscription

The `secret` is returned once; use it to verify the Qrindo-Signature header (t=...,v1=HMAC-SHA256("{t}.{rawBody}")).

Auth: BearerAuth

Request body (JSON)

NameTypeRequiredDescription
url string (uri) yes
events "scan.created" | "link.created" | "link.updated" | "link.deleted"[] yes
active boolean —

Responses

StatusDescription
201Created (secret returned once)
400Validation failed.
402Plan webhook limit reached

DELETE /webhooks/{id}

Delete a webhook subscription

Auth: BearerAuth

Parameters

NameInTypeRequiredDescription
id path string yes

Responses

StatusDescription
204Deleted
404Resource not found.

POST /webhooks/{id}/test

Send a test delivery (webhook.test event)

Auth: BearerAuth

Parameters

NameInTypeRequiredDescription
id path string yes

Responses

StatusDescription
200Queued

GET /webhooks/{id}/deliveries

List recent delivery attempts for a webhook

Auth: BearerAuth

Parameters

NameInTypeRequiredDescription
id path string yes

Responses

StatusDescription
200OK
404Resource not found.

POST /deeplinks

Create a deeplink

Auth: BearerAuth

Request body (JSON)

NameTypeRequiredDescription
code string —
title string —
targets object —
active boolean —

Responses

StatusDescription
201Created
400Validation failed.

Sites

GET /sites

List sites

Every site in the workspace the API key belongs to. Not paginated.

Auth: BearerAuth

Responses

StatusDescription
200OK
401Missing, malformed, expired, or revoked token.

POST /sites

Create a site

Requires the `links:write` scope.

Auth: BearerAuth

Request body (JSON)

NameTypeRequiredDescription
name string yes
pack_key string — Unit template pack to seed labels and destinations from.
external_id string —
address string —

Responses

StatusDescription
201Created
400Validation failed.
401Missing, malformed, expired, or revoked token.

GET /sites/{id}

Get one site

Auth: BearerAuth

Parameters

NameInTypeRequiredDescription
id path string yes Site id (ULID).

Responses

StatusDescription
200OK
404Resource not found.

GET /sites/{id}/units

List the units of a site

Returns units in fixed unit-number order, including any gaps left by deleted units, so a printed sheet keeps its numbering.

Auth: BearerAuth

Parameters

NameInTypeRequiredDescription
id path string yes Site id (ULID).

Responses

StatusDescription
200OK
404Resource not found.

POST /sites/{id}/units/batch

Create units in bulk

Creates up to 100 units and a link code for each. Per-unit success and failure are reported individually; the call itself returns 201 even when some units failed. Requires the `links:write` scope.

Auth: BearerAuth

Parameters

NameInTypeRequiredDescription
id path string yes Site id (ULID).

Request body (JSON)

NameTypeRequiredDescription
count integer —
start_at integer — First unit number. Defaults to the site's next free number.
label_pattern string — Label template, e.g. "Table {n}".
zone string —
destination object yes What every created unit points at. Same shape as a link's content.

Responses

StatusDescription
201Created (inspect `results` for per-unit outcomes)
400Validation failed.
404Resource not found.

GET /sites/{id}/leaderboard

Scan leaderboard for a site

Units ranked by scan count, plus the count of units never scanned.

Auth: BearerAuth

Parameters

NameInTypeRequiredDescription
id path string yes Site id (ULID).

Responses

StatusDescription
200OK
404Resource not found.

Conversions

POST /conversions

Report a conversion against a scan

Closes the loop on a scan. The redirect hands your destination a join key (`qr_click_id`); you hand a conversion back against it, and the scan that started it finally has an outcome. **Idempotency is the contract.** Every platform that sends conversion callbacks retries them — on a timeout, on a 500, on a deploy — and revenue that doubles on a retry is more dangerous than revenue that is missing: a missing number gets investigated, an inflated one gets believed. Send `external_id` (your own id for the conversion) and a replay answers `200` with `duplicate: true`, never an error you would queue and retry forever. Without it, a retry is indistinguishable from a second sale. **Send at least one join key** — `code`, `click_ref`, `session_id` or `visitor`. With none of them the conversion belongs to the workspace and to nothing inside it, which is a number that can be summed and never explained, so it is refused rather than accepted as an orphan. `value_cents` is in MINOR units and the field name says so: a field called `value` taking 12.50 from one integrator and 1250 from the next is a bug nobody can reproduce, and floating-point money is how a revenue report ends up fractionally wrong with nobody able to say why.

Auth: BearerAuth

Request body (JSON)

NameTypeRequiredDescription
code string — The short code. A code your key does not own is a 404, never someone else's attribution.
click_ref string — The `qr_click_id` the redirect appended to your destination URL.
session_id string —
visitor string —
kind string — Your name for this kind of conversion, e.g. `purchase`, `booking`.
value_cents integer — Minor units. 1250 is 12.50.
currency string — ISO 4217, three letters.
external_id string — Your id for this conversion. Send it — it is what makes a retry safe.
occurred_at string (date-time) — When it happened, if not now. ISO 8601.
metadata object —

Responses

StatusDescription
200Already recorded. The same `external_id` arrived before; this is the answer to a retry and carries the id of the conversion that already exists.
201Recorded
400No join key was sent, or the body failed validation.
404The code is not one this key's workspace owns.

OAuth

POST /oauth/token

Issue an access token

Client-credentials grant. Accepts `application/x-www-form-urlencoded` (per RFC 6749) or `application/json`; any other content type is 415. `client_credentials` is the only supported grant type. Errors use the OAuth `{error, error_description}` shape, NOT the RFC 9457 problem document the rest of this API returns.

Auth: public

Request body (JSON)

NameTypeRequiredDescription
grant_type "client_credentials" yes
client_id string yes
client_secret string yes
scope string —

Responses

StatusDescription
200Token issued
400Unsupported grant type
401Invalid client
415Unsupported content type

Try a request

The full reference above is part of this page. The interactive tester is a third-party bundle (Scalar), so it loads only when you ask for it. Click any endpoint inside it and use Test Request to send a real call.

Prefer the raw document? /openapi.yaml