Pages, forms, menus & brand kits
Beyond QR codes, the API manages the hosted features a QR can point at, plus
your saved brand kits. All endpoints live under /api/qr/, use the same
Bearer authentication, and operate on the workspace your API key belongs to.
Plan gating matches the dashboard: landing pages and forms need Growth or
higher, menus need Business or higher. A 403 with FEATURE_NOT_ON_PLAN
names the capability and the plans that unlock it.
Shared building blocks
Landing pages and menus accept buttons; all three hosted resources accept
socials and theme.
buttons[] (max 12)
| Field | Type | Required | Constraints |
|---|---|---|---|
label | string | yes | max 80 chars |
destination | string | yes | http:, https:, mailto:, or tel: URL |
hint | string | no | max 120 chars, shown under the label |
id | string | no | unique per list; auto-assigned when omitted |
socials[] (max 12)
| Field | Type | Required | Constraints |
|---|---|---|---|
platform | string | yes | website, twitter, instagram, facebook, linkedin, youtube, tiktok, github, email, whatsapp, custom |
url | string | yes | max 500 chars; http(s) for all platforms except email, which also accepts a bare address or mailto: |
label | string | only for custom | max 60 chars |
iconUrl | string | no (custom only) | http(s), max 500 chars |
id | string | no | unique per list; auto-assigned when omitted |
theme
Optional object with background, text, and accent, each a 6-digit hex
color like #0f172a. Blank values are ignored.
Landing pages
A hosted link-in-bio style page at https://qr.exaroutes.com/p/{pageId}.
Up to 100 pages per workspace.
GET /api/qr/landing-pages
POST /api/qr/landing-pages
GET /api/qr/landing-pages/{pageId}
PATCH /api/qr/landing-pages/{pageId}
POST /api/qr/landing-pages/{pageId}/image
DELETE /api/qr/landing-pages/{pageId}Create / update fields
| Field | Type | Required | Constraints |
|---|---|---|---|
name | string | create only | max 120 chars |
status | string | no | active (default) or paused |
headline | string | no | max 200 chars |
subheadline | string | no | max 200 chars |
body | string | no | max 4000 chars |
heroImageUrl | string | no | http(s), max 2000 chars (or upload below) |
avatarUrl | string | no | http(s), max 2000 chars (or upload below) |
buttons | array | no | shared block above |
socials | array | no | shared block above |
theme | object | no | shared block above |
PATCH is partial: only keys you send change, and a sent array or object replaces the stored one wholesale (no deep merge).
POST /api/qr/landing-pages
Authorization: Bearer exr_live_...
Content-Type: application/json
{
"name": "Spring campaign",
"headline": "Everything we launched this spring",
"buttons": [
{ "label": "Shop now", "destination": "https://yourbrand.com/spring" },
{ "label": "Email us", "destination": "mailto:hello@yourbrand.com" }
],
"socials": [{ "platform": "instagram", "url": "https://instagram.com/yourbrand" }],
"theme": { "background": "#0f172a", "text": "#f8fafc", "accent": "#38bdf8" }
}Image upload
POST /landing-pages/{pageId}/image with { "kind": "hero" | "avatar", "dataUrl": "data:image/png;base64,..." }. Max 5MB; png, jpeg, gif, or webp
(no SVG). Returns { kind, url } and stores the URL on the page.
Responses
Every page object: pageId, workspaceId, name, status, headline,
subheadline, body, heroImageUrl, avatarUrl, buttons, socials,
theme, viewCount, createdAt, updatedAt (epoch ms). List returns
{ "pages": [...] } newest first; get/create/update return the object.
Forms
A hosted lead-capture form at https://qr.exaroutes.com/f/{formId}.
Up to 100 forms per workspace, 50 fields per form.
GET /api/qr/forms
POST /api/qr/forms
GET /api/qr/forms/{formId}
PATCH /api/qr/forms/{formId}
DELETE /api/qr/forms/{formId} # ?purgeSubmissions=true also deletes stored submissions
GET /api/qr/forms/{formId}/submissionsCreate / update fields
| Field | Type | Required | Constraints |
|---|---|---|---|
name | string | create only | max 120 chars |
description | string | no | max 1000 chars |
fields | array | no | max 50, see below |
status | string | no | active (default) or paused |
redirectAfterSubmit | string | no | http(s) URL to send the visitor to |
thankYouMessage | string | no | max 1000 chars (used when no redirect) |
submitLabel | string | no | max 60 chars |
logoUrl | string | no | http(s), max 500 chars; send "" on PATCH to clear |
theme | object | no | shared block above |
socials | array | no | shared block above (forms have no buttons) |
Per-field shape (fields[])
| Field | Type | Required | Constraints |
|---|---|---|---|
type | string | yes | text, email, phone, textarea, select, radio, checkbox |
label | string | yes | max 120 chars |
options | string[] | for select AND radio | non-empty list of choices |
required | boolean | no | default false |
helpText | string | no | max 500 chars |
placeholder | string | no | max 200 chars |
maxLength | integer | no | 1 to 8000 |
id | string | no | unique per form; auto-assigned when omitted |
POST /api/qr/forms
Authorization: Bearer exr_live_...
Content-Type: application/json
{
"name": "Event RSVP",
"fields": [
{ "type": "text", "label": "Full name", "required": true },
{ "type": "email", "label": "Email", "required": true },
{ "type": "radio", "label": "Attending?", "options": ["Yes", "No", "Maybe"] }
],
"thankYouMessage": "See you there!"
}Responses
Every form object: formId, workspaceId, name, description, fields,
status, redirectAfterSubmit, thankYouMessage, submitLabel, logoUrl,
theme, socials, submissionCount, createdAt, updatedAt. List returns
{ "forms": [...] } newest first.
GET /forms/{formId}/submissions returns { "form": {...}, "submissions": [...] }, newest first, no pagination. Each submission: submissionId,
values (a map of field id to string; checkboxes serialize as "true" /
"false"), ip, userAgent, referrer, createdAt. The dashboard also
exports submissions as CSV.
Menus
A hosted restaurant menu at https://qr.exaroutes.com/m/{menuId}.
Up to 50 menus per workspace, 20 sections per menu, 50 items per section.
GET /api/qr/menus
POST /api/qr/menus
GET /api/qr/menus/{menuId}
PATCH /api/qr/menus/{menuId}
DELETE /api/qr/menus/{menuId}Create / update fields
| Field | Type | Required | Constraints |
|---|---|---|---|
name | string | create only | max 120 chars |
status | string | no | active (default) or paused |
headline | string | no | max 200 chars |
subheadline | string | no | max 200 chars |
footer | string | no | max 300 chars |
heroImageUrl | string | no | http(s), max 2000 chars |
sections | array | no | max 20, see below |
tagCategories | array | no | max 10 custom filter categories, { id?, name (max 80), values[] (max 30, lowercased, max 40 chars each) } |
theme / buttons / socials | no | shared blocks above |
Sections and items
Each section: name (required, max 120), description (max 500),
items[] (required array). Each item:
| Field | Type | Required | Constraints |
|---|---|---|---|
name | string | yes | max 120 chars |
description | string | no | max 500 chars |
price | string | no | free-form, max 30 chars ("$12.50", "€8", "12") |
allergens | string[] | no | free strings, lowercased, max 40 chars each, max 30. The dashboard suggests gluten, dairy, eggs, nuts, peanuts, soy, fish, shellfish, sesame, celery, mustard, sulphites, lupin, molluscs, but any value is accepted |
dietary | string[] | no | same rules; suggestions: vegetarian, vegan, gluten-free, dairy-free, halal, kosher, spicy |
customTags | object | no | map of tagCategory.id to values; keys must reference a category on the menu |
imageUrl | string | no | http(s), max 2000 chars |
id | string | no | unique; auto-assigned when omitted |
POST /api/qr/menus
Authorization: Bearer exr_live_...
Content-Type: application/json
{
"name": "Dinner",
"headline": "Seasonal dinner menu",
"sections": [
{
"name": "Mains",
"items": [
{ "name": "Pan-seared salmon", "price": "$24", "dietary": ["gluten-free"] },
{ "name": "Mushroom risotto", "price": "$18", "dietary": ["vegetarian"] }
]
}
]
}Responses
Every menu object: menuId, workspaceId, name, status, headline,
subheadline, footer, heroImageUrl, sections, tagCategories,
theme, buttons, socials, viewCount, createdAt, updatedAt. List
returns { "menus": [...] }, most recently updated first.
Brand kits
Reusable style captures applied to QR codes via brandKitId on
create, update, or bulk rows. Up to 50 kits per
workspace. Managing kits requires a workspace admin.
GET /api/qr/brand-kits
POST /api/qr/brand-kits
PATCH /api/qr/brand-kits/{kitId}
DELETE /api/qr/brand-kits/{kitId}There is no single-kit GET; the list returns every kit’s full style.
Base fields
| Field | Type | Required | Constraints |
|---|---|---|---|
name | string | create only | max 80 chars |
primaryColor | string | create only | 6-digit hex |
backgroundColor | string | create only | 6-digit hex |
dotType | string | no | square, rounded (default), dots, classy, classy-rounded, extra-rounded |
cornerType | string | no | square, dot, rounded, classy, classy-rounded, dots, extra-rounded (default) |
logoUrl | string | no | http(s); "" or null clears |
sortOrder | integer | no | dashboard ordering; null clears |
Advanced style fields (all optional)
- Toggles (boolean):
gradientEnabled,gradientMidEnabled,bgGradientEnabled,eyeGradientEnabled,eyeDotGradientEnabled,transparentBg,logoHideDots,frameUppercase. - Colors (6-digit hex;
""/nullclears):gradientColor,gradientMidColor,bgGradientColor2,eyeColor,eyeGradientColor2,eyeDotColor,eyeDotGradientColor2,frameColor,frameTextColor,frameBgColor. - Gradient styles (
linear|radial):gradientStyle,bgGradientStyle,eyeGradientStyle,eyeDotGradientStyle. - Rotations (0-360 degrees):
gradientRotation,bgGradientRotation,eyeGradientRotation,eyeDotGradientRotation. - Numbers:
bgRound(0-1),logoSize(0-1),margin(0-40),logoMargin(0-40). - Enums:
shape(square|circle),errorCorrection(L/M/Q/H),cornerDotType(dot,square,rounded,extra-rounded,classy,classy-rounded),frameId(none,border,scanme,banner,rounded,polaroid,phone,ticket,bubble,ribbon),frameFont(sans,serif,mono,display,rounded). frameText(string, max 24 chars).
PATCH is partial, with real clears: sending logoUrl: "", frameId: "none", or eyeColor: "" removes the stored field instead of leaving it
behind.
Responses
Create and update return the full kit row (kitId, workspaceId, name,
createdBy, every style field set, createdAt, updatedAt). List returns
{ "brandKits": [...] } sorted by sortOrder, then newest first. Applying
a kit through the API renders pixel-identically to applying it in the
builder.