Scanely documentation

Build with Scanely.

Create and manage dynamic QR codes, automate destinations and read results through the REST API.

Updated September 12, 2026

Authentication & workspace scope

Base URL: https://scanely.io. API access is included in Business and Lifetime Business. Create a key in Dashboard → Settings → API Keys, then send it as a Bearer token. Store it on your server; never embed it in a public page. The complete key is only returned when created.

curl https://scanely.io/api/qr \
  -H "Authorization: Bearer sk_live_your_key_here"

Use Content-Type: application/json for JSON request bodies. For client workspace requests, also send X-Scanely-Workspace: workspace-id. A server integration without this header operates in the account’s personal workspace. Browser sessions can carry a selected-workspace cookie.

Keys inherit the account’s permissions. Within a client workspace, the member’s role controls access and the workspace owner’s plan supplies feature and usage limits. Viewer access does not permit writes. Account settings such as API keys and GA4 remain personal.

Create, read & update QR codes

POST /api/qr

Create a URL code

{
  "destinationUrl": "https://example.com/menu",
  "name": "Table 4",
  "campaignTag": "menu_q3"
}

Response:

{
  "id": "qr-id",
  "slug": "Ab3xY9k",
  "shortUrl": "https://scanely.io/r/Ab3xY9k"
}

Encode the returned shortUrl in the QR image to retain tracking and editable destinations. The API returns metadata, not image bytes. Download artwork from the dashboard or render the short URL with a QR library.

For URL codes, destinationUrl must be HTTP/HTTPS and at most 2,048 characters. Optional fields: name (255), campaignTag (100), fgColor, bgColor, gradientStart, gradientEnd, frameText, routingRules and redirectSchedule. Use six-digit hex colors. Keep frame text at 30 characters or fewer so it remains editable through PUT.

GET /api/qr?tag=menu_q3
GET /api/qr/:id

List or retrieve codes

The list is an array of records, newest first; tag is an optional exact campaign-tag filter. A single-code request returns one record plus health status. Responses use stored snake_case fields such as destination_url and campaign_tag. The list adds scan_count; the single-code response adds scanCount.

JSON-valued fields such as redirect_schedule, routing_rules, routing_data, vcard_data and page_data are serialized strings or null: parse them before use. Boolean flags such as is_active are 0/1. password_hash is masked as protected or null. Responses may include additional fields; do not send the entire record back as an update.

PUT /api/qr/:id

Update selected fields

{
  "destinationUrl": "https://example.com/new-menu",
  "name": "Table 4 - patio",
  "isActive": true,
  "expiresAt": "2026-12-31T23:59:59Z"
}

Returns { "success": true }. Omitted fields are unchanged. The underlying QR type is not changed by this endpoint.

Update fieldsBehavior
destinationUrl, vcard, page, routingUpdate the content appropriate to the saved QR type. Contact, page and app-routing objects replace their stored object; include all fields you want to keep. destinationUrl is ignored on page, file and contact-card codes.
name, campaignTag, isActiveRename, organize, pause or resume. Null clears name or tag.
expiresAt, maxScansExpiry timestamp with timezone; null clears it. Positive integer maximum scan count; null clears it. A per-code cap can stop redirects, unlike the account’s monthly tracking allowance.
passwordAt least four characters; null clears it. Unavailable for hosted files and contact cards, and incompatible with maxScans.
notifyEmail, notifyThreshold, muteHealthAlertsScan-count email and threshold (0-10,000), and a boolean to mute/unmute health monitoring.
fgColor, bgColor, frameText, gradientStart, gradientEnd, moduleStyle, eyeStyleVisual settings. Frame text is at most 30 characters on update. Module and eye styles are configured after creation. Modules: square, rounded, dots, classy, extra-rounded. Eyes: square, rounded, circle, leaf.
utmSource, utmMedium, utmCampaign, utmContent, utmTermCampaign parameters appended at redirect time.
redirectSchedule, routingRules, abTestingEnabledAdvanced routing. See the sections below for shapes and compatibility. Not available on page codes.

DELETE /api/qr/:id

Delete permanently

Returns { "success": true }. Deletes the code and its associated data; the printed link will stop working. Use isActive: false for a reversible pause.

QR types & uploads

