API & Integrations
Create and manage QR codes, read analytics, and receive real-time events. The API is REST over HTTPS, returns JSON, and is scoped to a single organization per key.
Base URL
https://api.qroute.in/v1
Authentication
Authenticate with a secret API key, sent either as x-api-key or as a bearer token in the Authorization header. A key authenticates as the organization, not as a person. Create keys under API & Integrations. Keys are shown in full once and stored hashed. Never expose a secret key in client-side code.
curl https://api.qroute.in/api/v1/organizations/{orgId}/qr-codes \
-H "x-api-key: qrk_xxxxxxxxxxxxxxxxxxxxxxxx"
# equivalently
curl https://api.qroute.in/api/v1/organizations/{orgId}/qr-codes \
-H "Authorization: Bearer qrk_xxxxxxxxxxxxxxxxxxxxxxxx"Scopes
qr:readRead QR codes and their destination historyqr:writeCreate, edit, pause, and archive codes. Includes qr:readanalytics:readRead aggregated scan analytics
A key acts with editor authority at most. It can never manage members, billing, custom domains, other API keys, or webhook endpoints — those stay with a signed-in admin.
Rate limits
Requests are limited per client address over a rolling 15-minute window. Every response carries the standard RateLimit-* headers — back off when RateLimit-Remaining hits zero and retry after the reset. Over the limit, the API answers 429 with the code rate_limited.
RateLimit-Limit: 300 RateLimit-Remaining: 298 RateLimit-Reset: 812
Create a QR code
Create a dynamic (editable, tracked) or static code. Destinations are validated against a safe-protocol allowlist.
/api/v1/organizations/{orgId}/qr-codescurl -X POST https://api.qroute.in/api/v1/organizations/{orgId}/qr-codes \
-H "x-api-key: qrk_..." \
-H "Content-Type: application/json" \
-d '{
"name": "Lesson 1 intro",
"type": "url",
"mode": "dynamic",
"destination": "https://learn.brand.com/lesson-1",
"projectId": "clx1a2b3c4d5e6f7g8h9i0j1"
}'Response
{
"id": "clx1a2b3c4d5e6f7g8h9i0j1",
"slug": "V1StGXR8Z5",
"name": "Lesson 1 intro",
"type": "url",
"mode": "dynamic",
"status": "active",
"destination": "https://learn.brand.com/lesson-1",
"encodedUrl": "https://scan.brand.com/q/V1StGXR8Z5",
"createdAt": "2026-09-01T10:00:00.000Z"
}encodedUrl is what the QR image contains — it never changes when you edit the destination.
Retrieve a code
/api/v1/organizations/{orgId}/qr-codes/{id}curl https://api.qroute.in/api/v1/organizations/{orgId}/qr-codes/{id} \
-H "x-api-key: qrk_..."Edit destination
Update where a dynamic code points. The encoded QR image stays identical — printed codes keep working.
/api/v1/organizations/{orgId}/qr-codes/{id}/destinationcurl -X PATCH https://api.qroute.in/api/v1/organizations/{orgId}/qr-codes/{id}/destination \
-H "x-api-key: qrk_..." \
-H "Content-Type: application/json" \
-d '{ "destination": "https://learn.brand.com/lesson-1-v2" }'List codes
Filter by project, status, or domain. Results are paginated with a cursor.
/api/v1/organizations/{orgId}/qr-codes?projectId={id}&status=active&limit=20{
"data": [
{ "id": "clx1a2b3c4d5e6f7g8h9i0j1", "name": "Lesson 1 intro",
"status": "active", "mode": "dynamic",
"encodedUrl": "https://scan.brand.com/q/V1StGXR8Z5" }
],
"hasMore": true,
"nextCursor": "clx1a2b3c4d5e6f7g8h9i0j1"
}Pause & archive
Pause a code (scanners see a safe unavailable page), resume it, or archive it while preserving scan history — one endpoint taking { "status": "active" | "paused" | "archived" }.
/api/v1/organizations/{orgId}/qr-codes/{id}/statusAnalytics
Privacy-safe aggregates for the whole organization, or for one code with qrCodeId. Scans are recorded off the redirect path, so nothing here ever slows a scan down. Needs the analytics:read scope.
/api/v1/organizations/{orgId}/analytics?qrCodeId={id}{
"total": 4820,
"byDevice": [ { "device": "mobile", "scans": 4102 } ],
"byOs": [ { "os": "Android", "scans": 2610 } ],
"byCity": [ { "city": "Pune", "region": "Maharashtra",
"country": "IN", "scans": 1180 } ],
"byCountry": [ { "country": "IN", "scans": 2940 } ],
"unknownLocation": 312
}unknownLocation is how many scans we could not place — reported rather than hidden, so a breakdown is never mistaken for the whole.
Webhook events
Register an endpoint under Developers → API & Integrations and subscribe it to the events you care about — or to none, which subscribes it to all of them, including types added later. Failed deliveries are retried at 1m, 5m, 30m, 2h and 6h.
scan.created— A code was scannedqr.created— A code was createdqr.updated— A code was renamed, retyped, or repointedqr.status_changed— A code was paused, resumed, or archiveddomain.verified— A custom domain passed its DNS challenge
Example payload
{
"id": "evt_4f1c8b02d7a9412e8c5b6d3a0f7e2914",
"type": "scan.created",
"createdAt": "2026-09-01T10:05:00.412Z",
"organizationId": "clx0a1b2c3d4e5f6g7h8i9j0",
"data": {
"scan": {
"id": "clx9z8y7x6w5v4u3t2s1r0q9",
"qrCodeId": "clx1a2b3c4d5e6f7g8h9i0j1",
"hostname": "links.example.com",
"device": "mobile",
"os": "iOS",
"browser": "Safari",
"referrer": null,
"city": "Pune", "region": "Maharashtra", "country": "IN",
"scannedAt": "2026-09-01T10:05:00.310Z"
}
}
}A scan payload carries the coarse place we resolved and then discarded the IP for — never the address itself. Retries mean the same id can arrive twice, so key your idempotency on it.
Verify signatures
Every delivery is signed. Reject requests older than 5 minutes and compare a constant-time HMAC to prevent replay and forgery.
Qroute-Signature: t=1772359500,v1=5257a869e7b0... Qroute-Event-Id: evt_4f1c8b02d7a9412e8c5b6d3a0f7e2914 Qroute-Event-Type: scan.created Qroute-Delivery-Attempt: 1
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, header, secret) {
const [t, v1] = header.split(",").map((p) => p.split("=")[1]);
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // replay guard
// Must be the RAW body — re-serialising the parsed JSON reorders
// keys and breaks the digest.
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(v1 ?? "");
// Length is checked first: timingSafeEqual throws on a mismatch.
return a.length === b.length && timingSafeEqual(a, b);
}Zapier & Make
No code required — point a Catch Hook at a webhook endpoint. Payloads are flat JSON with stable field names, so you can map fields directly into any downstream action.
- 1. In Zapier/Make, create a “Catch Hook” trigger and copy its URL.
- 2. Add it as a webhook endpoint in API & Integrations and select events.
- 3. Trigger a test event and map
data.*fields to your action.
Errors
The API uses conventional HTTP status codes and returns a machine-readable error body.
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | Missing or malformed parameters |
| 401 | unauthorized | Missing or invalid API key |
| 403 | forbidden | Key lacks the required scope |
| 404 | not_found | Resource does not exist in this org |
| 409 | conflict | Duplicate or conflicting state |
| 422 | unsafe_destination | Destination blocked by the allowlist |
| 429 | rate_limited | Too many requests — back off |
{
"error": {
"code": "unsafe_destination",
"message": "Destination uses a blocked protocol (javascript:)."
}
}