Help · Developers

QR Walrus API reference

Create and change dynamic QR codes, read their scans, and pull form leads from your pages, all from your own server. This page covers keys, limits, every error and every endpoint, with examples you can paste.

Base URL
https://www.qrwalrus.com/api/v1
Auth
Authorization: Bearer qrw_…
Requests a month
Pro: 3,000 · Business: 10,000 · Enterprise: unlimited
Per key
60 requests a minute, 20 of them changes

Overview

  • Every path below starts with the base URL https://www.qrwalrus.com/api/v1. Use it exactly, with www and HTTPS.
  • Requests and answers are JSON. Send bodies with Content-Type: application/json; they can be up to 64 KB.
  • Answers put their result in data; the lists of codes and of leads add pagination. The one exception is GET /me, which answers with its fields at the top level. Times are ISO 8601, in UTC.
  • A key belongs to one workspace and acts as its owner: it sees and changes that workspace's codes and pages, nothing else.
  • This is version 1. We may add fields to answers at any time, so ignore fields you don't know. Anything that would break an integration (removing or renaming a field, changing what it means) comes as /api/v2 instead.

Authentication

  1. In the dashboard, open the sidebar → Integrations (or go there directly), find API and click New API key.
  2. Name it after what will use it (“My CRM”) and choose Read only or Read & write.
  3. Copy the key. It is shown once: we store only a hash of it, so nobody, us included, can show it again. Lost it? Revoke it and make a new one.

A key is qrw_ followed by 32 letters and digits. Send it with every request:

Authorization: Bearer qrw_…

Read only

Every GET: your key, codes, analytics, pages and leads.

Read & write

Also POST, PATCH and DELETE. A change sent with a read-only key gets 403 read_only_key.

Keep the key on your server

The API sends no CORS headers, so a browser won't let a web page on another site call it. That is on purpose: a key inside a web page or an app can be read by anyone who opens it. Keep keys in an environment variable or a secrets manager, never in code or a repository.

To revoke a key, open Integrations → API and click Revoke next to it. It stops working at once; requests that still send it get 401 invalid_api_key. A workspace can have up to 10 working keys.

Plans, quota and rate limits

The API is part of Pro and up. Each plan has a monthly request quota for the whole workspace, shared by all its keys:

PlanAPI requests a month
FreeNo API access
StarterNo API access
Pro3,000
Business10,000
EnterpriseUnlimited
  • What counts. Every request that passes the key, rate-limit, access and plan checks counts, whatever its answer: a 400 or a 404 counts too, and so does GET /me. Requests turned away by those checks don't.
  • Monthly quota. It resets on the 1st of each month (UTC). Once it is used up, requests get 429 monthly_quota_exceeded until then.
  • Per key. 60 requests a minute, of which up to 20 may be changes (POST, PATCH, DELETE). Windows are fixed: the count starts again at the start of each minute. Over the limit, the answer is 429 rate_limited with Retry-After.
  • Invalid keys. After 20 invalid keys from one network (an IPv4 address, or an IPv6 /64) within 10 minutes, further invalid keys from it get 429 too_many_bad_keys with Retry-After instead of 401. Valid keys from that network keep working.

Headers on every answer

  • X-Request-IdEvery answer

    This request's id (req_…). Log it, and quote it if you contact us.

  • X-RateLimit-LimitOnce the key is recognised

    Requests a minute this key may make: 60.

  • X-RateLimit-RemainingOnce the key is recognised

    Requests left in the current one-minute window.

  • X-RateLimit-ResetOnce the key is recognised

    Seconds until that window ends and the count starts again.

  • X-Quota-LimitCounted requests, and monthly_quota_exceeded

    This month's request quota. Left out on plans with no monthly cap.

  • X-Quota-RemainingCounted requests, and monthly_quota_exceeded

    Requests left this month, after this one. Left out with X-Quota-Limit.

  • Retry-Afterrate_limited, too_many_bad_keys

    Seconds to wait before sending the request again.

