Developer API
Create, update and track your QR codes straight from your own system, without opening the dashboard. The API is REST, answers in JSON and is available on the Business plan.
Overview
Every route starts at https://qrstream.pro/api/v1. You send JSON, you get JSON back, and each request authenticates with an API key — there are no sessions and no cookies.
API access is part of the Business plan. You create your keys in your account, in the “API” section of /account#api. A key is shown once, when it is created: copy it then and keep it in a secret store. If you lose it, revoke it and create another.
What to know up front
- Every key starts with qrs_ and belongs to your account, not to one code or folder.
- You can hold up to 10 active keys at a time.
- Send data as Content-Type: application/json, encoded in UTF-8.
- Dates are ISO 8601 in UTC, for example 2026-09-12T07:41:02.000Z.
- Code and folder identifiers are UUIDs.
Authentication
Send the key in the Authorization header. The Bearer form is the recommended one; x-api-key works exactly the same and helps where your client cannot set Authorization.
Authorization: Bearer qrs_9f3a1c7b2e…
x-api-key: qrs_9f3a1c7b2e…Keys are for server-to-server calls. Never put one in a web page, a mobile app or a public repository: whoever holds the key can read and change all of your codes. For the same reason the API sends no CORS headers, so a browser cannot call it directly.
Permissions
You choose what a key can do when you create it. A read-only key answers GET requests and refuses every write with forbidden. A read and write key can create, edit and delete codes and folders.
| Permission | Can | Cannot |
|---|---|---|
| Read only | GET on codes, folders, analytics and /me | POST, PATCH, DELETE |
| Read and write | Everything read-only can do, plus create, edit, pause and delete | Manage keys or the subscription |
The right origin
Always call https://qrstream.pro/api/v1. If you use a custom domain for your dynamic codes, that domain only serves scan redirects and forwards API requests on with a redirect — some libraries drop the authorization header across a redirect, and the request then arrives unauthenticated.
Rate limits
Each key gets 120 requests per minute. The limit is counted per key, not per account, so you can split integrations across keys and keep one from blocking another.
Every response tells you where you stand. Over the limit you get a 429 with Retry-After, the number of seconds to wait before trying again.
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 118
Retry-After: 27| Header | Means |
|---|---|
| X-RateLimit-Limit | Requests allowed in a minute (120). |
| X-RateLimit-Remaining | How many are left in the current minute. |
| Retry-After | Seconds to wait, sent only with a 429 response. |
For large imports, space your requests out and back off with a growing delay on both 429 and 500.
Errors
Every 4xx and 5xx response uses the same envelope: an error object with a stable code, a message in English for your logs and, on validation, the list of problems found. Always branch on code, never on the message text — wording can change.
{
"error": {
"code": "validation_failed",
"message": "The request could not be understood.",
"details": [
{ "path": "content.url", "message": "An address is required" }
]
}
}| Code | Status | Means |
|---|---|---|
| unauthorized | 401 | Key missing, wrong, disabled or expired. |
| forbidden | 403 | The key lacks the permission asked for, usually a write with a read-only key. |
| plan_required | 403 | The account has no API access. Move to the Business plan. |
| account_suspended | 403 | The account is suspended. Get in touch and we will reopen it. |
| quota_exceeded | 403 | A plan limit was reached, for example the number of dynamic codes. |
| not_found | 404 | The resource does not exist, or is not yours. The two look alike on purpose. |
| conflict | 409 | A folder with that name already exists. Pick another and send the request again. |
| validation_failed | 422 | The request body is not valid; details lists the fields. |
| rate_limited | 429 | You went over the 120 requests a minute. |
| render_failed | 500 | The image could not be rendered. Retry, or ask for the SVG. |
| internal | 500 | Something broke on our side. Retry; if it keeps happening, tell us. |
Pagination
Lists are walked with a cursor rather than page numbers, so you never skip a row when codes are added while you are reading.
| Parameter | Values | Default |
|---|---|---|
| limit | 1–100 | 25 |
| cursor | The nextCursor from the previous response | — |
{
"data": [],
"nextCursor": "MjAyNi0wOC0xNFQwOToxMjo0NFp8N2YxYzBiNTI"
}A null nextCursor means you have reached the end. Send it back as ?cursor=… to ask for the next page.
Your account
GET /me
Tells you who you are, which plan you are on and how much of your limits you have used. It works with any valid key and is the simplest call for checking a new one.
limits holds the plan's ceilings and usage what you have spent so far. A null in limits means “no ceiling”, not zero.
curl -s "https://qrstream.pro/api/v1/me" \
-H "Authorization: Bearer $QRS_API_KEY"{
"user": {
"id": "u_3kf9x7b2qd",
"name": "Ana Popescu",
"email": "ana@example.com"
},
"plan": {
"slug": "business",
"name": "Business",
"source": "subscription",
"periodEnd": "2026-10-01T00:00:00.000Z"
},
"limits": {
"dynamicCodes": null,
"scansPerMonth": null,
"analyticsMonths": 36,
"logo": true,
"csvImport": true,
"customDomain": true,
"googleAnalytics": true,
"users": null,
"storageBins": null,
"binPhotos": 40,
"rackLabels": true,
"apiAccess": true
},
"usage": {
"dynamicCodes": 64,
"storageBins": 3,
"scansThisMonth": 12840
},
"key": {
"id": "k_7d2a1f",
"permissions": {
"codes": [
"read",
"write"
],
"analytics": [
"read"
],
"folders": [
"read",
"write"
]
}
}
}Codes
GET /codes
Lists the account's codes, newest first. Filters combine: type (the code type), status (active or paused), folderId, and q to search by name.
curl -s "https://qrstream.pro/api/v1/codes?limit=25&type=website&status=active" \
-H "Authorization: Bearer $QRS_API_KEY"{
"data": [
{
"id": "7f1c0b52-1b7e-4a1e-9f0a-2d5b6c8e1a33",
"type": "website",
"name": "Summer menu",
"dynamic": true,
"status": "active",
"shortUrl": "https://qrstream.pro/r/7Kd2mA",
"scanCount": 1284,
"createdAt": "2026-08-14T09:12:44.000Z"
}
],
"nextCursor": null
}POST /codes
Creates a code and answers 201. The required fields are type, name and content; content must carry the same type as the code. design is optional — without it the code gets the default design.
dynamic is optional and defaults to false. For the types that only exist as static or only as dynamic it is ignored: the code gets the form its type requires either way.
| Field | Type | Description |
|---|---|---|
| type | text | The code type: website, wifi, vcard, pdf, whatsapp and the rest. |
| dynamic | boolean | Optional, false by default. true creates an editable short link; false writes the content into the modules. |
| name | text | The name in the dashboard, 1–120 characters. |
| content | object | The content, matching the type. See the section below. |
| design | object | Optional: colours, shapes, logo, frame, correction level. |
| folderId | uuid or null | The folder the code goes into. |
| password | text or null | Password for the scan page; dynamic codes only. |
| expiry | text | none, 30d or date. |
| expiresOn | date or null | The expiry day, required when expiry is date. |
curl -s -X POST "https://qrstream.pro/api/v1/codes" \
-H "Authorization: Bearer $QRS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "website",
"dynamic": true,
"name": "Summer menu",
"content": { "type": "website", "url": "https://example.com/menu" },
"folderId": null,
"expiry": "none"
}'{
"id": "7f1c0b52-1b7e-4a1e-9f0a-2d5b6c8e1a33",
"type": "website",
"name": "Summer menu",
"dynamic": true,
"status": "active",
"shortUrl": "https://qrstream.pro/r/7Kd2mA",
"payload": "https://qrstream.pro/r/7Kd2mA",
"target": "https://example.com/menu",
"content": { "type": "website", "url": "https://example.com/menu" },
"folderId": null,
"hasPassword": false,
"expiresAt": null,
"scanCount": 0,
"createdAt": "2026-09-12T07:41:02.000Z"
}GET /codes/{id}
One code, with every field. An unknown id, or one from another account, answers 404.
curl -s "https://qrstream.pro/api/v1/codes/7f1c0b52-1b7e-4a1e-9f0a-2d5b6c8e1a33" \
-H "Authorization: Bearer $QRS_API_KEY"PATCH /codes/{id}
Only the fields you send are changed. The content of a static code cannot change after it is created — the modules are already printed — but the name, the design, the folder and the password can.
curl -s -X PATCH "https://qrstream.pro/api/v1/codes/7f1c0b52-1b7e-4a1e-9f0a-2d5b6c8e1a33" \
-H "Authorization: Bearer $QRS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Summer menu 2026",
"content": { "type": "website", "url": "https://example.com/menu?v=2" },
"folderId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
}'DELETE /codes/{id}
Answers 204, with no body. The delete is soft: the code leaves the dashboard and the list, and a dynamic code stops redirecting.
curl -s -i -X DELETE "https://qrstream.pro/api/v1/codes/7f1c0b52-1b7e-4a1e-9f0a-2d5b6c8e1a33" \
-H "Authorization: Bearer $QRS_API_KEY"POST /codes/{id}/pause and /resume
Stops or restarts a dynamic code without deleting it. The response is the updated code, with status paused or active.
curl -s -X POST "https://qrstream.pro/api/v1/codes/7f1c0b52-1b7e-4a1e-9f0a-2d5b6c8e1a33/pause" \
-H "Authorization: Bearer $QRS_API_KEY"
curl -s -X POST "https://qrstream.pro/api/v1/codes/7f1c0b52-1b7e-4a1e-9f0a-2d5b6c8e1a33/resume" \
-H "Authorization: Bearer $QRS_API_KEY"The code object
| Field | Type | Description |
|---|---|---|
| id | uuid | The code's identifier. |
| type | text | The code type. |
| name | text | The name in the dashboard. |
| dynamic | boolean | Whether it goes through a short link. |
| status | text | active or paused. |
| shortUrl | text or null | The short link; null on static codes. |
| payload | text | What is actually written into the code's modules. |
| target | text or null | The final destination of a dynamic code. |
| content | object | The structured content, carrying its own type. |
| design | object | The code's full design. |
| folderId | uuid or null | The folder it sits in. |
| hasPassword | boolean | Whether the scan page asks for a password. The password itself is never returned. |
| expiresAt | date or null | When it expires. |
| campaignStart | date or null | Start of the campaign. |
| campaignEnd | date or null | End of the campaign. |
| printRun | text or null | The print run note. |
| scanCount | number | Total scans. |
| lastScanAt | date or null | The last scan. |
| brokenLink | boolean | The destination did not answer on the last check. |
| createdAt | date | When it was created. |
| updatedAt | date | The last change. |
Content, by type
The content object carries its own type, the same one as the code. The most used types are below; the rest are described in the OpenAPI document.
website
{ "type": "website", "url": "https://example.com/menu" }wifi
encryption can be WPA, WEP or nopass. Leave password empty for an open network.
{
"type": "wifi",
"ssid": "Cafe guest",
"password": "0000pass",
"encryption": "WPA",
"hidden": false
}vcard
Every field is optional, but a card with none of them has nothing to show.
{
"type": "vcard",
"first": "Ana",
"last": "Popescu",
"phone": "+40 721 000 000",
"email": "ana@example.com",
"org": "The Green Cafe",
"title": "Manager",
"url": "https://example.com/menu"
}Send the number in international format; message is the pre-filled message.
{
"type": "whatsapp",
"phone": "+40721000000",
"message": "Hi! I would like to book a table."
}fileUrl is the address of a file you have already uploaded. File uploads are not part of v1 — upload the document from the dashboard and reuse its address.
{
"type": "pdf",
"fileName": "menu.pdf",
"fileUrl": "https://blob.qrstream.pro/…/menu.pdf"
}Images
Every code has two image routes. Both take size between 128 and 2048 pixels, 1024 by default, and need read permission on codes.
| Route | Content type | Good for |
|---|---|---|
| GET /codes/{id}/image.svg | image/svg+xml | Print and any size; vector, no loss. |
| GET /codes/{id}/image.png | image/png | Screens, emails, documents. |
curl -s "https://qrstream.pro/api/v1/codes/7f1c0b52-1b7e-4a1e-9f0a-2d5b6c8e1a33/image.svg?size=1024" \
-H "Authorization: Bearer $QRS_API_KEY" -o cod.svg
curl -s "https://qrstream.pro/api/v1/codes/7f1c0b52-1b7e-4a1e-9f0a-2d5b6c8e1a33/image.png?size=512" \
-H "Authorization: Bearer $QRS_API_KEY" -o cod.pngOn PNG the logo is fetched and embedded by the server before rendering, so it does appear in the image. Frame text is drawn with a system font rather than the brand font, so it can look slightly different from the dashboard preview. When you need it exact, ask for the SVG.
Analytics
GET /codes/{id}/analytics
One code's scans, by day. range can be 7, 30 or 90 days, 30 by default. Dates are ISO and the values in breakdowns are raw — operating-system keys and ISO country codes — so you can group them yourself.
The country and city lists in breakdowns are cut to the busiest 50 values, largest first. Everything past that is still counted in totals.
curl -s "https://qrstream.pro/api/v1/codes/7f1c0b52-1b7e-4a1e-9f0a-2d5b6c8e1a33/analytics?range=30" \
-H "Authorization: Bearer $QRS_API_KEY"{
"range": 30,
"code": {
"id": "7f1c0b52-1b7e-4a1e-9f0a-2d5b6c8e1a33",
"name": "Summer menu",
"type": "website",
"scanCount": 1284,
"lastScanAt": "2026-08-15T17:02:11.000Z"
},
"totals": { "scans": 1284, "unique": 902 },
"series": [
{ "date": "2026-08-14", "scans": 42, "unique": 31 },
{ "date": "2026-08-15", "scans": 57, "unique": 40 }
],
"breakdowns": {
"os": [
{ "key": "ios", "value": 812 },
{ "key": "android", "value": 401 }
],
"country": [{ "key": "RO", "value": 1180 }],
"city": [{ "key": "Bucharest", "value": 640 }]
}
}GET /codes/{id}/scans.csv
The same scans, row by row, as text/csv. The response is streamed, so it works for codes with a lot of scans too.
curl -s "https://qrstream.pro/api/v1/codes/7f1c0b52-1b7e-4a1e-9f0a-2d5b6c8e1a33/scans.csv" \
-H "Authorization: Bearer $QRS_API_KEY" -o scanari.csvGET /analytics
The same picture for the whole account: totals, the daily series and the most scanned codes. How far back you can ask depends on your plan.
curl -s "https://qrstream.pro/api/v1/analytics?range=90" \
-H "Authorization: Bearer $QRS_API_KEY"{
"range": 90,
"totals": { "scans": 41280, "unique": 28904 },
"series": [{ "date": "2026-06-14", "scans": 402, "unique": 311 }],
"topCodes": [
{ "id": "7f1c0b52-1b7e-4a1e-9f0a-2d5b6c8e1a33", "name": "Summer menu", "type": "website", "scans": 1284 }
],
"breakdowns": {
"os": [{ "key": "ios", "value": 24310 }],
"country": [{ "key": "RO", "value": 39002 }],
"city": [{ "key": "Bucharest", "value": 18770 }]
}
}Folders
Folders group codes in the dashboard. A folder without a scope is an ordinary folder; scope can be storage for bins or location for rack labels. Deleting a folder does not delete the codes inside it.
| Route | Permission | Does |
|---|---|---|
| GET /folders | folders: read | Every folder, with its code count. |
| POST /folders | folders: write | Creates a folder; answers 201. |
| PATCH /folders/{id} | folders: write | Renames the folder. |
| DELETE /folders/{id} | folders: write | Deletes the folder; answers 204. |
curl -s "https://qrstream.pro/api/v1/folders" \
-H "Authorization: Bearer $QRS_API_KEY"
curl -s -X POST "https://qrstream.pro/api/v1/folders" \
-H "Authorization: Bearer $QRS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Campaigns", "scope": null }'
curl -s -X PATCH "https://qrstream.pro/api/v1/folders/1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" \
-H "Authorization: Bearer $QRS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Campaigns 2026" }'
curl -s -i -X DELETE "https://qrstream.pro/api/v1/folders/1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" \
-H "Authorization: Bearer $QRS_API_KEY"{
"data": [
{
"id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"name": "Campaigns",
"color": null,
"scope": null,
"createdAt": "2026-08-14T09:12:44.000Z",
"updatedAt": "2026-08-14T09:12:44.000Z"
}
]
}OpenAPI
The full description of the API is published as an OpenAPI 3.1 document at https://qrstream.pro/api/v1/openapi.json. It is public, needs no key, and you can feed it to a client generator or open it in Postman or Insomnia.
curl -s https://qrstream.pro/api/v1/openapi.json -o openapi.jsonThat document is the source of truth for the content and design schemas of every code type. This page only covers what you reach for most.
MCP server for AI agents
MCP (Model Context Protocol) is how an AI agent discovers the tools it can use and then calls them on its own. Our MCP server exposes the same operations as the REST API — one tool per endpoint — and works on the account the key belongs to.
The server address
POST https://qrstream.pro/api/mcp
Authorization: Bearer qrs_9f3a1c7b2e…The server needs the Business plan, like the REST API, and spends the same 120 requests a minute the key has. Connecting a client costs 3 requests, because the agent runs the handshake first: initialize, notifications/initialized and tools/list.
Claude Code
claude mcp add --transport http qrstream https://qrstream.pro/api/mcp --header "Authorization: Bearer qrs_YOUR_KEY"Cursor — .cursor/mcp.json
{
"mcpServers": {
"qrstream": {
"url": "https://qrstream.pro/api/mcp",
"headers": {
"Authorization": "Bearer qrs_YOUR_KEY"
}
}
}
}Claude Desktop — claude_desktop_config.json
{
"mcpServers": {
"qrstream": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://qrstream.pro/api/mcp",
"--header",
"Authorization: Bearer ${QRSTREAM_KEY}"
],
"env": {
"QRSTREAM_KEY": "qrs_YOUR_KEY"
}
}
}
}Custom connectors in claude.ai require OAuth, so this server, which authenticates with an API key, connects from Claude Code, Cursor, VS Code and Claude Desktop (through mcp-remote --header). OAuth stays a possible later phase.
| Tool | Access | What it does |
|---|---|---|
| qrstream_get_account | any key | Tells you which plan you are on, how much of your limits you have used and what the key may do. |
| qrstream_list_codes | codes:read | Lists the account's codes, with filters by type, status, folder and name. |
| qrstream_get_code | codes:read | Returns a single code, with every one of its fields. |
| qrstream_create_code | codes:write | Creates a new code, static or dynamic, from the content you give it. |
| qrstream_update_code | codes:write | Changes a code's name, content, design, folder or password. |
| qrstream_delete_code | codes:write | Deletes a code; a dynamic code stops redirecting once it is gone. |
| qrstream_pause_code | codes:write | Pauses a dynamic code without deleting it. |
| qrstream_resume_code | codes:write | Restarts a paused code. |
| qrstream_get_code_image_svg | codes:read | Returns the code's image as an SVG document, the one to use for print. |
| qrstream_get_code_image_png | codes:read | Returns the code's image as a PNG, for screens, emails and documents. |
| qrstream_get_code_analytics | analytics:read | One code's scans over 7, 30 or 90 days, with totals and breakdowns. |
| qrstream_get_code_scans | analytics:read | One code's scans, row by row, as CSV text. |
| qrstream_get_account_analytics | analytics:read | The same picture for the whole account: totals, the daily series and the most scanned codes. |
| qrstream_list_folders | folders:read | Lists the account's folders, with the number of codes in each. |
| qrstream_create_folder | folders:write | Creates a new folder, an ordinary one or one for bins or rack labels. |
| qrstream_rename_folder | folders:write | Renames an existing folder. |
| qrstream_delete_folder | folders:write | Deletes a folder; the codes inside it stay, but leave the folder. |
- SVG and PNG images go up to 1024 pixels; for anything larger use the REST API's image routes.
- The scan list stops at 500 rows; take the full export from the REST API's CSV route.
- Lists are walked with a cursor: when a response carries nextCursor, send it back to ask for the next page.
- Write tools refuse a read-only key with a clear message, which the agent shows you.