POST accepts type: url (default), vcard, appstore, file or page. Each returns the same id/slug/shortUrl shape.

Scanely Pages

Create with type: "page" plus a page object, or a template when you want a preset. If page is present it is validated and stored; otherwise Scanely uses template or blank. Templates: menu, flyer, event, reviews, contact, blank. Pages start as inactive drafts. After saving, call POST /api/qr/:id/publish to activate the page. A scan then opens it at the returned short URL. Drafts use a shared Pages and QR slot. A/B testing, Smart Routing and schedules are not available on page codes.

{
  "type": "page",
  "name": "Dinner menu",
  "template": "menu"
}

Or send the full page object:

{
  "type": "page",
  "name": "Dinner menu",
  "page": {
    "template": "menu",
    "accent": "#183D3D",
    "blocks": [
      {
        "id": "header-1",
        "type": "header",
        "title": "Dinner",
        "subtitle": "Today",
        "assetId": ""
      },
      {
        "id": "text-1",
        "type": "text",
        "markdown": "Hours: 5-10 PM."
      }
    ]
  }
}

Update with PUT { page }:

{
  "page": {
    "template": "menu",
    "accent": "#183D3D",
    "blocks": [
      {
        "id": "header-1",
        "type": "header",
        "title": "Dinner",
        "subtitle": "Today",
        "assetId": ""
      },
      {
        "id": "text-1",
        "type": "text",
        "markdown": "Hours: 5-10 PM."
      }
    ]
  }
}

Optional PUT field pageLeadNotify (boolean) emails the account owner when a new lead arrives; it defaults to on. Upload a page image or PDF with POST /api/qr/:id/page-asset (multipart file, kind=image or kind=file), then set the returned id on the matching block’s assetId. Page photos and PDFs are included on Lifetime. File-QR hosting remains subscription-only.

Contact card

{
  "type": "vcard",
  "name": "Alex Morgan",
  "vcard": {
    "firstName": "Alex",
    "lastName": "Morgan",
    "org": "Example Co",
    "title": "Sales",
    "phone": "+12125550100",
    "email": "alex@example.com",
    "website": "https://example.com"
  }
}

firstName is required; each contact field is limited to 200 characters. A scan opens a hosted page with a downloadable vCard. An address or note is not part of the dynamic contact-card API.

App download

{
  "type": "appstore",
  "name": "Our app",
  "routing": {
    "ios": "https://apps.apple.com/",
    "android": "https://play.google.com/store/apps",
    "fallback": "https://example.com/app"
  }
}

fallback is required. Omit a store link or use an empty string when unavailable. iOS and Android devices use their corresponding link, otherwise the fallback is used.

Hosted file

Create with { "type": "file", "name": "Product brochure" }, then upload to the returned ID. The code cannot serve a file until the upload succeeds.

curl -X POST https://scanely.io/api/qr/QR_ID/file \
  -H "Authorization: Bearer sk_live_your_key_here" \
  -F "file=@brochure.pdf"

POST /api/qr/:id/file uploads or replaces the file and returns { success, fileName, fileSize }. PDF, PNG, JPEG, WebP and GIF are supported, up to 20 MB each. File hosting requires a monthly subscription; it is excluded from Lifetime. Owner storage caps: Starter 500 MB, Pro 2 GB, Business 10 GB.

QR logo

POST /api/qr/:id/logo accepts a multipart file: PNG, JPEG or SVG up to 500 KB. Returns { success, logoUrl }. DELETE /api/qr/:id/logo removes it. QR logos require Pro, Business or any Lifetime plan.

Timezone-aware redirect schedules

Send redirectSchedule on POST or PUT. It is available only on URL codes with Pro, Business or any Lifetime plan. The main destination is used outside a scheduled window. Page codes cannot use schedules.

Daily windows

{
  "destinationUrl": "https://example.com/menu",
  "redirectSchedule": {
    "mode": "daily",
    "timezone": "America/New_York",
    "windows": [
      {
        "start": "08:00",
        "end": "12:00",
        "url": "https://example.com/breakfast"
      },
      {
        "start": "17:00",
        "end": "22:00",
        "url": "https://example.com/dinner"
      }
    ]
  }
}

