API reference

VisionLink QR API 1.0.0 · OpenAPI 3.1 spec (YAML)

Overview

The /v1 API lets a workspace create, list, retarget, pause, activate and delete hosted QR codes, read each code's scan stats, get each code's QR as a styled SVG or PNG, and read its plan usage. Every code gets a public scan URL at the domain root: https://vlcard.me/<short_code>. On plans with custom links (Business and above) a code can also have a vanity slug, https://vlcard.me/<slug>, which scans the same way; the short-code URL keeps working either way. Scans get a 302 to the code's current destination, so you can change where a printed QR goes without reprinting it.

On Enterprise and the partner API plans, webhooks push code changes and scans to your own HTTPS endpoint as they happen; see Webhooks.

Call the API from your server. Browser calls from other origins are blocked by CORS, and an API key in front-end code is a leaked key.

Requests and responses are JSON (Content-Type: application/json). Timestamps are RFC 3339 in UTC. Codes created through the API always have origin set to api.

Destination rules (checked on create and update, same rules as the dashboard):

  • http or https URL with a host, at most 2048 bytes after trimming surrounding whitespace.
  • No spaces or control characters inside the URL, no user@ userinfo, no IP-literal hosts such as [::1], and a port of 1 to 5 digits if present.
  • Percent escapes in the path and fragment must be valid and must not encode NUL, CR or LF. The query string is passed through as is.

A destination that breaks any rule is rejected with 400 invalid_destination.

Base URL: https://vlcard.me/v1

Authentication

A workspace API key, sent as X-API-Key: vlq_…. Managers of a workspace whose plan includes API access mint keys in the dashboard under API. The full key is shown once; only a hash is stored, so if you lose it, revoke it and mint a new one. A key acts on its own workspace only.

Rate limits

There are two budgets, chosen after the key is looked up.

  • Valid key: limited per key to your plan's requests per minute (Business 120, Enterprise 600, Partner 300, Scale 1,200, Platform 3,000). The window is a fixed minute that starts with the first request. Over the limit you get 429 rate_limited with no rate-limit headers; wait for the minute to pass.
  • Missing, unknown, revoked or no-access key: 60 requests per minute per client IP, shared by every protected /v1 route. These responses carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds until the window resets). The 61st request gets 429 rate_limited with Retry-After in seconds.

GET /v1/health is never rate limited. Scans of https://vlcard.me/<short_code> never count against these limits and are never answered with 429.

Errors

Errors are JSON with one field, error, holding a stable snake_case code: {"error": "not_found"}. Branch on the code, not on the HTTP status alone. Unmatched paths under /v1 return 404 not_found.

Error codes
CodeStatusMeaning
missing_api_key401No X-API-Key header.
invalid_api_key401The key is unknown, mistyped or revoked.
api_access_required403The workspace's plan doesn't include API access.
rate_limited429The per-key or no-key IP budget is spent.
not_found404No such code in this workspace, a malformed short code, or an unknown /v1 path.
invalid_json400The body isn't a JSON object with the documented field types.
invalid_limit400limit isn't an integer of 1 or more.
invalid_offset400offset isn't an integer of 0 or more.
invalid_expiry400expires_at isn't an RFC 3339 timestamp.
invalid_name400name is neither a string nor null.
invalid_destination400The destination is missing or breaks the destination rules.
expiry_must_be_future400expires_at isn't in the future.
expiry_exceeds_plan_limit400expires_at is further out than the plan allows.
ads_not_available400has_ad is true on a plan without an ad-supported pool.
invalid_status400Reserved; not returned by the current endpoints.
active_limit_reached403The clean active pool is full.
ad_limit_reached403The ad-supported active pool is full.
library_limit_reached403The workspace holds as many codes as the plan allows.
redirect_quota_reached403This month's redirect units are used up.
code_expired409The code is past expires_at and can't be activated.
invalid_slug400vanity_slug isn't a valid slug (or isn't a string or null).
not_entitled403The plan doesn't include custom links (Business and above).
slug_taken409The slug is used by another code, or reads as an existing short code.
slug_cooling_down409Another workspace released the slug less than 90 days ago.
slug_reserved422The slug is a reserved word or blocked by staff.
legacy_code_read_only409The code is a legacy VisionLink code printed before v2. You can read it (get, list, stats, image) but not change, pause, delete, restyle or link it through the API.
invalid_date400from or to isn't a YYYY-MM-DD date.
invalid_range400from is after to, the range is longer than 366 days, or it ends before the oldest day the plan keeps.
analytics_not_available403The plan doesn't include scan stats over the API (Business and above).
invalid_format400format isn't svg or png.
invalid_size400size isn't a whole number from 512 to 2048.
invalid_preset400preset is missing, empty, or not a string or null.
preset_not_found404The preset isn't one of this workspace's presets, or there is no default preset.
presets_not_available403The plan doesn't include presets (Business and above).
preset_not_usable409The preset's logo was deleted or its frame text is no longer allowed. Fix it in the dashboard.
render_failed502Drawing the image failed. Safe to retry.
image_unavailable503Images are temporarily unavailable. Safe to retry later.
internal_error500Unexpected server error.

