Developers

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

base url
https://api.qroute.in/v1
RESTJSONOrg-scoped keysSigned webhooks

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
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 history
  • qr:writeCreate, edit, pause, and archive codes. Includes qr:read
  • analytics: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.

response headers
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.

POST/api/v1/organizations/{orgId}/qr-codes
curl
curl -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

200 · json
{
  "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

GET/api/v1/organizations/{orgId}/qr-codes/{id}
curl
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.

PATCH/api/v1/organizations/{orgId}/qr-codes/{id}/destination
curl
curl -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.

GET/api/v1/organizations/{orgId}/qr-codes?projectId={id}&status=active&limit=20
200 · json
{
  "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" }.

PATCH/api/v1/organizations/{orgId}/qr-codes/{id}/status

Analytics

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.

GET/api/v1/organizations/{orgId}/analytics?qrCodeId={id}
200 · json
{
  "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 scanned
  • qr.created— A code was created
  • qr.updated— A code was renamed, retyped, or repointed
  • qr.status_changed— A code was paused, resumed, or archived
  • domain.verified— A custom domain passed its DNS challenge

Example payload

POST to your endpoint
{
  "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.

headers
Qroute-Signature: t=1772359500,v1=5257a869e7b0...
Qroute-Event-Id: evt_4f1c8b02d7a9412e8c5b6d3a0f7e2914
Qroute-Event-Type: scan.created
Qroute-Delivery-Attempt: 1
node.js
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. 1. In Zapier/Make, create a “Catch Hook” trigger and copy its URL.
  2. 2. Add it as a webhook endpoint in API & Integrations and select events.
  3. 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.

StatusCodeMeaning
400invalid_requestMissing or malformed parameters
401unauthorizedMissing or invalid API key
403forbiddenKey lacks the required scope
404not_foundResource does not exist in this org
409conflictDuplicate or conflicting state
422unsafe_destinationDestination blocked by the allowlist
429rate_limitedToo many requests — back off
422 · json
{
  "error": {
    "code": "unsafe_destination",
    "message": "Destination uses a blocked protocol (javascript:)."
  }
}

Ready to build?

Create an API key and try the endpoints above.

Go to API & Integrations →