Use an IANA timezone such as America/New_York, America/Chicago, America/Denver, America/Los_Angeles, America/Phoenix, America/Anchorage or Pacific/Honolulu. Times are 24-hour HH:mm, evaluated in that timezone rather than the scanner’s timezone.

Up to 12 windows, repeating every day. Windows must not overlap, and equal start/end is invalid. Overnight windows are supported, such as 22:00 to 02:00. The start is inclusive and end exclusive. Daylight saving follows local clock time; the repeated hour matches twice and a skipped hour does not occur. Weekday filters are not supported.

One-time range

{
  "redirectSchedule": {
    "mode": "once",
    "timezone": "America/New_York",
    "start": "2026-12-01T13:00:00Z",
    "end": "2026-12-01T17:00:00Z",
    "url": "https://example.com/offer"
  }
}

One-time start and end are absolute ISO timestamps with an explicit offset or Z. The example is 8:00 AM-12:00 PM in New York on December 1. Supply a valid IANA timezone for display. The end must follow the start. A timezone-less local datetime is not accepted.

Clear or replace a schedule

{
  "redirectSchedule": null
}

A schedule cannot be combined with Smart Routing or enabled A/B testing. When switching, clear routingRules and set abTestingEnabled: false before or in the same PUT request.

Legacy scheduleUrl, scheduleStart and scheduleEnd remain supported for one date range. Use a complete valid range, or clear all three with null. Do not mix legacy fields with a non-null redirectSchedule. Saving a new-format schedule replaces the legacy range.

Smart Routing & A/B variants

Smart Routing

{
  "routingRules": [
    {
      "conditions": [
        {
          "field": "country",
          "op": "in",
          "values": [
            "US",
            "CA"
          ]
        },
        {
          "field": "device",
          "op": "in",
          "values": [
            "mobile"
          ]
        }
      ],
      "url": "https://example.com/north-america"
    }
  ]
}

Send routingRules on POST or PUT for a URL code. Rules are evaluated in order; the first rule whose conditions all match wins. Otherwise the main URL is used. Unknown scanner attributes do not match a condition. Smart Routing is not available on page codes.

Condition fields: country (two-letter codes), device (mobile, tablet, desktop), or os (windows, macos, android, ios, linux, other). Operators: in or not_in. Limits: 15 rules, 8 conditions per rule, 40 values per condition and 8 KB of rule data. Clear rules with null or an empty array.

A/B variants

GET /api/qr/:id/variants lists variants. Create one with:

POST /api/qr/:id/variants

{
  "destinationUrl": "https://example.com/offer-a",
  "label": "Offer A",
  "weight": 50
}

Returns { "id": "variant-id" }. Use PUT /api/qr/:id/variants/:variantId to update destinationUrl, label, weight or isActive, and DELETE at the same path to remove a variant.

Up to five variants; use integer weights between 1 and 100 with a total no greater than 100. Enable with PUT /api/qr/:id and { "abTestingEnabled": true }. Browser assignments are sticky for up to 30 days via a cookie. Plan for one routing strategy per code. Smart Routing and A/B require Pro, Business or any Lifetime plan; schedules must be removed first. A/B testing is not available on page codes.

Bulk creation

POST /api/qr/bulk

{
  "items": [
    {
      "destinationUrl": "https://example.com/table-1",
      "name": "Table 1",
      "campaignTag": "menu"
    },
    {
      "destinationUrl": "invalid-url",
      "name": "Table 2"
    }
  ]
}

Example response:

{
  "created": [
    {
      "row": 1,
      "id": "qr-id",
      "slug": "Ab3xY9k",
      "shortUrl": "https://scanely.io/r/Ab3xY9k",
      "name": "Table 1",
      "destinationUrl": "https://example.com/table-1",
      "campaignTag": "menu"
    }
  ],
  "skipped": [
    {
      "row": 2,
      "reason": "Invalid URL format"
    }
  ],
  "total": 2
}

Accepts 1-1,000 URL-code rows on paid plans, subject to the owner’s remaining QR allowance. Row numbers are one-based. Invalid rows are skipped and reported; successful rows stay created. Check created and skipped instead of assuming the whole batch succeeded. Retrying the entire batch creates additional codes for successful rows.