Codes

Hosted QR codes owned by the key's workspace.

GET/v1/codes

List codes

Returns the workspace's codes, newest first. The list includes codes created in the dashboard, not only API-created ones. total is the count of all codes in the workspace, so page with offset until you have seen total rows.

GET /v1/codes parameters
FieldTypeDescription
limitqueryintegerPage size, 1 or more. Values above 100 are treated as 100.
offsetqueryintegerRows to skip, 0 or more.
Request
curl -s https://vlcard.me/v1/codes \
  -H "X-API-Key: $VLQ_API_KEY"

200 One page of codes.

{
  "codes": [
    {
      "id": "3f0c9a52-8a3b-4d7e-9f41-2b6c1d0e7a18",
      "org_id": "9b1e4c27-5d63-4f08-a2c9-7e3d5b1a6f40",
      "short_code": "aB3xY9",
      "name": "Spring flyer",
      "destination": "https://example.com/spring?utm_source=qr",
      "status": "active",
      "origin": "api",
      "expires_at": null,
      "has_ad": false,
      "created_at": "2026-09-17T08:30:00Z",
      "updated_at": "2026-09-17T08:30:00Z",
      "vanity_slug": null,
      "vanity_url": null
    }
  ],
  "total": 1
}

POST/v1/codes

Create a code

Creates an active code with a new six-character short_code. The code counts against the plan's active pool (clean, or ad-supported when has_ad is true), library size and monthly redirect quota. Its scan URL is https://vlcard.me/<short_code>.

POST /v1/codes request body
FieldTypeDescription
destinationrequiredstring (uri)http or https URL that follows the destination rules. Surrounding whitespace is trimmed. maxLength is in bytes.
namestring | nullOptional label. Trimmed; empty means no name.
expires_atstring (date-time) | nullOptional RFC 3339 timestamp in the future, within the plan's maximum expiry. null or an empty string means no expiry.
has_adbooleanPartner plans (Partner, Scale, Platform) only. Puts the code in the ad-supported pool, whose scans cost 0.25 redirect units. On other plans true returns 400 ads_not_available. Default false.
Request
curl -s -X POST https://vlcard.me/v1/codes \
  -H "X-API-Key: $VLQ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"destination":"https://example.com/menu"}'

201 The code was created.

{
  "id": "3f0c9a52-8a3b-4d7e-9f41-2b6c1d0e7a18",
  "org_id": "9b1e4c27-5d63-4f08-a2c9-7e3d5b1a6f40",
  "short_code": "aB3xY9",
  "name": "Spring flyer",
  "destination": "https://example.com/spring?utm_source=qr",
  "status": "active",
  "origin": "api",
  "expires_at": "2026-12-31T22:00:00Z",
  "has_ad": true,
  "created_at": "2026-09-17T08:30:00Z",
  "updated_at": "2026-09-17T08:30:00Z",
  "vanity_slug": null,
  "vanity_url": null
}

GET/v1/codes/{code}

Get a code

A deleted code (from the dashboard or the API) is not returned by the API (list or get); it answers 404 like a code that never existed.

GET /v1/codes/{code} parameters
FieldTypeDescription
codepathrequiredstringThe code's six-character short_code (not its id). Case-sensitive. Anything that isn't six letters or digits, or belongs to another workspace, returns 404 not_found.
Request
curl -s https://vlcard.me/v1/codes/aB3xY9 \
  -H "X-API-Key: $VLQ_API_KEY"

200 The code.

{
  "id": "3f0c9a52-8a3b-4d7e-9f41-2b6c1d0e7a18",
  "org_id": "9b1e4c27-5d63-4f08-a2c9-7e3d5b1a6f40",
  "short_code": "aB3xY9",
  "name": null,
  "destination": "https://example.com/menu",
  "status": "paused",
  "origin": "api",
  "expires_at": null,
  "has_ad": false,
  "created_at": "2026-09-17T08:30:00Z",
  "updated_at": "2026-09-18T12:05:41Z",
  "vanity_slug": null,
  "vanity_url": null
}

PATCH/v1/codes/{code}

Retarget a code or set its custom link

Sets a new destination and optionally changes the name. Scans follow the new destination straight away. The short code, status and expiry don't change.

vanity_slug sets (string) or removes (null or empty string) the code's custom link, https://vlcard.me/<slug>. A request that only sets vanity_slug may leave out destination. Changes are all-or-nothing: if the slug is refused, the destination isn't changed either. Changing or removing a slug breaks anything printed with the old slug (the short-code URL keeps working); the old slug is held for 90 days so only this workspace can take it back.

PATCH /v1/codes/{code} parameters
FieldTypeDescription
codepathrequiredstringThe code's six-character short_code (not its id). Case-sensitive. Anything that isn't six letters or digits, or belongs to another workspace, returns 404 not_found.
PATCH /v1/codes/{code} request body
FieldTypeDescription
destinationstring (uri)The new destination. Required on every update, even if only the name changes, unless the request only sets vanity_slug.
namestring | nullLeave the field out to keep the current name. Send a string to set it, or null or "" to remove it.
vanity_slugstring | nullBusiness plans and above. Leave out to keep the current custom link. A string sets it (3-32 characters of lowercase letters, digits and single dashes, not starting or ending with a dash; input is lowercased); null or "" removes it.
Request
curl -s -X PATCH https://vlcard.me/v1/codes/aB3xY9 \
  -H "X-API-Key: $VLQ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"destination":"https://example.com/summer"}'