“Once the key is recognised” means every answer except 401s, 429 too_many_bad_keys and 500s; those carry X-Request-Id (and Retry-After on the 429).

A rate-limited answer
429 Too Many Requests
Retry-After: 18
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 18
X-Request-Id: req_9f2c4e1a7b3d5f60
Content-Type: application/json

{"error":{"code":"rate_limited","message":"Slow down: at most 60 requests a minute per key."}}

How to back off

  1. On a 429 with Retry-After, wait that many seconds and send the same request again. It was turned away before anything happened, so resending is safe, even a POST.
  2. For a burst of requests, watch X-RateLimit-Remaining; when it reaches 0, wait X-RateLimit-Reset seconds.
  3. A 429 without Retry-After (monthly_quota_exceeded) won't pass on a retry, and neither will a 403 (limit_exceeded, feature_not_available, read_only_key). Wait for the 1st, upgrade, or free up codes.
  4. On a 500, retry a GET after a short pause, doubling the pause each time. Before resending a POST, list your codes to check it wasn't created.

Errors

Every error has the same shape. Branch on code; message is written for people and its wording may change. details appears only on some errors.

{ "error": { "code", "message", "details"? } }
{
  "error": {
    "code": "invalid_api_key",
    "message": "That API key isn't valid. It may have been revoked."
  }
}
  • missing_api_key401

    No Authorization: Bearer header.

  • invalid_api_key401

    The key is unknown, malformed or revoked.

  • too_many_bad_keys429

    Over 20 invalid keys from your network within 10 minutes. Has Retry-After.

  • rate_limited429

    Over 60 requests, or 20 changes, in a minute for this key. Has Retry-After.

  • read_only_key403

    A POST, PATCH or DELETE sent with a read-only key.

  • plan_required403

    The workspace's plan doesn't include the API (Pro and up do).

  • monthly_quota_exceeded429

    This month's requests are used up. The quota resets on the 1st (UTC); there is no Retry-After.

  • invalid_json400

    A POST or PATCH with an empty body, or a body that isn't valid JSON.

  • body_too_large413

    The body is over 64 KB.

  • invalid_request400

    A field or query parameter is wrong; message says which. When the body doesn't match the schema, message is "Validation failed" and details lists the fields. Smart-rule and access problems put a list of issues in details; a number parameter out of range puts its name in details.parameter.

  • not_found404

    No code or page with that id in your workspace.

  • feature_not_available403

    The code uses something the plan doesn't include. details.feature names it, and details.requiredPlan the plan that has it, when there is one.

  • limit_exceeded403

    A new code would pass the plan's dynamic-code limit. details holds feature, limit and currentValue. Retrying won't help.

  • conflict409

    Rare: the new code's id was taken at the same instant. Nothing was created; send the request again.

  • internal_error500

    Something failed on our side. message quotes the request id.

Creating and updating codes runs the same checks as the dashboard, and their errors are passed through: their codes are lower-cased (FEATURE_NOT_AVAILABLE becomes feature_not_available), and an error without a code gets one from its status (400 → invalid_request, 404 → not_found, 409 → conflict).

400: a body that doesn't match the schema
{
  "error": {
    "code": "invalid_request",
    "message": "Validation failed",
    "details": {
      "formErrors": [],
      "fieldErrors": {
        "contentType": [
          "Required"
        ]
      }
    }
  }
}
403: the plan's dynamic-code limit
{
  "error": {
    "code": "limit_exceeded",
    "message": "Limit exceeded",
    "details": {
      "feature": "qrCodesIncluded",
      "limit": 200,
      "currentValue": 200
    }
  }
}

Endpoints

The examples read your key from the QRWALRUS_API_KEY environment variable. “Any key” means a read-only key is enough.

Check your key

GET/meAny key

The quickest way to check a key works. It answers with the workspace and its plan code, the key's id and access, this month's usage (the request itself included) and the per-key limits. usage.month.limit is null on a plan with no monthly cap. This answer has no data wrapper.

Request
curl https://www.qrwalrus.com/api/v1/me \
  -H "Authorization: Bearer $QRWALRUS_API_KEY"