Analytics, exports & sharing

  • GET /api/scans/:qrId?period=30d returns the range, plan and per-code analytics, including totalScans, timeSeries, geoBreakdown, deviceBreakdown and browserBreakdown.
  • GET /api/dashboard/analytics?period=30d returns workspace-level analytics.
  • GET /api/dashboard/analytics/:type?period=30d returns a breakdown for locations, browsers, os, referrers, devices, hours or campaigns.
  • GET /api/dashboard/insights returns insights calculated from the current workspace’s data.
  • GET /api/dashboard/export?period=30d&qrId=QR_ID downloads raw scan CSV. The QR filter is optional; the workspace header applies. There is no campaign-tag filter or campaign-tag column in this export.
  • GET /api/qr/:id/leads lists first-party Scan Page leads as JSON (items, total, limit). Newest first, up to 200 rows. Fields: id, created_at, name, email, phone, message. Add ?format=csv on a paid plan for a CSV download.
  • GET /api/dashboard/leads lists workspace leads with the same lead fields plus qr_id, qr_name and qr_slug.

Periods: 7d, 30d (default), 90d, this_month, last_month, all or custom. Custom ranges use from=YYYY-MM-DD&to=YYYY-MM-DD. Free accounts are restricted to the current calendar month. Raw scan CSV requires Pro, Business or any Lifetime plan. Lead CSV on GET /api/qr/:id/leads?format=csv requires any paid plan.

Recorded scan times and hourly charts use UTC. Stored timestamps may use SQLite’s YYYY-MM-DD HH:mm:ss representation; interpret those as UTC. Repeated scans from the same hashed IP and QR within one hour are deduplicated. Unique counts are estimates, and approximate location can be missing. Monthly tracking caps stop recording without stopping ordinary redirects.

Public read-only analytics

POST /api/qr/:id/share creates or rotates a public link and returns { shareToken, shareUrl }. Rotation immediately invalidates the old link. DELETE /api/qr/:id/share revokes access. Requires Pro, Business or any Lifetime plan. Anyone with the link can view it, without login. GET /api/shared/:token is the public data endpoint.

Test without adding campaign scans

POST /api/qr/:id/test creates a test session for an active code and returns { id, url, expiresAt }. Open or encode this temporary URL, then read GET /api/qr/:id/test?session=TEST_ID for completed_at, device, country and expires_at (or null for an unknown session).

Tests expire in 15 minutes. Creating another session invalidates any unfinished one for that code. Test activity is excluded from campaign analytics, the scan allowance, GA4 and conversions. Do not distribute or print the temporary URL; test the final exported QR separately.

Conversion tracking

Available with Pro, Business or any Lifetime plan. A personal or client workspace has one project, one website origin and one goal.

GET /api/conversions/settings
PUT /api/conversions/settings

{
  "origin": "https://example.com",
  "goal": "booking",
  "enabled": true
}

PUT returns the project id. Origin must use HTTPS without a path, query or fragment; local development permits HTTP on localhost or 127.0.0.1. Goals: lead, booking, purchase. Keep the workspace header consistent between configuration and reporting.

Browser integration

<script src="https://scanely.io/scanely.js"></script>
<script>
  Scanely.init({ project: "PROJECT_ID", consent: false });

  // Call from your consent manager after the relevant consent choice.
  function onAnalyticsConsentGranted() {
    Scanely.consent(true);
  }

  // Call only after your application confirms the booking succeeded.
  async function onBookingConfirmed(opaqueBookingId) {
    await Scanely.track("booking", {
      eventId: "booking_" + opaqueBookingId
    });
  }
</script>

The script uses the public project ID, never your secret API key. Call the event only after your application confirms the actual action. The event name must match the configured goal. Use an opaque, stable event ID for each action and reuse it on retries: 8-100 characters, letters, digits, underscores or hyphens. Never put contact details in it.

For a purchase project:

Scanely.track("purchase", {
  eventId: "order_a1b2c3d4",
  valueMinor: 4900,
  currency: "USD"
});

valueMinor is an integer amount from 0 to 10,000,000,000; currency is a three-letter uppercase code. The SDK sends to the public POST /api/conversions/event?project=PROJECT_ID endpoint using the allowed origin and signed scan reference. Prefer the SDK over constructing events manually.