200 The updated code.

{
  "id": "3f0c9a52-8a3b-4d7e-9f41-2b6c1d0e7a18",
  "org_id": "9b1e4c27-5d63-4f08-a2c9-7e3d5b1a6f40",
  "short_code": "aB3xY9",
  "name": "Summer flyer",
  "destination": "https://example.com/summer",
  "status": "active",
  "origin": "api",
  "expires_at": null,
  "has_ad": false,
  "created_at": "2026-09-17T08:30:00Z",
  "updated_at": "2026-10-01T06:00:00Z",
  "vanity_slug": null,
  "vanity_url": null
}

DELETE/v1/codes/{code}

Delete a code

Deletes the code for good. The scan URL stops redirecting at once and shows a "no longer active" page (410), and the short code is never given to anyone else. A deleted code can't be restored through the API, is no longer returned by list, get or stats, and still counts toward your plan's library limit. Its custom link, if any, is released. To stop scans but keep the code, pause it instead.

DELETE /v1/codes/{code} parameters
FieldTypeDescription
codepathrequiredstringThe code's six-character short_code (not its id). Case-sensitive. Anything that isn't six letters or digits, or belongs to another workspace, returns 404 not_found.
Request
curl -s -X DELETE https://vlcard.me/v1/codes/aB3xY9 \
  -H "X-API-Key: $VLQ_API_KEY"

204 The code was deleted. No body.

POST/v1/codes/{code}/pause

Pause a code

Sets status to paused. Scans show an "unavailable" page instead of redirecting, and the code stops counting against the active pool. It stays in the library. No request body. Pausing an already paused code succeeds.

POST /v1/codes/{code}/pause parameters
FieldTypeDescription
codepathrequiredstringThe code's six-character short_code (not its id). Case-sensitive. Anything that isn't six letters or digits, or belongs to another workspace, returns 404 not_found.
Request
curl -s -X POST https://vlcard.me/v1/codes/aB3xY9/pause \
  -H "X-API-Key: $VLQ_API_KEY"

200 The paused code.

{
  "id": "3f0c9a52-8a3b-4d7e-9f41-2b6c1d0e7a18",
  "org_id": "9b1e4c27-5d63-4f08-a2c9-7e3d5b1a6f40",
  "short_code": "aB3xY9",
  "name": "Spring flyer",
  "destination": "https://example.com/spring?utm_source=qr",
  "status": "paused",
  "origin": "api",
  "expires_at": null,
  "has_ad": false,
  "created_at": "2026-09-17T08:30:00Z",
  "updated_at": "2026-09-18T12:05:41Z",
  "vanity_slug": null,
  "vanity_url": null
}

POST/v1/codes/{code}/activate

Activate a code

Sets status to active so scans redirect again. Re-checks the plan's active pool for a paused code. A code past its expires_at can't be activated; create a new code instead. No request body.

POST /v1/codes/{code}/activate parameters
FieldTypeDescription
codepathrequiredstringThe code's six-character short_code (not its id). Case-sensitive. Anything that isn't six letters or digits, or belongs to another workspace, returns 404 not_found.
Request
curl -s -X POST https://vlcard.me/v1/codes/aB3xY9/activate \
  -H "X-API-Key: $VLQ_API_KEY"

200 The active code.

{
  "id": "3f0c9a52-8a3b-4d7e-9f41-2b6c1d0e7a18",
  "org_id": "9b1e4c27-5d63-4f08-a2c9-7e3d5b1a6f40",
  "short_code": "aB3xY9",
  "name": "Spring flyer",
  "destination": "https://example.com/spring?utm_source=qr",
  "status": "active",
  "origin": "api",
  "expires_at": null,
  "has_ad": false,
  "created_at": "2026-09-17T08:30:00Z",
  "updated_at": "2026-09-19T07:45:12Z",
  "vanity_slug": null,
  "vanity_url": null
}

GET/v1/codes/{code}/stats

Get scan stats for a code

Scan counts for one code over a range of days: totals, one row per day and the top breakdowns. Available on Business and every plan above it (Business, Enterprise, Partner, Scale and Platform). Other plans get 403 analytics_not_available.

Days are calendar days in Africa/Johannesburg (SAST), and from and to are both included. Leave both out for the last 30 days up to today. A range can be at most 366 days. A to after today is treated as today. A from older than the plan keeps is moved up to the oldest day the plan keeps, and the response's from shows the day actually used. window_months is how far back the plan keeps scans (Business and Partner 12, Scale 24, null for unlimited on Enterprise and Platform).

scans and bot_scans count every scan, bots included. Bots are left out of unique_visitors, daily[].unique_visitors and every breakdown except devices. scans_lifetime and last_scanned_at cover the code's whole life and ignore the range. Only counts are returned, never personal data such as IP addresses or user agents.

