API Documentation
Create QR short links and change where they lead from your own systems. The API is free and available to every team. It works on the same data as the dashboard, so a code created through the API shows up there right away.
- Authentication
- The link object
- List
- Create
- Get one
- Update
- Delete
- Statistics
- Errors
- Limits
- What a scan returns
Authentication
Base URL: https://qr-redirect-vercel.vercel.app. All endpoints live under /api/v1, accept and return JSON, and need an API key in the Authorization header:
Authorization: Bearer qr_your_key
The team owner creates and revokes keys in the dashboard: Settings → Developer API. A key is shown once, when it is created. Only its hash is stored, so a lost key cannot be recovered: revoke it and create a new one. A key gives access to every QR code of the team. When a person leaves the team, the keys they created stop working.
The link object
| Field | Type | Meaning |
|---|---|---|
short_id | string | The id printed in the QR code. Never changes. |
short_url | string | Full address encoded in the QR code: https://qr-redirect-vercel.vercel.app/<short_id>. |
target_url | string | Where a scan is redirected right now. |
label | string | null | Name shown in the dashboard. Up to 120 characters, longer values are cut. |
status | string | active, paused or archived. Only active codes redirect. |
tags | string | null | JSON-encoded array of tags, for example "[\"print\",\"autumn\"]". |
expires_at | string | null | ISO 8601 date-time in UTC. |
expiry_behavior | string | inactive (default): after expires_at the code shows the inactive page. keep: it keeps redirecting. |
expired | boolean | true when expires_at is in the past. |
default_utm | string | null | JSON-encoded object of query parameters added to the target. Single-link responses only. |
append_utm | 0 | 1 | Whether default_utm is added on redirect. Parameters already present in the target win. Single-link responses only. |
pass_through_query | 0 | 1 | Whether the query string of the scanned URL is passed on to the target. Single-link responses only. |
folder_id, campaign_id, client_id | string | null | Grouping set in the dashboard. Single-link responses only. |
created_at, updated_at | string | ISO 8601 date-time in UTC. |
QR designs (colors, logo) are edited in the dashboard only. The API ignores qr_style.
List links
GET /api/v1/links returns the team's links, newest first. Deleted links are never returned.
| Query | Default | Rules |
|---|---|---|
limit | 200 | Whole number. Values outside 1–200 are clamped into that range. |
offset | 0 | Non-negative whole number. Read page after page until a page comes back shorter than limit. |
curl "https://qr-redirect-vercel.vercel.app/api/v1/links?limit=50&offset=0" \
-H "Authorization: Bearer qr_your_key"
Response 200:
{ "links": [ { "short_id": "menu", "short_url": "https://qr-redirect-vercel.vercel.app/menu", "target_url": "https://example.com/menu",
"label": "Menu", "status": "active", "tags": null, "expires_at": null, "expiry_behavior": "inactive",
"expired": false, "created_at": "2026-09-18T09:14:27.203Z", "updated_at": "2026-09-18T09:14:27.203Z" } ] }
Anything that is not a whole number answers 400 invalid_pagination.
Create a link
POST /api/v1/links
curl -X POST https://qr-redirect-vercel.vercel.app/api/v1/links \
-H "Authorization: Bearer qr_your_key" \
-H "Content-Type: application/json" \
-d '{"target_url":"https://example.com/menu","label":"Menu","custom_slug":"menu"}'
| Field | Required | Rules |
|---|---|---|
target_url | yes | http or https, up to 4,096 characters. Private, loopback and link-local addresses are rejected. long_url is accepted as an alias. |
custom_slug | no | 2–40 characters a-z 0-9 _ -. Stored in lower case. Reserved words such as admin or docs are rejected. Without it a random 6-character id is generated. |
label | no | Up to 120 characters. |
tags | no | Array of strings or one comma-separated string. Up to 20 tags of 40 characters. |
expires_at | no | YYYY-MM-DD (midnight UTC) or an ISO 8601 date-time. Must be in the future. |
expiry_behavior | no | inactive (default) or keep. |
default_utm | no | Object of up to 10 entries. Keys a-z 0-9 _ up to 40 characters, values up to 200 characters, 1 KB in total. |
append_utm | no | Boolean. Add default_utm to the target on redirect. |
pass_through_query | no | Boolean. Pass the scanned URL's query string on to the target. |
folder_id, campaign_id, client_id | no | Ids of a folder, campaign or client of the same team. |
Response 201:
{ "short_id": "menu", "short_url": "https://qr-redirect-vercel.vercel.app/menu", "target_url": "https://example.com/menu",
"status": "active", "label": "Menu", "expires_at": null }
Print the QR code only after you have scanned short_url with a phone and seen the right page.
Get one link
GET /api/v1/links/{id} returns { "link": { … } } with every field of the link object. A deleted link, or a link of another team, answers 404 not_found.
curl https://qr-redirect-vercel.vercel.app/api/v1/links/menu -H "Authorization: Bearer qr_your_key"
Update a link
PATCH /api/v1/links/{id} changes where a printed code leads. The code itself stays the same, and the change applies from the next scan. Send only the fields you want to change:
curl -X PATCH https://qr-redirect-vercel.vercel.app/api/v1/links/menu \
-H "Authorization: Bearer qr_your_key" \
-H "Content-Type: application/json" \
-d '{"target_url":"https://example.com/winter-menu"}'
Fields: target_url (or long_url), label, status (active, paused, archived), expires_at, expiry_behavior, default_utm, append_utm, pass_through_query, tags. The rules are the same as for creating. Two differences: expires_at may be in the past (the response then carries "expired": true), and null or an empty string clears expires_at, default_utm or tags. The short id cannot be changed.
Response 200: { "link": { … } } with the updated object. A body without any of these fields answers 400 empty_update. Bringing an archived link back counts against the active-code limit and can answer 403 limit_reached. Every change of target, status, label or expiry is written to the link's history in the dashboard, marked as made by the API.
Delete a link
DELETE /api/v1/links/{id} answers 200 { "ok": true }. The link disappears from lists, and the printed code shows the inactive page. Its scan history is kept. The short id is retired for good: nobody, including you, can register it again. To switch a code off for a while, use "status": "paused". To keep it out of the way and free a slot, use "archived". Both can be undone.
Statistics
GET /api/v1/links/{short_id}/stats returns the scan report of one code, GET /api/v1/analytics the report of the whole team. Both take the same query parameters as the dashboard: range=7|30|90|all or from=YYYY-MM-DD&to=YYYY-MM-DD, tz=<IANA timezone> (default: the team timezone, else UTC) and bots=1 to include bots and link previews. Numbers count people only unless bots=1 is given.
curl "https://qr-redirect-vercel.vercel.app/api/v1/links/aB3k9x/stats?range=30&tz=Asia/Dubai" \
-H "Authorization: Bearer qr_your_key"
Response fields: totals (total, uniques, last24h, bots, inactive), daily and hourly series (zero-filled), and breakdowns byCountry, byCity, byChannel, byDevice, byOs, byBrowser, byReferrer, byOutcome. The team report adds top (codes by scans).
Rate limit: 120 requests per minute per key. Above that the API answers 429 with Retry-After.
Errors
Every error is JSON with a readable message and a stable machine code:
{ "error": "Slug is taken", "code": "slug_taken" }
| Status | Code | When |
|---|---|---|
400 | invalid_target | target_url is missing, not http/https, too long, or points to a private address. |
400 | invalid_slug | custom_slug breaks the format or is a reserved word. |
400 | invalid_date | expires_at is not a real date, or is in the past when creating. |
400 | invalid_expiry_behavior, invalid_status, invalid_utm | The value is outside the rules above. |
400 | folder_not_found, campaign_not_found, client_not_found | The id does not belong to your team. |
400 | invalid_pagination | limit or offset is not a whole number. |
400 | empty_update | PATCH without any known field. |
401 | unauthorized | The key is missing, wrong or revoked. |
403 | limit_reached | The team already holds the maximum number of active QR codes. |
404 | not_found | No such link in your team, or it was deleted. |
409 | slug_taken | The id belongs to a live link. |
409 | slug_retired | The id was used before and can never be reused. |
413 | too_large | A field is over its size limit. Used for QR designs in the dashboard, listed here so clients handle it. |
503 | id_unavailable | A free random id could not be allocated. Retry the request. |
Fair-use limits
QR Service is free. To protect the shared infrastructure, a team can hold up to 1,000 active QR codes and 25 members. Archived and deleted codes do not count. Reaching the limit answers 403 limit_reached, and existing codes keep working. A team can have up to 50 API keys. One list request returns at most 200 links. Keep automated traffic reasonable: we may rate-limit clients that put the service at risk. If you need more room, write to hello@beizstudio.com.
What a scan returns
A QR code encodes https://qr-redirect-vercel.vercel.app/<short_id>. What a scanner gets back:
| Status | Case |
|---|---|
302 | Active code. Location is the current target. The response is never cached, so a new target applies from the next scan. |
404 | Unknown or deleted code. |
410 | Archived code, or expired code with expiry_behavior: "inactive". |
423 | Paused code. |
503 | The lookup failed on our side. Comes with Retry-After: 30. |
Every case except 302 shows the branded page "This QR code is not active". Preview it at /inactive?reason=paused (also unknown, expired, archived, deleted); the preview always answers 200. HEAD requests return the same status and are not counted as scans. Custom ids are not case-sensitive: /Menu finds /menu.
Help
Questions and bug reports: hello@beizstudio.com.