Attribution starts with an eligible URL redirect whose final origin matches the project. The SDK uses session storage and sends events only after consent. The reference is valid for up to 30 days, limited by the browser tab’s session; there is no cross-device attribution. The latest matching visit in the tab is used. Redirect-side attribution and regular scan logging are separate from browser SDK consent.

GET /api/conversions/stats returns results for visits from the last 30 days. Goal completion is counted once per attributed visit. Purchase values are website-reported, not payment-provider verification. Disabling the project stops new tracking but keeps past data.

Client workspaces

Business or Lifetime Business owners can create up to 30 spaces. Send X-Scanely-Workspace for scoped requests. Editors can manage QR codes and conversion settings; viewers have read access. Only owners manage branding, invitations and roles. Owner plan limits are shared across all owned spaces.

GET /api/workspaces

List accessible spaces; returns items, active and canCreate.

POST /api/workspaces

Create with { "name": "Acme" }; returns id and name.

GET /api/workspaces/team

Read members and pending invitations in the selected client workspace.

POST /api/workspaces/invites

Owner: { "email": "editor@example.com", "role": "editor" }. Returns id, url, expiresInDays. Role can also be viewer.

POST /api/workspaces/accept

Accept with { "token": "invite-token" }, authenticated as the invited email. Returns workspaceId.

PATCH /api/workspaces/team

Owner: { "userId": "member-id", "role": "viewer" }, editor or remove; alternatively { "inviteId": "invite-id" } revokes an invitation.

PUT /api/workspaces/branding

Owner: send all fields: name, brandName, brandColor and brandLogo. Example below.

GET /api/workspaces/report

Read branded report data for the last 30 days as JSON. The dashboard generates the downloadable PDF.

Branding payload

{
  "name": "Acme",
  "brandName": "Acme Studio",
  "brandColor": "#285B49",
  "brandLogo": null
}

Names are limited to 80 characters. brandLogo is null or a PNG/JPEG base64 data URL; use an image under 200 KB. Invitations expire after seven days and can be used once by the specified email. The returned URL must be shared manually; no invitation email is sent automatically. Up to 50 pending invites per workspace.

POST /api/workspaces/select with { "workspaceId": "workspace-id" } or null changes the browser session’s selected workspace. Server integrations should use the header instead. Client workspaces become read-only when the owner loses workspace plan access.

Account integrations & saved designs

  • GET /api/user/plan reads current plan and usage information.
  • GET /api/user/api-keys lists keys; POST /api/user/api-keys with { "name": "Server integration" } returns id, key, prefix and name. Up to five keys. DELETE /api/user/api-keys/:id revokes a key.
  • GET /api/user/ga4 reads GA4 connection status. PUT /api/user/ga4 accepts measurementId and apiSecret; an empty measurementId disconnects. A new measurement ID needs its own secret. The secret is write-only. Requires Pro, Business or any Lifetime plan.
  • GET /api/dashboard/design-templates lists saved designs in the current workspace; POST at the same path saves a name and visual fields (fgColor, bgColor, gradientStart, gradientEnd, frameText, moduleStyle, eyeStyle). DELETE /api/dashboard/design-templates/:id removes one. Up to 50 saved designs per workspace; names are limited to 60 characters.

GA4 scan events and Scanely conversion events are separate integrations. Print PDFs are generated in the browser, either from the public static tools or a saved QR’s dashboard Print kit. Analytics PDFs are generated in the dashboard. There is no public PDF-rendering endpoint.

Errors & limits

StatusMeaning
400Invalid input, unsupported combinations, invalid dates or file validation failure. Read the response message.
401Missing or invalid authentication.
403Insufficient plan, role, capacity or storage, or an invalid public conversion origin/reference.
404Resource not found or inaccessible in the current scope.
409A conflict or capacity constraint, for example reaching the pending workspace invitation limit.
413Conversion event payload too large.
500Server-side failure, such as an unavailable file storage service.

Success and error shapes vary by endpoint; use the HTTP status and returned message. Write requests are not generally idempotent. Inspect the result before retrying creation, bulk imports or invitation requests. Conversion event IDs provide event deduplication.

See the plan capability matrix for QR, tracked-scan and storage allowances. Dates sent for expiry and one-time ranges should include their timezone offset; daily schedules use local clock times plus a named timezone.