GET /v1/codes/{code}/stats parameters
FieldTypeDescription
codepathrequiredstringThe code's six-character short_code (not its id). Case-sensitive. Anything that isn't six letters or digits, or belongs to another workspace, returns 404 not_found.
fromquerystring (date)First day of the range, YYYY-MM-DD in SAST. Defaults to 29 days before to. Moved up to the plan's oldest kept day if it is older.
toquerystring (date)Last day of the range, YYYY-MM-DD in SAST, included. Defaults to today. A day after today is treated as today.
Request
curl -s https://vlcard.me/v1/codes/aB3xY9/stats \
  -H "X-API-Key: $VLQ_API_KEY"

200 Stats for the code over the range actually used.

{
  "short_code": "aB3xY9",
  "from": "2026-09-21",
  "to": "2026-09-23",
  "timezone": "Africa/Johannesburg",
  "window_months": 12,
  "scans": 57,
  "unique_visitors": 41,
  "bot_scans": 2,
  "scans_lifetime": 1500,
  "last_scanned_at": "2026-09-23T08:12:44Z",
  "daily": [
    {
      "day": "2026-09-21",
      "scans": 12,
      "unique_visitors": 9
    },
    {
      "day": "2026-09-22",
      "scans": 0,
      "unique_visitors": 0
    },
    {
      "day": "2026-09-23",
      "scans": 45,
      "unique_visitors": 32
    }
  ],
  "countries": [
    {
      "key": "ZA",
      "scans": 48
    },
    {
      "key": "NA",
      "scans": 5
    }
  ],
  "devices": [
    {
      "key": "mobile",
      "scans": 50
    },
    {
      "key": "desktop",
      "scans": 5
    },
    {
      "key": "bot",
      "scans": 2
    }
  ],
  "browsers": [
    {
      "key": "Chrome",
      "scans": 33
    },
    {
      "key": "Safari",
      "scans": 22
    }
  ],
  "operating_systems": [
    {
      "key": "Android",
      "scans": 30
    },
    {
      "key": "iOS",
      "scans": 20
    },
    {
      "key": "Windows",
      "scans": 5
    }
  ],
  "referrers": [
    {
      "key": "Direct",
      "scans": 51
    },
    {
      "key": "example.com",
      "scans": 4
    }
  ],
  "utm_sources": [
    {
      "key": "newsletter",
      "scans": 6
    }
  ]
}

GET/v1/codes/{code}/image

Get a styled QR image for a code

The code's QR as an SVG or PNG, drawn exactly like a dashboard download: it always encodes the permanent https://vlcard.me/<short_code> (never a custom link), with a 4-module quiet zone, error correction M (H when the style has a logo), and the "Made with VisionLink QR" caption band when the code carries a watermark. Codes created through the API never do.

The style comes from your workspace's presets, which you build in the dashboard (Settings, QR designs). The API never takes raw style JSON, logos or frame text.

  • No preset: the code's own saved style, set in the dashboard or with PUT /v1/codes/{code}/style. A code without one is plain.
  • preset=default: the workspace's default preset (plain when there is none).
  • preset=<id>: that preset. Another workspace's preset, or an unknown id, is 404 preset_not_found.

On a plan without QR styling the image is plain, and the saved style is kept for when you upgrade. A preset's logo that has since been deleted, or frame text that is no longer allowed, is left out of the image.

The response carries a strong ETag that depends only on the drawing, and Cache-Control: private, no-cache. Store the image and send If-None-Match with its ETag; while nothing changed you get 304 with no body, which is cheap. Each request, 304 included, counts against your per-key rate limit.

GET /v1/codes/{code}/image parameters
FieldTypeDescription
codepathrequiredstringThe code's six-character short_code (not its id). Case-sensitive. Anything that isn't six letters or digits, or belongs to another workspace, returns 404 not_found.
formatquery"svg" | "png"svg (default) or png. Email signatures, Office and many website builders need PNG.
sizequeryintegerWidth of the whole image in pixels, 512 to 2048 (default 1024). A frame or the watermark band makes the image taller than it is wide. SVG scales freely; the size only sets its width and height.
presetquerystringdefault, or a preset id from the dashboard. Leave it out to use the code's own saved style.
If-None-MatchheaderstringThe ETag of an image you already have.
Request
curl -s https://vlcard.me/v1/codes/aB3xY9/image \
  -H "X-API-Key: $VLQ_API_KEY"

200 The image. Content-Disposition suggests vlq-<short_code>.svg or .png.

304 The image hasn't changed since the ETag you sent. No body.

PUT/v1/codes/{code}/style

Save a preset onto a code

Saves one of your workspace's presets onto the code, so the dashboard, its downloads and GET /v1/codes/{code}/image without a preset all draw it. null clears the style (plain). Available on Business and above; other plans get 403 presets_not_available (clearing works on any plan).

The preset's style is copied onto the code: editing the preset in the dashboard later doesn't change codes it was saved onto. A preset whose logo has since been deleted, or whose frame text is no longer allowed, is refused with 409 preset_not_usable; fix it in the dashboard first.