200 OK
{
  "workspace": {
    "id": "ws_3f9a1c0e5b7d2a4c6e8f",
    "plan": "pro"
  },
  "key": {
    "id": "key_Xb2kQ9mZ4tLp",
    "access": "write"
  },
  "usage": {
    "month": {
      "used": 42,
      "limit": 3000
    }
  },
  "limits": {
    "requestsPerMinute": 60,
    "writesPerMinute": 20
  }
}

The code object

Every endpoint under /qrs answers with codes in this shape (the full example is under Create a code).

Fields

  • idstring

    12 letters and digits. Use it in /qrs/{id}.
  • scanUrlstring

    The address the printed code opens. Encode this in your QR image. It never changes, even when you change where the code goes.
  • namestring

    Your label for the code.
  • contentTypestring

    What kind of content the code serves (see Create a code).
  • contentstring

    Where the code goes: its destination, in its type's format.
  • payloadstring

    The text the QR image encodes. For codes made through the API it is the same as scanUrl.
  • statusstring

    draft, active, disabled, archived. Only active codes open their content when scanned.
  • tagsstring[]

    Lower-case labels.
  • folderIdstring | null

    The folder it's in, or null.
  • logoAssetIdstring | null

    The centre logo from your library, or null.
  • routingobject | null

    Smart rules, or null when there are none.
  • accessobject | null

    End date, scan cap and geofence, or null when there are none.
  • passwordProtectedboolean

    Scanners must type an access code first. The access code itself is never returned.
  • template, landingThemestring

    The QR design template and the theme of the page scanners see.
  • style, metadataobject

    The full design, and type-specific extras. Only present when set.
  • analyticsEnabledboolean

    Whether scans are recorded for analytics.
  • scanCount, uniqueScanCountnumber

    Scans and unique scans since the code was made.
  • createdAt, updatedAtstring

    ISO 8601, UTC.

List codes

GET/qrsAny key

Codes in your workspace, newest first. To page through them, add limit to offset while pagination.hasMore is true.

Query parameters (all optional)

  • limitinteger

    1–100. Default 25.
  • offsetinteger

    How many codes to skip: 0–1,000,000. Default 0.
  • statusstring

    draft, active, disabled, archived. Without it you get every status except archived; archived codes only come with status=archived.
  • tagstring

    Only codes with this tag.
  • folderIdstring

    Only codes in this folder; none for codes in no folder.
  • searchstring

    Part of the name, any case (the first 100 characters are used).
Request
curl "https://www.qrwalrus.com/api/v1/qrs?limit=1&tag=flyer" \
  -H "Authorization: Bearer $QRWALRUS_API_KEY"
200 OK
{
  "data": [
    {
      "id": "aB3dE5fG7hJ9",
      "name": "Spring flyer",
      "contentType": "url",
      "content": "https://example.com/spring",
      "payload": "https://www.qrwalrus.com/s/aB3dE5fG7hJ9",
      "template": "classic",
      "landingTheme": "default",
      "analyticsEnabled": true,
      "status": "active",
      "passwordProtected": false,
      "scanCount": 128,
      "uniqueScanCount": 97,
      "createdAt": "2026-10-01T09:30:00.000Z",
      "updatedAt": "2026-10-03T12:04:51.000Z",
      "routing": null,
      "access": {
        "v": 1,
        "expiresAt": "2026-12-31T23:59:59+05:30"
      },
      "tags": [
        "flyer"
      ],
      "folderId": null,
      "logoAssetId": null,
      "scanUrl": "https://www.qrwalrus.com/s/aB3dE5fG7hJ9"
    }
  ],
  "pagination": {
    "limit": 1,
    "offset": 0,
    "total": 7,
    "hasMore": true
  }
}

An unknown status, a malformed tag, or a limit or offset that isn't a whole number in range gets 400 invalid_request (for the numbers, details.parameter names the one at fault). Bad values are never swapped for the default.

Create a code

POST/qrsRead & write key

