Overview
- Every path below starts with the base URL
https://www.qrwalrus.com/api/v1. Use it exactly, withwwwand 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 addpagination. 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/v2instead.
Authentication
- In the dashboard, open the sidebar → Integrations (or go there directly), find API and click New API key.
- Name it after what will use it (“My CRM”) and choose Read only or Read & write.
- 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:
| Plan | API requests a month |
|---|---|
| Free | No API access |
| Starter | No API access |
| Pro | 3,000 |
| Business | 10,000 |
| Enterprise | Unlimited |
- 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_exceededuntil 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_limitedwithRetry-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_keyswithRetry-Afterinstead of 401. Valid keys from that network keep working.
Headers on every answer
X-Request-IdEvery answerThis request's id (req_…). Log it, and quote it if you contact us.
X-RateLimit-LimitOnce the key is recognisedRequests a minute this key may make: 60.
X-RateLimit-RemainingOnce the key is recognisedRequests left in the current one-minute window.
X-RateLimit-ResetOnce the key is recognisedSeconds until that window ends and the count starts again.
X-Quota-LimitCounted requests, and monthly_quota_exceededThis month's request quota. Left out on plans with no monthly cap.
X-Quota-RemainingCounted requests, and monthly_quota_exceededRequests left this month, after this one. Left out with X-Quota-Limit.
Retry-Afterrate_limited, too_many_bad_keysSeconds 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).
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
- 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. - For a burst of requests, watch
X-RateLimit-Remaining; when it reaches 0, waitX-RateLimit-Resetseconds. - 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. - 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": "invalid_api_key",
"message": "That API key isn't valid. It may have been revoked."
}
}missing_api_key401No Authorization: Bearer header.
invalid_api_key401The key is unknown, malformed or revoked.
too_many_bad_keys429Over 20 invalid keys from your network within 10 minutes. Has Retry-After.
rate_limited429Over 60 requests, or 20 changes, in a minute for this key. Has Retry-After.
read_only_key403A POST, PATCH or DELETE sent with a read-only key.
plan_required403The workspace's plan doesn't include the API (Pro and up do).
monthly_quota_exceeded429This month's requests are used up. The quota resets on the 1st (UTC); there is no Retry-After.
invalid_json400A POST or PATCH with an empty body, or a body that isn't valid JSON.
body_too_large413The body is over 64 KB.
invalid_request400A 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_found404No code or page with that id in your workspace.
feature_not_available403The 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_exceeded403A new code would pass the plan's dynamic-code limit. details holds feature, limit and currentValue. Retrying won't help.
conflict409Rare: the new code's id was taken at the same instant. Nothing was created; send the request again.
internal_error500Something 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).
{
"error": {
"code": "invalid_request",
"message": "Validation failed",
"details": {
"formErrors": [],
"fieldErrors": {
"contentType": [
"Required"
]
}
}
}
}{
"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.
- GET
/meThe key's workspace, plan, access and this month's usage. - GET
/qrsYour codes, newest first, with filters and paging. - POST
/qrsA new dynamic code, held to your plan's rules. - GET
/qrs/{id}One code. - PATCH
/qrs/{id}Change a code: a new destination, tags, status and more. - DELETE
/qrs/{id}Delete a code and its scan history. - GET
/qrs/{id}/analyticsScans of one code by day, device, place, referrer, UTM source and rule. - GET
/pagesYour pages: id, name, status and address. - GET
/pages/{id}/leadsMessages sent through a page's forms, newest first.
Check your key
/meAny keyThe 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.
curl https://www.qrwalrus.com/api/v1/me \
-H "Authorization: Bearer $QRWALRUS_API_KEY"{
"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
idstring12 letters and digits. Use it in /qrs/{id}.scanUrlstringThe address the printed code opens. Encode this in your QR image. It never changes, even when you change where the code goes.namestringYour label for the code.contentTypestringWhat kind of content the code serves (see Create a code).contentstringWhere the code goes: its destination, in its type's format.payloadstringThe text the QR image encodes. For codes made through the API it is the same as scanUrl.statusstringdraft, active, disabled, archived. Only active codes open their content when scanned.tagsstring[]Lower-case labels.folderIdstring | nullThe folder it's in, or null.logoAssetIdstring | nullThe centre logo from your library, or null.routingobject | nullSmart rules, or null when there are none.accessobject | nullEnd date, scan cap and geofence, or null when there are none.passwordProtectedbooleanScanners must type an access code first. The access code itself is never returned.template, landingThemestringThe QR design template and the theme of the page scanners see.style, metadataobjectThe full design, and type-specific extras. Only present when set.analyticsEnabledbooleanWhether scans are recorded for analytics.scanCount, uniqueScanCountnumberScans and unique scans since the code was made.createdAt, updatedAtstringISO 8601, UTC.
List codes
/qrsAny keyCodes in your workspace, newest first. To page through them, add limit to offset while pagination.hasMore is true.
Query parameters (all optional)
limitinteger1–100. Default 25.offsetintegerHow many codes to skip: 0–1,000,000. Default 0.statusstringdraft, active, disabled, archived. Without it you get every status except archived; archived codes only come with status=archived.tagstringOnly codes with this tag.folderIdstringOnly codes in this folder; none for codes in no folder.searchstringPart of the name, any case (the first 100 characters are used).
curl "https://www.qrwalrus.com/api/v1/qrs?limit=1&tag=flyer" \
-H "Authorization: Bearer $QRWALRUS_API_KEY"{
"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
/qrsRead & write keyMakes 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.
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"]
}'{
"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
contentTypestringrequiredOne of: url, vcard, wifi, text, email, sms, phone, location, event, whatsapp, project_overview, smart_menu, pdf, images, video, social_links, lost_found, page.contentstringrequiredThe destination, in the type's format (below). Up to 50,000 characters.namestringUp to 512 characters. Default "".messagestringUp 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 | nullA folder in your workspace. Otherwise: 400 "Folder not found".logoAssetIdstring | nullA logo from your library. Otherwise: 400 "Logo not found in your library".statusstringdraft, active, disabled, archived. Default active.analyticsEnabledbooleanRecord scans for analytics. Default true.passwordstring | nullAn access code of 4–64 characters that scanners must type. Only a hash is stored; it is never returned.routingobject | nullSmart rules (below).accessobject | nullEnd date, scan cap and geofence (below).templatestringclassic, rounded, dots, minimal, branded. Default classic.landingThemestringdefault, midnight, minimal, executive, sunset, forest, noir, aurora, blush, graphite. Default default.styleobjectColours, 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.metadataobjectType-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
urlA web link starting with
http://orhttps://(https://is added when there is no scheme). It can't be the code's own scan address.textAny text.
phonesmswhatsappA phone number with its country code, like
+919876543210. Forsmsandwhatsapp,messagesets the text.emailAn address, or a full
mailto:link with?subject=and&body=.vcardA vCard: text that contains
BEGIN:VCARD.wifiA Wi-Fi string with the network name, like
WIFI:T:WPA;S:CafeGuest;P:coffee123;;.pageThe id of one of your pages (
pg_…), from List pages. Scans open the page.locationeventproject_overviewsmart_menupdfimagesvideosocial_linkslost_foundStructured 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.
{
"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 = 0datefrom and/or to: “YYYY-MM-DD”, inclusivescansmetric: “total” or “unique”; op: “lt” or “gte”; value: a numbernearlat, 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.
{
"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
/qrs/{id}Any keyOne code, by its id. A code that isn't in your workspace gets 404 not_found.
curl https://www.qrwalrus.com/api/v1/qrs/aB3dE5fG7hJ9 \
-H "Authorization: Bearer $QRWALRUS_API_KEY"{
"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
/qrs/{id}Read & write keySend 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, andcontentTypeif the type changes) is checked like on create and works from the next scan. ThescanUrl, and every printed copy, stay the same. - null removes
routing,access,folderId,logoAssetIdandpassword; other fields don't take null. Fields you leave out keep what is stored. tagsreplaces the whole list.metadatais merged into the stored one, key by key.stylereplaces 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.
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 }'{
"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
/qrs/{id}Read & write keyDeletes 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.
curl -X DELETE https://www.qrwalrus.com/api/v1/qrs/aB3dE5fG7hJ9 \
-H "Authorization: Bearer $QRWALRUS_API_KEY"{
"data": {
"id": "aB3dE5fG7hJ9",
"deleted": true
}
}Code analytics
/qrs/{id}/analyticsAny keyQuery parameters
daysintegerHow 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.
curl "https://www.qrwalrus.com/api/v1/qrs/aB3dE5fG7hJ9/analytics?days=7" \
-H "Authorization: Bearer $QRWALRUS_API_KEY"{
"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.daysis the days you asked for, after the caps;period.sinceis where the period starts, at midnight UTC, and never before the day the code was made.totals.scansandtotals.uniqueScansare lifetime counts;totals.lastScannedAtand every breakdown cover the period only.byDaylists only days with scans (UTC dates).byCountryandbyCityhold the top 20,byReferrerandbyUtmSourcethe top 10.byRulecounts scans per smart rule;"ruleId": nullis the code's own content.
List pages
/pagesAny keyEvery 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.
curl https://www.qrwalrus.com/api/v1/pages \
-H "Authorization: Bearer $QRWALRUS_API_KEY"{
"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
/pages/{id}/leadsAny keyWhat 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)
limitinteger1–100. Default 25.beforeintegerFor the next page of results: the pagination.nextBefore of the previous answer. A lead's full id (lead_4812) works too.
curl "https://www.qrwalrus.com/api/v1/pages/pg_4c1d9e7a2b3f5e/leads?limit=1" \
-H "Authorization: Bearer $QRWALRUS_API_KEY"{
"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
}
}curl "https://www.qrwalrus.com/api/v1/pages/pg_4c1d9e7a2b3f5e/leads?limit=1&before=4812" \
-H "Authorization: Bearer $QRWALRUS_API_KEY"- Keep passing
beforethepagination.nextBeforeof the last answer. WhenhasMoreis false,nextBeforeisnulland you have them all. Alimitorbeforethat isn't valid gets 400invalid_request. fieldsmaps each question's label to the answer, for quick use;answerskeeps every field with its id and type, so use it when two questions share a label.source.qrIdis the code the visitor came through, ornull.countryandcityarenullwhen 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.
// 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 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-Afterseconds, and don't retrymonthly_quota_exceededorlimit_exceeded. - Log
X-Request-Idwith 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.