PUT /v1/codes/{code}/style parameters
FieldTypeDescription
codepathrequiredstringThe code's six-character short_code (not its id). Case-sensitive. Anything that isn't six letters or digits, or belongs to another workspace, returns 404 not_found.
PUT /v1/codes/{code}/style request body
FieldTypeDescription
presetrequiredstring | nulldefault, a preset id, or null to clear.
Request
curl -s -X PUT https://vlcard.me/v1/codes/aB3xY9/style \
  -H "X-API-Key: $VLQ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"preset":"default"}'

204 Saved. No body.

Usage

Plan limits and what the workspace is using.

GET/v1/usage

Get plan usage

A snapshot of the workspace's plan and how much of it is used. A null limit means unlimited. Redirect units reset each calendar month (Africa/Johannesburg); a clean scan costs 1 unit and an ad-supported scan 0.25. When over_quota is true, existing codes keep redirecting but creates fail with redirect_quota_reached.

Request
curl -s https://vlcard.me/v1/usage \
  -H "X-API-Key: $VLQ_API_KEY"

200 Usage for the key's workspace.

{
  "audience": "b2b",
  "api_access": true,
  "plan_id": "partner",
  "display_name": "Partner",
  "clean_active": 212,
  "clean_limit": 400,
  "ad_active": 35,
  "ad_limit": 1200,
  "library_used": 480,
  "library_limit": 10000,
  "redirect_units": 18250.75,
  "redirect_limit": 100000,
  "over_quota": false
}

Health

Liveness check. No key needed.

GET/v1/health

Check that the API is up

Answers without touching the database. Use it for uptime checks, not to test your key.

No API key needed.

Request
curl -s https://vlcard.me/v1/health

200 The API process is running.

{
  "status": "ok"
}

Webhooks

Webhooks push events to your own HTTPS endpoint as they happen, so you don't have to poll /v1. They come with Enterprise and the partner API plans (Partner, Scale, Platform).

Setting up. Workspace owners and admins add endpoints in the dashboard under Settings → Webhooks: an HTTPS URL and the events it should receive. A workspace can have up to 5 endpoints. Each endpoint gets its own signing secret (whsec_…), shown once when you add the endpoint or rotate the secret; keep it on your server like a password. Rotating replaces the secret at once, including for retries of earlier events. Send test event posts a webhook.test event to that endpoint only, once. Endpoints are managed in the dashboard only; there are no /v1 endpoints for them.

Events.

  • code.created: a code was created in the dashboard, over /v1 or by our staff. A code created with a custom link arrives once, with vanity_slug already set.
  • code.updated: a code's destination, name, status (paused, activated, expired) or expiry changed, or its custom link was set, changed or removed. data.changes lists what changed and data.previous holds the old values. A code becomes expired when the first scan after expires_at finds it, so that event arrives then, not at expires_at itself.
  • code.deleted: a code was deleted (retired). Its short code is never reused and scans get a "no longer active" page.
  • scan.created: codes were scanned. Scans are grouped: one event carries 1 to 100 scans (data.scans) of one workspace, gathered over about 2 seconds, so a busy code doesn't cause a request per scan. Each scan's code.vanity_slug is the custom link that scan came through, or null when the short-code URL was scanned.

Delivery. We POST a JSON body {id, type, created_at, workspace_id, data} with Content-Type: application/json, a few seconds after the event. Any 2xx within 10 seconds counts as delivered; redirects are not followed and count as a failure. Failures are retried with backoff (30 s, 2 min, 10 min, 30 min, 1 h, 2 h, 4 h, 8 h, 8 h), 10 attempts over about 24 hours, then the delivery is marked failed. An endpoint that has failed for 3 days without a single success is disabled; the dashboard shows it and you can enable it again. Every delivery from the last 30 days is listed under its endpoint with its status, response code and attempts, and can be sent again from there.

Order and duplicates. Events can arrive out of order and, rarely, more than once (delivery is at least once). Use id (evt_…) to drop duplicates, created_at or the code's updated_at to order them, and each scan's own id if you store scans. Answer quickly with a 2xx and do slow work afterwards.

Verifying. Every request carries VisionLink-Signature: t=<unix seconds>,v1=<hex>, where the hex is HMAC-SHA256 with your endpoint's secret (the whole whsec_… string as the key) over <t>.<raw request body>. Compute it over the raw bytes you received, before any JSON parsing, compare in constant time, and reject a t more than 5 minutes from your clock so an old request can't be replayed. There may be more than one v1= value; accept the request if any of them matches. VisionLink-Event-Id and VisionLink-Event-Type repeat the body's id and type.

Privacy. Scan events carry the time, country, device type, browser, operating system, referring site's domain, campaign (UTM) tags and whether the scan looked like a bot. Never the scanner's IP address (or its hash), user-agent text, language or visitor identifier.

Webhook request headers
FieldTypeDescription
VisionLink-Signaturerequiredstringt=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with the endpoint secret>. Reject a t more than 5 minutes from your clock.
VisionLink-Event-IdrequiredstringThe event's id. The same on every retry and resend; use it to drop duplicates.
VisionLink-Event-TyperequiredstringThe event's type.

