GET /me
Inspect current API key
Auth: BearerAuth
Responses
| Status | Description |
|---|---|
200 | OK |
Live, hand-maintained spec rendered with Scalar. Click any endpoint and use Test Request to send a real call.
links:write scope.
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" }' short_url to test the redirect, then poll
/api/v1/links/{code}/stats?period=7d for analytics.
Qrindo Public API v1.0 · 40 public endpoints · Base URL: https://api.qrindo.com/api/v1
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.
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 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..."
}
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.
Per-key per-minute, with RFC 9331
headers (RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset).
The interactive reference below reads the same spec and can send real requests.
GET /me
Auth: BearerAuth
Responses
| Status | Description |
|---|---|
200 | OK |
GET /links
Auth: BearerAuth
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
cursor | query | string | — | |
limit | query | integer | — |
Responses
| Status | Description |
|---|---|
200 | OK |
401 | Missing, malformed, expired, or revoked token. |
429 | Per-key minute window exceeded. |
POST /links
Auth: BearerAuth
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
Idempotency-Key | header | string | — |
Request body (JSON)
| Name | Type | Required | Description |
|---|---|---|---|
target_url | string (uri) | yes | |
code | string | — | Optional vanity slug. 4-8 chars, [A-Za-z0-9_-]. |
title | string | — | |
active | boolean | — |
Responses
| Status | Description |
|---|---|
201 | Created |
400 | Validation failed |
409 | Code conflict or idempotency mismatch |
GET /links/{code}
Auth: BearerAuth
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
code | path | string | yes |
Responses
| Status | Description |
|---|---|
200 | OK |
404 | Resource not found. |
PATCH /links/{code}
Auth: BearerAuth
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
code | path | string | yes |
Request body (JSON)
| Name | Type | Required | Description |
|---|---|---|---|
target_url | string (uri) | — | |
title | string | — | |
active | boolean | — |
Responses
| Status | Description |
|---|---|
200 | OK |
DELETE /links/{code}
Auth: BearerAuth
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
code | path | string | yes |
Responses
| Status | Description |
|---|---|
204 | Deleted |
404 | Resource not found. |
POST /links/{code}/rotate
Auth: BearerAuth
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
code | path | string | yes |
Request body (JSON)
| Name | Type | Required | Description |
|---|---|---|---|
target_url | string (uri) | yes |
Responses
| Status | Description |
|---|---|
200 | OK |
POST /links/batch
Returns 201 if all succeed, 207 (multi-status) on partial failure. Each item reports its own success/error.
Auth: BearerAuth
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
Idempotency-Key | header | string | — |
Request body (JSON)
| Name | Type | Required | Description |
|---|---|---|---|
items | CreateLink[] | yes |
Responses
| Status | Description |
|---|---|
201 | All created |
207 | Partial success |
401 | Missing, malformed, expired, or revoked token. |
PATCH /links/batch
Auth: BearerAuth
Request body (JSON)
| Name | Type | Required | Description |
|---|---|---|---|
codes | string[] | yes | |
active | boolean | — | |
tags | string[] | — | |
folder_id | string | — |
Responses
| Status | Description |
|---|---|
200 | OK |
207 | Partial success |
DELETE /links/batch
Auth: BearerAuth
Request body (JSON)
| Name | Type | Required | Description |
|---|---|---|---|
codes | string[] | yes |
Responses
| Status | Description |
|---|---|
200 | OK |
207 | Partial success |
POST /links/import
Accepts text/csv or multipart/form-data (a `file` field). CSV columns: target_url,code,title,tags (tags pipe-separated).
Auth: BearerAuth
Responses
| Status | Description |
|---|---|
201 | All imported |
207 | Partial import |
413 | Upload exceeds the size limit. |
GET /qr/{codeAndExt}
Auth: BearerAuth
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
codeAndExt | path | string | yes | |
size | query | integer | — | |
fg | query | string | — | |
bg | query | string | — | |
ec | query | "L" | "M" | "Q" | "H" | — |
Responses
| Status | Description |
|---|---|
200 | PNG or SVG bytes |
POST /qr/render
Auth: BearerAuth
Request body (JSON)
| Name | Type | Required | Description |
|---|---|---|---|
content | string | yes | |
format | "png" | "svg" | — | |
size | integer | — | |
fg | string | — | |
bg | string | — | |
ec | "L" | "M" | "Q" | "H" | — |
Responses
| Status | Description |
|---|---|
200 | Rendered image |
POST /qr/batch
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)
| Name | Type | Required | Description |
|---|---|---|---|
codes | string[] | yes | |
format | "png" | "svg" | — | |
size | integer | — | |
fg | string | — | |
bg | string | — |
Responses
| Status | Description |
|---|---|
200 | ZIP archive of QR images |
404 | None of the provided codes resolved to a link |
GET /links/{code}/stats
Scans over time. `granularity` controls the bucket size; a size too fine for the window is rejected with 400 rather than silently downgraded, and a response never exceeds 1500 points.
Auth: BearerAuth
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
code | path | string | yes | |
period | query | "7d" | "30d" | "90d" | "1y" | — | |
from | query | string | — | Window start: a calendar day (YYYY-MM-DD, read in `tz`) or a full ISO instant. Instants are what make sub-day resolution reachable. |
to | query | string | — | Window end, same formats as `from`. Exclusive. |
tz | query | string | — | IANA zone every bucket and timestamp is expressed in. Defaults to UTC, which is rarely what you want if your audience is not in it. |
granularity | query | "auto" | "minute" | "5min" | "hour" | "day" | "week" | — |
Responses
| Status | Description |
|---|---|
200 | OK |
GET /links/{code}/breakdown
Auth: BearerAuth
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
code | path | string | yes | |
period | query | "7d" | "30d" | "90d" | "1y" | — | |
from | query | string | — | Window start: a calendar day (YYYY-MM-DD, read in `tz`) or a full ISO instant. Instants are what make sub-day resolution reachable. |
to | query | string | — | Window end, same formats as `from`. Exclusive. |
tz | query | string | — | IANA zone every bucket and timestamp is expressed in. Defaults to UTC, which is rarely what you want if your audience is not in it. |
dim | query | "country" | "device" | "os" | "browser" | "referrer" | — |
Responses
| Status | Description |
|---|---|
200 | OK |
GET /links/{code}/events
One row per recorded scan, newest first, each with its exact timestamp. Keyset-paginated: pass the previous response's `next_cursor` as `cursor`. There is no offset and no total count, both are expensive on the underlying time-series table. Individual scans are retained for 365 days.
Auth: BearerAuth
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
code | path | string | yes | |
period | query | "7d" | "30d" | "90d" | "1y" | — | |
from | query | string | — | Window start: a calendar day (YYYY-MM-DD, read in `tz`) or a full ISO instant. Instants are what make sub-day resolution reachable. |
to | query | string | — | Window end, same formats as `from`. Exclusive. |
tz | query | string | — | IANA zone every bucket and timestamp is expressed in. Defaults to UTC, which is rarely what you want if your audience is not in it. |
cursor | query | string | — | |
limit | query | integer | — | |
include_bots | query | boolean | — | Bots are excluded unless you ask for them. |
country | query | string | — | |
city | query | string | — | |
region | query | string | — | |
device | query | string | — | |
os | query | string | — | |
browser | query | string | — | |
referrer_host | query | string | — | |
visitor | query | string | — | Salted visitor hash, as returned in `visitor`. |
Responses
| Status | Description |
|---|---|
200 | OK |
GET /events
Auth: BearerAuth
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
cursor | query | string | — | |
limit | query | integer | — | |
from | query | string (date-time) | — | |
to | query | string (date-time) | — | |
code | query | string | — |
Responses
| Status | Description |
|---|---|
200 | OK |
POST /public/preview
Auth: public
Request body (JSON)
| Name | Type | Required | Description |
|---|---|---|---|
target_url | string (uri) | yes |
Responses
| Status | Description |
|---|---|
200 | Disposable short link + QR |
429 | Per-key minute window exceeded. |
GET /public/stats/{code}
Auth: public
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
code | path | string | yes |
Responses
| Status | Description |
|---|---|
200 | OK |
404 | Resource not found. |
429 | Per-key minute window exceeded. |
GET /public/preview/{codeAndExt}
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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
codeAndExt | path | string | yes |
Responses
| Status | Description |
|---|---|
200 | QR image |
404 | Resource not found. |
GET /public/platform-stats
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
| Status | Description |
|---|---|
200 | OK |
GET /webhooks
Auth: BearerAuth
Responses
| Status | Description |
|---|---|
200 | OK |
401 | Missing, malformed, expired, or revoked token. |
POST /webhooks
The `secret` is returned once; use it to verify the Qrindo-Signature header (t=...,v1=HMAC-SHA256("{t}.{rawBody}")).
Auth: BearerAuth
Request body (JSON)
| Name | Type | Required | Description |
|---|---|---|---|
url | string (uri) | yes | |
events | "scan.created" | "link.created" | "link.updated" | "link.deleted"[] | yes | |
active | boolean | — |
Responses
| Status | Description |
|---|---|
201 | Created (secret returned once) |
400 | Validation failed. |
402 | Plan webhook limit reached |
DELETE /webhooks/{id}
Auth: BearerAuth
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string | yes |
Responses
| Status | Description |
|---|---|
204 | Deleted |
404 | Resource not found. |
POST /webhooks/{id}/test
Auth: BearerAuth
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string | yes |
Responses
| Status | Description |
|---|---|
200 | Queued |
GET /webhooks/{id}/deliveries
Auth: BearerAuth
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string | yes |
Responses
| Status | Description |
|---|---|
200 | OK |
404 | Resource not found. |
GET /deeplinks
Auth: BearerAuth
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
cursor | query | string | — | |
limit | query | integer | — |
Responses
| Status | Description |
|---|---|
200 | OK |
401 | Missing, malformed, expired, or revoked token. |
POST /deeplinks
Auth: BearerAuth
Request body (JSON)
| Name | Type | Required | Description |
|---|---|---|---|
code | string | — | |
title | string | — | |
targets | object | — | |
active | boolean | — |
Responses
| Status | Description |
|---|---|
201 | Created |
400 | Validation failed. |
GET /deeplinks/{code}
Auth: BearerAuth
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
code | path | string | yes |
Responses
| Status | Description |
|---|---|
200 | OK |
404 | Resource not found. |
PATCH /deeplinks/{code}
Auth: BearerAuth
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
code | path | string | yes |
Request body (JSON)
| Name | Type | Required | Description |
|---|---|---|---|
code | string | — | |
title | string | — | |
targets | object | — | |
active | boolean | — |
Responses
| Status | Description |
|---|---|
200 | OK |
DELETE /deeplinks/{code}
Auth: BearerAuth
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
code | path | string | yes |
Responses
| Status | Description |
|---|---|
204 | Deleted |
404 | Resource not found. |
GET /sites
Every site in the workspace the API key belongs to. Not paginated.
Auth: BearerAuth
Responses
| Status | Description |
|---|---|
200 | OK |
401 | Missing, malformed, expired, or revoked token. |
POST /sites
Requires the `links:write` scope.
Auth: BearerAuth
Request body (JSON)
| Name | Type | Required | Description |
|---|---|---|---|
name | string | yes | |
pack_key | string | — | Unit template pack to seed labels and destinations from. |
external_id | string | — | |
address | string | — |
Responses
| Status | Description |
|---|---|
201 | Created |
400 | Validation failed. |
401 | Missing, malformed, expired, or revoked token. |
GET /sites/{id}
Auth: BearerAuth
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string | yes | Site id (ULID). |
Responses
| Status | Description |
|---|---|
200 | OK |
404 | Resource not found. |
GET /sites/{id}/units
Returns units in fixed unit-number order, including any gaps left by deleted units, so a printed sheet keeps its numbering.
Auth: BearerAuth
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string | yes | Site id (ULID). |
Responses
| Status | Description |
|---|---|
200 | OK |
404 | Resource not found. |
POST /sites/{id}/units/batch
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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string | yes | Site id (ULID). |
Request body (JSON)
| Name | Type | Required | Description |
|---|---|---|---|
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
| Status | Description |
|---|---|
201 | Created (inspect `results` for per-unit outcomes) |
400 | Validation failed. |
404 | Resource not found. |
GET /sites/{id}/leaderboard
Units ranked by scan count, plus the count of units never scanned.
Auth: BearerAuth
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string | yes | Site id (ULID). |
Responses
| Status | Description |
|---|---|
200 | OK |
404 | Resource not found. |
POST /conversions
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)
| Name | Type | Required | Description |
|---|---|---|---|
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
| Status | Description |
|---|---|
200 | Already recorded. The same `external_id` arrived before; this is the answer to a retry and carries the id of the conversion that already exists. |
201 | Recorded |
400 | No join key was sent, or the body failed validation. |
404 | The code is not one this key's workspace owns. |
POST /oauth/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)
| Name | Type | Required | Description |
|---|---|---|---|
grant_type | "client_credentials" | yes | |
client_id | string | yes | |
client_secret | string | yes | |
scope | string | — |
Responses
| Status | Description |
|---|---|
200 | Token issued |
400 | Unsupported grant type |
401 | Invalid client |
415 | Unsupported content type |
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