Makes a dynamic code and answers 201 with it. Encode its scanUrl in your QR image: that is the address the printed code opens, and it stays the same when you later change where the code goes.

Request
curl -X POST https://www.qrwalrus.com/api/v1/qrs \
  -H "Authorization: Bearer $QRWALRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Spring flyer",
    "contentType": "url",
    "content": "https://example.com/spring",
    "tags": ["flyer"]
  }'
201 Created
{
  "data": {
    "id": "aB3dE5fG7hJ9",
    "name": "Spring flyer",
    "contentType": "url",
    "content": "https://example.com/spring",
    "payload": "https://www.qrwalrus.com/s/aB3dE5fG7hJ9",
    "template": "classic",
    "landingTheme": "default",
    "analyticsEnabled": true,
    "status": "active",
    "passwordProtected": false,
    "scanCount": 0,
    "uniqueScanCount": 0,
    "createdAt": "2026-10-01T09:30:00.000Z",
    "updatedAt": "2026-10-01T09:30:00.000Z",
    "routing": null,
    "access": null,
    "tags": [
      "flyer"
    ],
    "folderId": null,
    "logoAssetId": null,
    "scanUrl": "https://www.qrwalrus.com/s/aB3dE5fG7hJ9"
  }
}

Body

  • contentTypestringrequired

    One of: url, vcard, wifi, text, email, sms, phone, location, event, whatsapp, project_overview, smart_menu, pdf, images, video, social_links, lost_found, page.
  • contentstringrequired

    The destination, in the type's format (below). Up to 50,000 characters.
  • namestring

    Up to 512 characters. Default "".
  • messagestring

    Up to 1,000 characters: the SMS text or WhatsApp message. Stored as metadata.message.
  • tagsstring[]

    Up to 20 tags of 1–32 characters: letters, digits, - and _, starting with a letter or digit. They are lower-cased and duplicates dropped.
  • folderIdstring | null

    A folder in your workspace. Otherwise: 400 "Folder not found".
  • logoAssetIdstring | null

    A logo from your library. Otherwise: 400 "Logo not found in your library".
  • statusstring

    draft, active, disabled, archived. Default active.
  • analyticsEnabledboolean

    Record scans for analytics. Default true.
  • passwordstring | null

    An access code of 4–64 characters that scanners must type. Only a hash is stored; it is never returned.
  • routingobject | null

    Smart rules (below).
  • accessobject | null

    End date, scan cap and geofence (below).
  • templatestring

    classic, rounded, dots, minimal, branded. Default classic.
  • landingThemestring

    default, midnight, minimal, executive, sunset, forest, noir, aurora, blush, graphite. Default default.
  • styleobject

    Colours, shapes, logo and the full design, exactly as the dashboard stores them; unknown keys are refused. The easiest way to get one is from a code designed in the dashboard.
  • metadataobject

    Type-specific extras, as the dashboard stores them.

Leave out id: every new code gets its own, and a body with one gets 400 invalid_request. Other fields not listed here are ignored.

Content for each type

  • url

    A web link starting with http:// or https:// (https:// is added when there is no scheme). It can't be the code's own scan address.

  • text

    Any text.

  • phonesmswhatsapp

    A phone number with its country code, like +919876543210. For sms and whatsapp, message sets the text.

  • email

    An address, or a full mailto: link with ?subject= and &body=.

  • vcard

    A vCard: text that contains BEGIN:VCARD.

  • wifi

    A Wi-Fi string with the network name, like WIFI:T:WPA;S:CafeGuest;P:coffee123;;.

  • page

    The id of one of your pages (pg_…), from List pages. Scans open the page.

  • locationeventproject_overviewsmart_menupdfimagesvideosocial_linkslost_found

    Structured content in the format the dashboard saves. Make one in the dashboard, then read it with Get a code to see exactly what to send.

Content the scan couldn't serve gets 400 invalid_request, with a message saying what's wrong.

Smart rules: routing