POSTcode.created

A code was created

Sent to endpoints subscribed to code.created when a code is created in the dashboard, over /v1 or by staff. A code created with a custom link arrives once, with vanity_slug set.

Body
{
  "id": "evt_3b9f0c6ad2e94a7f8e51c2d4b6a8f0e1",
  "type": "code.created",
  "created_at": "2026-09-23T08:00:01.250Z",
  "workspace_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "data": {
    "code": {
      "id": "0f8fad5b-d9cb-469f-a165-70867728950e",
      "org_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
      "short_code": "aB3xY9",
      "name": "Spring menu",
      "destination": "https://example.com/menu",
      "status": "active",
      "origin": "api",
      "expires_at": null,
      "has_ad": false,
      "created_at": "2026-09-23T08:00:01.201Z",
      "updated_at": "2026-09-23T08:00:01.201Z",
      "deleted_at": null,
      "vanity_slug": "spring-menu",
      "vanity_url": "https://vlcard.me/spring-menu"
    }
  }
}

POSTcode.updated

A code changed

Sent when a code's destination, name, status or expiry changes, or its custom link is set, changed or removed. changes lists the fields that changed; previous holds their old values.

Body
{
  "id": "evt_9d2c41e07ab5463c8f1e2a3b4c5d6e7f",
  "type": "code.updated",
  "created_at": "2026-09-24T12:30:00.004Z",
  "workspace_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "data": {
    "code": {
      "id": "0f8fad5b-d9cb-469f-a165-70867728950e",
      "org_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
      "short_code": "aB3xY9",
      "name": "Spring menu",
      "destination": "https://example.com/menu-v2",
      "status": "active",
      "origin": "api",
      "expires_at": null,
      "has_ad": false,
      "created_at": "2026-09-23T08:00:01.201Z",
      "updated_at": "2026-09-24T12:30:00.001Z",
      "deleted_at": null,
      "vanity_slug": "spring-menu",
      "vanity_url": "https://vlcard.me/spring-menu"
    },
    "changes": [
      "destination"
    ],
    "previous": {
      "destination": "https://example.com/menu"
    }
  }
}

POSTcode.deleted

A code was deleted

Sent when a code is deleted (retired). The code keeps its short code forever; status is deleted, its custom link is released and scans get a "no longer active" page.

Body
{
  "id": "evt_1a2b3c4d5e6f47809a1b2c3d4e5f6a7b",
  "type": "code.deleted",
  "created_at": "2026-10-01T09:15:42.870Z",
  "workspace_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "data": {
    "code": {
      "id": "0f8fad5b-d9cb-469f-a165-70867728950e",
      "org_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
      "short_code": "aB3xY9",
      "name": "Spring menu",
      "destination": "https://example.com/menu-v2",
      "status": "deleted",
      "origin": "api",
      "expires_at": null,
      "has_ad": false,
      "created_at": "2026-09-23T08:00:01.201Z",
      "updated_at": "2026-10-01T09:15:42.866Z",
      "deleted_at": "2026-10-01T09:15:42.866Z",
      "vanity_slug": null,
      "vanity_url": null
    }
  }
}

POSTscan.created

Codes were scanned

Sent a few seconds after scans. One event carries 1 to 100 scans of one workspace, gathered over about 2 seconds; a busy workspace gets several events. Each scan has its own id for de-duplication, and code.vanity_slug is the custom link used for that scan (null when the short code was scanned), even if the code's link has changed since. Counts-level fields only; never the scanner's IP address, its hash, the user-agent text or a visitor identifier.

Body
{
  "id": "evt_5f0e1d2c3b4a49588776655443322110",
  "type": "scan.created",
  "created_at": "2026-09-24T07:45:12.502Z",
  "workspace_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "data": {
    "scans": [
      {
        "id": "6ba7b810-9dad-41d1-80b4-00c04fd430c8",
        "scanned_at": "2026-09-24T07:45:10.917Z",
        "code": {
          "short_code": "aB3xY9",
          "vanity_slug": "spring-menu"
        },
        "country": "ZA",
        "device": "mobile",
        "browser": "Chrome",
        "os": "Android",
        "referrer_host": null,
        "utm_source": "poster",
        "utm_medium": "print",
        "utm_campaign": "spring",
        "is_bot": false
      }
    ]
  }
}

POSTwebhook.test

Test event

Sent once, only to the endpoint you pressed Send test event for, whatever events it subscribes to. It can't be subscribed to and is never retried. Use it to check your URL and your signature check.

Body
{
  "id": "evt_c0ffee00c0ffee00c0ffee00c0ffee00",
  "type": "webhook.test",
  "created_at": "2026-09-23T08:05:00.000Z",
  "workspace_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "data": {
    "message": "Test event from VisionLink QR. Your endpoint received and can verify it.",
    "endpoint_id": "9b2e7c1a-4d3f-4a6b-8c9d-0e1f2a3b4c5d"
  }
}

Verifying the signature

