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, and read its plan usage. Every code gets a public scan URL at the domain root: https://vlcard.me/<short_code>. Scans get a 302 to the code's current destination, so you can change where a printed QR goes without reprinting it.

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.
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"
    }
  ],
  "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"
}

GET/v1/codes/{code}

Get a code

A code deleted from the dashboard 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"
}

PATCH/v1/codes/{code}

Retarget a code

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.

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
destinationrequiredstring (uri)The new destination. Required on every update, even if only the name changes.
namestring | nullLeave the field out to keep the current name. Send a string to set it, or null or "" to remove 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"
}

DELETE/v1/codes/{code}

Delete a code

Permanently deletes the code and its scan analytics. The scan URL stops redirecting at once. 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"
}

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"
}

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"
}

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"How the code was created. api for this API; the others are dashboard origins.
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)

CodeList

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

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.