Up to 20 rules, checked top to bottom on every scan; the first enabled rule whose conditions match decides where the scanner goes, and when none matches the code serves its own content. Each rule has an id (unique within the code), an optional label, enabled (default true), match (“all”, the default, or “any”), 1–5 conditions and a destination. Times and dates are read in timezone, an IANA name.

routing
{
  "v": 1,
  "timezone": "Asia/Kolkata",
  "rules": [
    {
      "id": "weekend",
      "label": "Weekend offer",
      "conditions": [
        {
          "type": "day",
          "in": [
            0,
            6
          ]
        }
      ],
      "destination": {
        "type": "url",
        "url": "https://example.com/weekend"
      }
    }
  ]
}
  • devicein: "mobile", "tablet", "desktop"
  • osin: "ios", "android", "windows", "macos", "linux", "other"
  • languagein: language codes, like “hi”, “en”
  • countryin: country codes, like “IN”, “US”
  • timefrom, to: “HH:mm” (24-hour; to is exclusive; to before from runs overnight)
  • dayin: weekdays 0–6, Sunday = 0
  • datefrom and/or to: “YYYY-MM-DD”, inclusive
  • scansmetric: “total” or “unique”; op: “lt” or “gte”; value: a number
  • nearlat, lng, radiusMeters (10–100,000); the scanner is asked for their location

Any condition takes "not": true to turn it around. A destination is { "type": "url", "url": "https://…" }, or { "type": "content", "contentType", "content", "metadata"? } to serve other content, where contentType is one of url, vcard, wifi, text, email, sms, phone, location, event, whatsapp, page. A routing object with no rules is stored as null.

End date, scan cap and geofence: access

v is 1. expiresAt is an ISO 8601 time with its offset; maxScans a lifetime cap (1 or more); geofence { "lat", "lng", "radiusMeters" } (10–100,000 metres) serves scans only inside the circle. Each part is optional; an access object with none of them is stored as null.

access
{
  "v": 1,
  "expiresAt": "2026-12-31T23:59:59+05:30",
  "maxScans": 500
}

Plan limits

  • Each new code counts toward your plan's dynamic-code limit (Pro 200, Business 750, Enterprise unlimited, plus any add-on packs). Archived codes don't count. At the limit the answer is 403 limit_exceeded.
  • A code that uses something the plan doesn't include (smart rules, location rules and geofences, an end date, a scan cap, an access code, analytics, or the vCard, file and item-tag types) gets 403 feature_not_available.

Get a code

GET/qrs/{id}Any key

One code, by its id. A code that isn't in your workspace gets 404 not_found.

Request
curl https://www.qrwalrus.com/api/v1/qrs/aB3dE5fG7hJ9 \
  -H "Authorization: Bearer $QRWALRUS_API_KEY"
200 OK
{
  "data": {
    "id": "aB3dE5fG7hJ9",
    "name": "Spring flyer",
    "contentType": "url",
    "content": "https://example.com/spring",
    "payload": "https://www.qrwalrus.com/s/aB3dE5fG7hJ9",
    "template": "classic",
    "landingTheme": "default",
    "analyticsEnabled": true,
    "status": "active",
    "passwordProtected": false,
    "scanCount": 128,
    "uniqueScanCount": 97,
    "createdAt": "2026-10-01T09:30:00.000Z",
    "updatedAt": "2026-10-03T12:04:51.000Z",
    "routing": null,
    "access": {
      "v": 1,
      "expiresAt": "2026-12-31T23:59:59+05:30"
    },
    "tags": [
      "flyer"
    ],
    "folderId": null,
    "logoAssetId": null,
    "scanUrl": "https://www.qrwalrus.com/s/aB3dE5fG7hJ9"
  }
}

Update a code

PATCH/qrs/{id}Read & write key

Send only the fields to change; the body takes the same fields as Create a code, all optional, and the answer is the updated code.

  • A new destination (content, and contentType if the type changes) is checked like on create and works from the next scan. The scanUrl, and every printed copy, stay the same.
  • null removes routing, access, folderId, logoAssetId and password; other fields don't take null. Fields you leave out keep what is stored.
  • tags replaces the whole list. metadata is merged into the stored one, key by key. style replaces the stored style; {} clears it.
  • The plan's features are checked against the code as it will be, so adding smart rules on a plan without them gets 403 feature_not_available.
