API referencePages, forms & menus

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)

FieldTypeRequiredConstraints
labelstringyesmax 80 chars
destinationstringyeshttp:, https:, mailto:, or tel: URL
hintstringnomax 120 chars, shown under the label
idstringnounique per list; auto-assigned when omitted

socials[] (max 12)

FieldTypeRequiredConstraints
platformstringyeswebsite, twitter, instagram, facebook, linkedin, youtube, tiktok, github, email, whatsapp, custom
urlstringyesmax 500 chars; http(s) for all platforms except email, which also accepts a bare address or mailto:
labelstringonly for custommax 60 chars
iconUrlstringno (custom only)http(s), max 500 chars
idstringnounique 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

FieldTypeRequiredConstraints
namestringcreate onlymax 120 chars
statusstringnoactive (default) or paused
headlinestringnomax 200 chars
subheadlinestringnomax 200 chars
bodystringnomax 4000 chars
heroImageUrlstringnohttp(s), max 2000 chars (or upload below)
avatarUrlstringnohttp(s), max 2000 chars (or upload below)
buttonsarraynoshared block above
socialsarraynoshared block above
themeobjectnoshared 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}/submissions

Create / update fields

FieldTypeRequiredConstraints
namestringcreate onlymax 120 chars
descriptionstringnomax 1000 chars
fieldsarraynomax 50, see below
statusstringnoactive (default) or paused
redirectAfterSubmitstringnohttp(s) URL to send the visitor to
thankYouMessagestringnomax 1000 chars (used when no redirect)
submitLabelstringnomax 60 chars
logoUrlstringnohttp(s), max 500 chars; send "" on PATCH to clear
themeobjectnoshared block above
socialsarraynoshared block above (forms have no buttons)

Per-field shape (fields[])

FieldTypeRequiredConstraints
typestringyestext, email, phone, textarea, select, radio, checkbox
labelstringyesmax 120 chars
optionsstring[]for select AND radionon-empty list of choices
requiredbooleannodefault false
helpTextstringnomax 500 chars
placeholderstringnomax 200 chars
maxLengthintegerno1 to 8000
idstringnounique 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.

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

FieldTypeRequiredConstraints
namestringcreate onlymax 120 chars
statusstringnoactive (default) or paused
headlinestringnomax 200 chars
subheadlinestringnomax 200 chars
footerstringnomax 300 chars
heroImageUrlstringnohttp(s), max 2000 chars
sectionsarraynomax 20, see below
tagCategoriesarraynomax 10 custom filter categories, { id?, name (max 80), values[] (max 30, lowercased, max 40 chars each) }
theme / buttons / socialsnoshared blocks above

Sections and items

Each section: name (required, max 120), description (max 500), items[] (required array). Each item:

FieldTypeRequiredConstraints
namestringyesmax 120 chars
descriptionstringnomax 500 chars
pricestringnofree-form, max 30 chars ("$12.50", "€8", "12")
allergensstring[]nofree 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
dietarystring[]nosame rules; suggestions: vegetarian, vegan, gluten-free, dairy-free, halal, kosher, spicy
customTagsobjectnomap of tagCategory.id to values; keys must reference a category on the menu
imageUrlstringnohttp(s), max 2000 chars
idstringnounique; 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

FieldTypeRequiredConstraints
namestringcreate onlymax 80 chars
primaryColorstringcreate only6-digit hex
backgroundColorstringcreate only6-digit hex
dotTypestringnosquare, rounded (default), dots, classy, classy-rounded, extra-rounded
cornerTypestringnosquare, dot, rounded, classy, classy-rounded, dots, extra-rounded (default)
logoUrlstringnohttp(s); "" or null clears
sortOrderintegernodashboard ordering; null clears

Advanced style fields (all optional)

  • Toggles (boolean): gradientEnabled, gradientMidEnabled, bgGradientEnabled, eyeGradientEnabled, eyeDotGradientEnabled, transparentBg, logoHideDots, frameUppercase.
  • Colors (6-digit hex; ""/null clears): 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.