Node.js
// Node 18+. Keep the raw body: with Express, use express.raw({ type: "application/json" }).
import crypto from "node:crypto";

export function verifyVisionLinkSignature(rawBody, header, secret, toleranceSeconds = 300) {
  let timestamp = NaN;
  const signatures = [];
  for (const piece of String(header ?? "").split(",")) {
    const [key, value] = piece.trim().split("=");
    if (key === "t") timestamp = Number(value);
    if (key === "v1" && value) signatures.push(value);
  }
  if (!Number.isInteger(timestamp) || signatures.length === 0) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
  return signatures.some((sig) => {
    const given = Buffer.from(sig, "hex");
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}

// app.post("/visionlink/webhooks", express.raw({ type: "application/json" }), (req, res) => {
//   const ok = verifyVisionLinkSignature(req.body, req.get("VisionLink-Signature"), process.env.VISIONLINK_WEBHOOK_SECRET);
//   if (!ok) return res.sendStatus(400);
//   const event = JSON.parse(req.body);
//   // Drop duplicates by event.id, then handle event.type.
//   res.sendStatus(204);
// });
Go
package webhook

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "errors"
    "strconv"
    "strings"
    "time"
)

// VerifySignature checks a VisionLink-Signature header against the raw request body.
func VerifySignature(body []byte, header, secret string, tolerance time.Duration) error {
    timestamp := int64(-1)
    var signatures []string
    for _, part := range strings.Split(header, ",") {
        key, value, _ := strings.Cut(strings.TrimSpace(part), "=")
        switch key {
        case "t":
            n, err := strconv.ParseInt(value, 10, 64)
            if err != nil {
                return errors.New("bad timestamp")
            }
            timestamp = n
        case "v1":
            signatures = append(signatures, value)
        }
    }
    if timestamp < 0 || len(signatures) == 0 {
        return errors.New("no signature")
    }
    if age := time.Since(time.Unix(timestamp, 0)); age > tolerance || age < -tolerance {
        return errors.New("timestamp outside the tolerance")
    }
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(strconv.FormatInt(timestamp, 10) + "."))
    mac.Write(body)
    expected := mac.Sum(nil)
    for _, s := range signatures {
        if given, err := hex.DecodeString(s); err == nil && hmac.Equal(given, expected) {
            return nil
        }
    }
    return errors.New("signature mismatch")
}

// In your handler: read the raw body, then
//   err := VerifySignature(body, r.Header.Get("VisionLink-Signature"), secret, 5*time.Minute)
// Answer 400 on an error; otherwise drop duplicates by the event id, handle it and answer 204.

Objects

QRCode