Request: a new destination, and the end date removed
curl -X PATCH https://www.qrwalrus.com/api/v1/qrs/aB3dE5fG7hJ9 \
  -H "Authorization: Bearer $QRWALRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "content": "https://example.com/summer", "access": null }'
200 OK
{
  "data": {
    "id": "aB3dE5fG7hJ9",
    "name": "Spring flyer",
    "contentType": "url",
    "content": "https://example.com/summer",
    "payload": "https://www.qrwalrus.com/s/aB3dE5fG7hJ9",
    "template": "classic",
    "landingTheme": "default",
    "analyticsEnabled": true,
    "status": "active",
    "passwordProtected": false,
    "scanCount": 128,
    "uniqueScanCount": 97,
    "createdAt": "2026-10-01T09:30:00.000Z",
    "updatedAt": "2026-10-25T09:45:12.000Z",
    "routing": null,
    "access": null,
    "tags": [
      "flyer"
    ],
    "folderId": null,
    "logoAssetId": null,
    "scanUrl": "https://www.qrwalrus.com/s/aB3dE5fG7hJ9"
  }
}

Delete a code

DELETE/qrs/{id}Read & write key

Deletes the code and its scan history. This can't be undone, and printed copies stop working. To pause a code instead, PATCH its status to disabled.

Request
curl -X DELETE https://www.qrwalrus.com/api/v1/qrs/aB3dE5fG7hJ9 \
  -H "Authorization: Bearer $QRWALRUS_API_KEY"
200 OK
{
  "data": {
    "id": "aB3dE5fG7hJ9",
    "deleted": true
  }
}

Code analytics

GET/qrs/{id}/analyticsAny key

Query parameters

  • daysinteger

    How far back to look: 1–3,650, default 30; anything else gets 400 invalid_request. Never further than your plan's analytics history or the day the code was made.
Request
curl "https://www.qrwalrus.com/api/v1/qrs/aB3dE5fG7hJ9/analytics?days=7" \
  -H "Authorization: Bearer $QRWALRUS_API_KEY"
200 OK
{
  "data": {
    "qr": {
      "id": "aB3dE5fG7hJ9",
      "name": "Spring flyer"
    },
    "period": {
      "days": 7,
      "since": "2026-10-18T00:00:00.000Z"
    },
    "totals": {
      "scans": 128,
      "uniqueScans": 97,
      "lastScannedAt": "2026-10-25T08:12:44.000Z"
    },
    "byDay": [
      {
        "date": "2026-10-24",
        "scans": 31
      },
      {
        "date": "2026-10-25",
        "scans": 12
      }
    ],
    "byDevice": [
      {
        "device": "mobile",
        "scans": 40
      },
      {
        "device": "desktop",
        "scans": 3
      }
    ],
    "byCountry": [
      {
        "country": "IN",
        "scans": 41
      }
    ],
    "byCity": [
      {
        "city": "Pune",
        "countryCode": "IN",
        "scans": 22
      }
    ],
    "byReferrer": [],
    "byUtmSource": [
      {
        "source": "flyer",
        "scans": 9
      }
    ],
    "byRule": [
      {
        "ruleId": null,
        "scans": 43
      }
    ]
  }
}
  • period.days is the days you asked for, after the caps; period.since is where the period starts, at midnight UTC, and never before the day the code was made.
  • totals.scans and totals.uniqueScans are lifetime counts; totals.lastScannedAt and every breakdown cover the period only.
  • byDay lists only days with scans (UTC dates). byCountry and byCity hold the top 20, byReferrer and byUtmSource the top 10.
  • byRule counts scans per smart rule; "ruleId": null is the code's own content.

List pages

GET/pagesAny key

Every page in your workspace, newest first, with no paging. status is draft, published or suspended; url is the page's qrwalrus.page address; publishedAt is null until the first publish. Use a page's id for its leads, or as the content of a code of type page.

Request
curl https://www.qrwalrus.com/api/v1/pages \
  -H "Authorization: Bearer $QRWALRUS_API_KEY"
200 OK
{
  "data": [
    {
      "id": "pg_4c1d9e7a2b3f5e",
      "name": "Cafe menu",
      "status": "published",
      "url": "https://pizzacafe.qrwalrus.page",
      "publishedAt": "2026-10-02T11:00:00.000Z",
      "createdAt": "2026-10-01T10:00:00.000Z",
      "updatedAt": "2026-10-20T15:42:10.000Z"
    }
  ]
}

A page's leads

GET/pages/{id}/leadsAny key

What visitors sent through the page's forms, newest first. Reading leads doesn't mark them as read in the dashboard. A page that isn't in your workspace gets 404 not_found.

Query parameters (all optional)

  • limitinteger

    1–100. Default 25.
  • beforeinteger

    For the next page of results: the pagination.nextBefore of the previous answer. A lead's full id (lead_4812) works too.
Request
curl "https://www.qrwalrus.com/api/v1/pages/pg_4c1d9e7a2b3f5e/leads?limit=1" \
  -H "Authorization: Bearer $QRWALRUS_API_KEY"
200 OK
{
  "data": [
    {
      "id": "lead_4812",
      "createdAt": "2026-10-25T07:58:31.000Z",
      "path": "/",
      "blockId": "bk3x9q2a",
      "fields": {
        "Name": "Asha Rao",
        "Email": "asha@example.com",
        "Message": "Table for six on Friday?"
      },
      "answers": [
        {
          "fieldId": "f8m2kq1z",
          "label": "Name",
          "type": "text",
          "value": "Asha Rao"
        },
        {
          "fieldId": "f2p7wd4c",
          "label": "Email",
          "type": "email",
          "value": "asha@example.com"
        },
        {
          "fieldId": "f5t1hx9e",
          "label": "Message",
          "type": "message",
          "value": "Table for six on Friday?"
        }
      ],
      "source": {
        "qrId": "aB3dE5fG7hJ9"
      },
      "country": "IN",
      "city": "Pune"
    }
  ],
  "pagination": {
    "limit": 1,
    "hasMore": true,
    "nextBefore": 4812
  }
}
The next page
curl "https://www.qrwalrus.com/api/v1/pages/pg_4c1d9e7a2b3f5e/leads?limit=1&before=4812" \
  -H "Authorization: Bearer $QRWALRUS_API_KEY"
  • Keep passing before the pagination.nextBefore of the last answer. When hasMore is false, nextBefore is null and you have them all. A limit or before that isn't valid gets 400 invalid_request.
  • fields maps each question's label to the answer, for quick use; answers keeps every field with its id and type, so use it when two questions share a label.
  • source.qrId is the code the visitor came through, or null. country and city are null when unknown.

Full examples

Each script checks the key with GET /me, creates a URL code and prints its scanUrl. On a 429 with Retry-After it waits and tries again, up to four times in all; any other error stops it with the code, the message and the request id.

JavaScript (Node 18+, fetch)
// Node 18+ (fetch is built in). Save as create-code.mjs and run:
//   QRWALRUS_API_KEY=qrw_... node create-code.mjs
const BASE = "https://www.qrwalrus.com/api/v1";
const KEY = process.env.QRWALRUS_API_KEY;

async function call(method, path, body) {
  for (let attempt = 1; ; attempt++) {
    const res = await fetch(BASE + path, {
      method,
      headers: {
        Authorization: "Bearer " + KEY,
        ...(body ? { "Content-Type": "application/json" } : {}),
      },
      body: body ? JSON.stringify(body) : undefined,
    });

    // A 429 with Retry-After was turned away before anything happened: wait, then send it again.
    const retryAfter = res.headers.get("Retry-After");
    if (res.status === 429 && retryAfter && attempt < 4) {
      await new Promise((resolve) => setTimeout(resolve, Number(retryAfter) * 1000));
      continue;
    }

    const json = await res.json().catch(() => null);
    if (!res.ok) {
      const error = json?.error ?? { code: res.status, message: res.statusText };
      throw new Error(error.code + ": " + error.message + " (request " + res.headers.get("X-Request-Id") + ")");
    }
    return json;
  }
}