QRCode fields
FieldTypeDescription
idrequiredstring (uuid)Internal id. Use short_code in API paths.
org_idrequiredstring (uuid)The workspace that owns the code.
short_coderequiredstringSix case-sensitive letters and digits. The scan URL is https://vlcard.me/<short_code>.
namerequiredstring | nullYour label for the code. Never shown to scanners.
destinationrequiredstring (uri)Where scans are sent, stored trimmed. maxLength is in bytes.
statusrequired"active" | "paused" | "expired"active redirects. paused and expired show an unavailable page. A code becomes expired once a scan finds it past expires_at.
originrequired"api" | "standalone" | "once_off" | "visionlink_card" | "legacy"How the code was created. api for this API; the others are dashboard origins. legacy is a code carried over unchanged from the previous VisionLink QR system (printed as https://qr.visionlink.co.za/qr/<code>); only VisionLink's own workspace has them.
expires_atrequiredstring (date-time) | nullAfter this moment scans stop redirecting. null means no expiry.
has_adrequiredbooleanWhether the code is in the ad-supported pool.
created_atrequiredstring (date-time)
updated_atrequiredstring (date-time)
vanity_slugrequiredstring | nullThe code's custom link slug, lowercase. null when unset.
vanity_urlrequiredstring (uri) | nullhttps://vlcard.me/<vanity_slug>, or null when unset. Scans the same as the short-code URL.

CodeList

CodeList fields
FieldTypeDescription
codesrequiredQRCode[]
totalrequiredintegerAll codes in the workspace, not just this page.

CodeStats

CodeStats fields
FieldTypeDescription
short_coderequiredstringThe code the stats are for.
fromrequiredstring (date)First day counted (SAST). Later than the from you sent if the plan doesn't keep scans that old.
torequiredstring (date)Last day counted (SAST), included. Never after today.
timezonerequired"Africa/Johannesburg"The time zone the days are counted in.
window_monthsrequiredinteger | nullHow many months of scans the plan keeps. null is unlimited.
scansrequiredintegerScans in the range, bots included.
unique_visitorsrequiredintegerDistinct visitors in the range, bots left out. A visitor is an anonymous hash, never returned.
bot_scansrequiredintegerScans in the range that came from bots. Already counted in scans.
scans_lifetimerequiredintegerEvery scan the code has ever had, bots included. Ignores the range and the plan's window.
last_scanned_atrequiredstring (date-time) | nullThe code's most recent scan, whatever the range. null if it has never been scanned.
dailyrequiredStatsDaily[]One row per day from from to to, oldest first. Days without scans are included with zeros.
countriesrequiredStatsBreakdownItem[]Top 20 countries by scans, as two-letter ISO codes such as ZA. Bots and scans with no known country are left out. Countries come from the DB-IP IP to Country Lite database (CC BY 4.0), so wherever you show them, credit it with the link "IP Geolocation by DB-IP" pointing to db-ip.com.
devicesrequiredStatsBreakdownItem[]Scans by device type (mobile, tablet, desktop, bot, unknown). The only breakdown that includes bots.
browsersrequiredStatsBreakdownItem[]Top 20 browsers by scans, bots left out. Unknown when the browser couldn't be told.
operating_systemsrequiredStatsBreakdownItem[]Top 20 operating systems by scans, bots left out. Unknown when the system couldn't be told.
referrersrequiredStatsBreakdownItem[]Top 20 referring hosts by scans, bots left out. Direct when the scan had no referrer, which is usual for a camera scan.
utm_sourcesrequiredStatsBreakdownItem[]Top 20 utm_source values on the scan URL by scans, bots left out. Scans without one are left out.

StatsDaily

StatsDaily fields
FieldTypeDescription
dayrequiredstring (date)A calendar day in SAST.
scansrequiredintegerScans that day, bots included.
unique_visitorsrequiredintegerDistinct visitors that day, bots left out.

StatsBreakdownItem

StatsBreakdownItem fields
FieldTypeDescription
keyrequiredstringThe value being counted, for example ZA, mobile or Chrome.
scansrequiredintegerScans with this value in the range. Lists are sorted by scans, highest first, then by key. An empty list is [].

Usage

Usage fields
FieldTypeDescription
audiencerequired"b2b" | "b2c"b2b for partner plans, b2c for Business and Enterprise.
api_accessrequiredboolean
plan_idrequiredstringPlan id, for example partner, scale, pro (Business) or enterprise.
display_namerequiredstring
clean_activerequiredintegerActive codes in the clean pool.
clean_limitrequiredinteger | nullClean active cap. null is unlimited.
ad_activerequiredintegerActive codes in the ad-supported pool.
ad_limitrequiredinteger | nullAd-supported active cap. null is unlimited.
library_usedrequiredintegerCodes stored in the workspace, active or not.
library_limitrequiredinteger | nullLibrary cap. null is unlimited.
redirect_unitsrequirednumberRedirect units used this month.
redirect_limitrequiredinteger | nullMonthly redirect units. null is unlimited.
over_quotarequiredbooleanTrue when redirect_units has reached redirect_limit. Creates are blocked; scans still redirect.

WebhookEvent

WebhookEvent fields
FieldTypeDescription
idrequiredstringUnique event id. The same on every retry and resend; use it to drop duplicates.
typerequired"code.created" | "code.updated" | "code.deleted" | "scan.created" | "webhook.test"What happened. Also in the VisionLink-Event-Type header.
created_atrequiredstring (date-time)When the event was recorded, UTC with milliseconds.
workspace_idrequiredstring (uuid)The workspace the event belongs to (the org_id of its codes).
datarequiredobjectThe event's payload; see each event.

WebhookCode

WebhookCode fields
FieldTypeDescription
idrequiredstring (uuid)
org_idrequiredstring (uuid)
short_coderequiredstringThe scan URL is https://vlcard.me/<short_code>.
namerequiredstring | null
destinationrequiredstring (uri)
statusrequired"active" | "paused" | "expired" | "deleted"
originrequired"api" | "standalone" | "once_off" | "visionlink_card" | "legacy"
expires_atrequiredstring (date-time) | null
has_adrequiredboolean
created_atrequiredstring (date-time)
updated_atrequiredstring (date-time)
deleted_atrequiredstring (date-time) | nullWhen the code was deleted; null unless status is deleted.
vanity_slugrequiredstring | nullThe code's custom link slug now, or null.
vanity_urlrequiredstring (uri) | null

CodeUpdatedData

CodeUpdatedData fields
FieldTypeDescription
coderequiredWebhookCodeA code as it is after the event. The same fields as QRCode, plus deleted_at, and status can be deleted.
changesrequired"destination" | "name" | "status" | "expires_at" | "vanity_slug"[]The fields that changed. A custom link change is always its own event.
previousrequiredobjectThe old value of each changed field, keyed like changes (expires_at, name and vanity_slug can be null).

WebhookScan

WebhookScan fields
FieldTypeDescription
idrequiredstring (uuid)The scan's id, unique; use it to drop duplicates if you store scans.
scanned_atrequiredstring (date-time)
coderequiredobject
countryrequiredstring | nullISO 3166-1 alpha-2 country, or null when unknown.
devicerequired"mobile" | "tablet" | "desktop" | "bot" | "unknown"
browserrequiredstring | null
osrequiredstring | null
referrer_hostrequiredstring | nullThe referring site's domain, or null for a direct scan.
utm_sourcerequiredstring | null
utm_mediumrequiredstring | null
utm_campaignrequiredstring | null
is_botrequiredbooleanThe scan looked like a bot or link preview.