const me = await call("GET", "/me");
console.log("Key works. Plan:", me.workspace.plan, "| access:", me.key.access);

const { data: code } = await call("POST", "/qrs", {
  name: "Spring flyer",
  contentType: "url",
  content: "https://example.com/spring",
});
console.log("Encode this in your QR image:", code.scanUrl);
Python (requests)
# Python 3 with requests (pip install requests). Run:
#   QRWALRUS_API_KEY=qrw_... python create_code.py
import os
import time

import requests

BASE = "https://www.qrwalrus.com/api/v1"
session = requests.Session()
session.headers["Authorization"] = "Bearer " + os.environ["QRWALRUS_API_KEY"]


def call(method, path, body=None):
    for attempt in range(1, 5):
        res = session.request(method, BASE + path, json=body, timeout=30)

        # A 429 with Retry-After was turned away before anything happened: wait, then send it again.
        retry_after = res.headers.get("Retry-After")
        if res.status_code == 429 and retry_after and attempt < 4:
            time.sleep(int(retry_after))
            continue

        if not res.ok:
            try:
                error = res.json()["error"]
            except (ValueError, KeyError):
                error = {"code": res.status_code, "message": res.reason}
            raise RuntimeError(f"{error['code']}: {error['message']} (request {res.headers.get('X-Request-Id')})")
        return res.json()


me = call("GET", "/me")
print("Key works. Plan:", me["workspace"]["plan"], "| access:", me["key"]["access"])

code = call("POST", "/qrs", {
    "name": "Spring flyer",
    "contentType": "url",
    "content": "https://example.com/spring",
})["data"]
print("Encode this in your QR image:", code["scanUrl"])

Webhooks

To hear about new leads, scans and guest photos as they happen, use webhooks instead of polling: add an endpoint under Integrations and we POST each event (lead.created, scan.created, guest_photo.submitted) to your server. Everything about them is in the webhooks guide.

Good practice

  • Use a read-only key wherever you only read: reports, dashboards, syncing leads to a CRM.
  • Make one key per integration and name it after it, so you can revoke one without breaking the others. You can have up to 10.
  • Keep keys on your server, in an environment variable or a secrets manager.
  • Back off on 429: wait Retry-After seconds, and don't retry monthly_quota_exceeded or limit_exceeded.
  • Log X-Request-Id with every failed request. It lets us find the request if you contact us.
  • Store each code's id when you create it, and change its destination with PATCH rather than making a new code: the printed code keeps working.

Questions about the API

Which plans include the API?

Pro and up. Monthly requests: Pro: 3,000 · Business: 10,000 · Enterprise: unlimited. Free and Starter don't include it: requests from a workspace on those plans get 403 plan_required.

Do failed requests count toward the monthly quota?

Requests turned away before the count (a missing or invalid key, the per-minute limit, a read-only key on a change, a plan without the API) don't count. Every request past those checks counts, whatever its answer, including a 400 or a 404.

Can I call the API from JavaScript in a browser?

No. The API sends no CORS headers, so browsers block calls from web pages on other sites, and a key in a web page or an app can be read by anyone who opens it. Call the API from your server and keep the key there.

If I change where a code goes, do I need to reprint it?

No. A printed code opens its scanUrl, which never changes. Send a PATCH with the new content and the next scan goes to the new destination.

How do I get the QR code image?

The API returns the code's scanUrl, the address the image has to encode. Encode it with any QR library, or download the designed image from the dashboard. There is no image endpoint.

What happens when I revoke a key?

It stops working at once. Requests that still send it get 401 invalid_api_key. Other keys keep working.

Still stuck? Contact us with the request's X-Request-Id and the time you sent it, and we'll look it up.