Developer

Evertag REST API

A thin, authenticated shell over the same repos the dashboard uses. Every request needs a bearer API key; every response — one deliberate exception — is the same { success, data, error } envelope. Webhooks (scan-threshold notifications) are not available yet.

Base URL: https://evertag.app/api/v1

01

Authentication

Every /api/v1 request carries an Authorization: Bearer <secret> header. Keys are minted from your organization's API keys page (dashboard-only — there is no key-creation endpoint).

Authorization: Bearer evtg_8fH2kQmz9pR...
  • Keys start with the evtg_ prefix and are 45+ characters of random, high-entropy text.
  • The full secret is shown exactly once, at creation time. Evertag stores only a SHA-256 hash and a short display prefix — if you lose it, revoke it and create a new one.
  • Each key carries one of two scopes: read (list/get/render/stats/scan summaries) or read,write (also create/update/delete). Write endpoints reject a read-only key with 403 insufficient_scope.
  • Keys are revocable at any time from the dashboard; a revoked key is rejected immediately.

02

Quotas & rate limits

The only guaranteed ceiling is the monthly quota below — it is enforced atomically in Postgres and cannot be exceeded. Everything else on this page under "burst limits" is advisory, best-effort protection against runaway clients, not a contract: it lives in a KV counter that is deliberately swallowed on any infrastructure hiccup, so it can under-enforce but will never wrongly block a request that should succeed.

Plan quotas — dynamic codes, seats, organizations, co-owners and API calls per month, read from the TIERS constant
PlanPriceDynamic codesSeatsOrganizationsCo-ownersAPI calls / month
FreeFree511No100
Starter$9/mo5033Yes1,000
Growth$19/mo2501010Yes10,000
Business$39/mo1,0002525Yes50,000

Co-owners — granting the owner role to another member, whether by promoting them or by inviting them at that role, requires a paid plan. It applies to new grants only: existing owners are never removed when a plan ends or is downgraded, and the organization keeps every owner it already has.

An owner invitation is checked against the plan twice — when it is sent, and again when it is accepted. If the organization no longer has an active plan by the time the invitee clicks the link, accepting is refused. Nothing is lost: the invitation stays pending, and the same link works as soon as the plan is active again. Invitations at every other role are unaffected.

Once the monthly quota is reached, every call returns 429 quota_exceeded with a retry-after header (seconds until the UTC month rolls over) until it resets.

Burst limits (advisory)

  • Per organization: ~60 calls/minute. The 61st call inside a wall-clock minute returns 429 rate_limited.
  • Per IP, pre-authentication: ~120 calls/minute, checked before your key is even verified — this protects against a flood of garbage keys, not against a legitimate integration.
  • If the rate-limit store is unavailable, burst checks are skipped entirely rather than blocking traffic — only the monthly quota above is ever a hard stop.

03

Response envelope

Every JSON response — success or failure — shares one shape. The single exception is qr.svg, which returns a raw image on success (see below).

// success
{
  "success": true,
  "data": {
        …
        },
  "error": null
}

// failure
{
  "success": false,
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "human-readable detail"
  }
}

04

Endpoints

File codes (type: 'file') require a multipart upload and can only be created from the dashboard — POST /codes rejects them with 400 invalid_request.

GET /api/v1/codes

List every non-archived code owned by the key's organization. Read scope suffices.

Optional ?externalRef= narrows the list to the code carrying that reconciliation id (see externalRef under Create) — since the ref is unique per organization the result is an array of 0 or 1 codes. No match returns an empty list, not a 404. The value is trimmed before matching, exactly as it is on write; a blank ?externalRef= is a 400 invalid_request, never an unfiltered list.

curl https://evertag.app/api/v1/codes \
  -H "Authorization: Bearer evtg_..."

# reconciliation lookup by your own id
curl "https://evertag.app/api/v1/codes?externalRef=proj-42" \
  -H "Authorization: Bearer evtg_..."
{
  "success": true,
  "data": [
    {
      "id": "b7e6b6b0-2a2f-4a2b-9c3d-8f0a2c1e9a11",
      "name": "Table tent",
      "slug": "8f3k2a",
      "type": "url",
      "state": "active",
      "destination": "https://example.com/menu",
      "config": { "destination": "https://example.com/menu" },
      "shortUrl": "https://evertag.app/s/8f3k2a",
      "externalRef": null,
      "createdAt": "2026-07-01T12:00:00.000Z",
      "updatedAt": "2026-07-01T12:00:00.000Z"
    }
  ],
  "error": null
}
POST /api/v1/codes

Write scope required. Creates a code and counts against the plan's dynamic-code limit.

FieldTypeNotes
namestringrequired, non-empty
typestringone of url, vcard, wifi, linkpage, menu, appstore (not file) — see Code types below
configobjectshape depends on type, validated server-side
externalRefstringoptional — your own reconciliation id (e.g. an internal project id), 1–255 chars, trimmed, unique per organization among non-archived codes. A duplicate returns 409 external_ref_conflict. Archiving a code releases its ref for reuse while the archived code keeps the value, so a ref can never be held hostage by a code you can no longer list.
curl -X POST https://evertag.app/api/v1/codes \
  -H "Authorization: Bearer evtg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "type": "url",
    "name": "Table tent",
    "config": { "destination": "https://example.com/menu" }
  }'
// 201 Created
{
  "success": true,
  "data": {
    "id": "b7e6b6b0-2a2f-4a2b-9c3d-8f0a2c1e9a11",
    "name": "Table tent",
    "slug": "8f3k2a",
    "type": "url",
    "state": "active",
    "destination": "https://example.com/menu",
    "config": { "destination": "https://example.com/menu" },
    "shortUrl": "https://evertag.app/s/8f3k2a",
    "externalRef": null,
    "createdAt": "2026-07-01T12:00:00.000Z",
    "updatedAt": "2026-07-01T12:00:00.000Z"
  },
  "error": null
}
GET /api/v1/codes/:id

Read a single code. Read scope suffices. A foreign or unknown id returns 404 not_found.

curl https://evertag.app/api/v1/codes/b7e6b6b0-2a2f-4a2b-9c3d-8f0a2c1e9a11 \
  -H "Authorization: Bearer evtg_..."
{
  "success": true,
  "data": {
    "id": "b7e6b6b0-2a2f-4a2b-9c3d-8f0a2c1e9a11",
    "name": "Table tent",
    "slug": "8f3k2a",
    "type": "url",
    "state": "active",
    "destination": "https://example.com/menu",
    "config": { "destination": "https://example.com/menu" },
    "shortUrl": "https://evertag.app/s/8f3k2a",
    "externalRef": null,
    "createdAt": "2026-07-01T12:00:00.000Z",
    "updatedAt": "2026-07-01T12:00:00.000Z"
  },
  "error": null
}
PATCH /api/v1/codes/:id

Write scope required. Send exactly one of config, state, or externalRef.

FieldTypeNotes
configobjectrevalidated against the code's (immutable) type
statestring'active' or 'paused' only — 'redirect_only' is a system-managed state and is rejected with 400 invalid_request if requested explicitly
externalRefstring | nullset (1–255 chars, unique per organization) or null to clear. A duplicate returns 409 external_ref_conflict.
curl -X PATCH https://evertag.app/api/v1/codes/b7e6b6b0-2a2f-4a2b-9c3d-8f0a2c1e9a11 \
  -H "Authorization: Bearer evtg_..." \
  -H "Content-Type: application/json" \
  -d '{ "state": "paused" }'
{
  "success": true,
  "data": {
    "id": "b7e6b6b0-2a2f-4a2b-9c3d-8f0a2c1e9a11",
    "name": "Table tent",
    "slug": "8f3k2a",
    "type": "url",
    "state": "paused",
    "destination": "https://example.com/menu",
    "config": { "destination": "https://example.com/menu" },
    "shortUrl": "https://evertag.app/s/8f3k2a",
    "externalRef": null,
    "createdAt": "2026-07-01T12:00:00.000Z",
    "updatedAt": "2026-07-01T12:05:00.000Z"
  },
  "error": null
}
DELETE /api/v1/codes/:id

Write scope required. Archives the code — a terminal state. Archiving removes it from the edge KV lookup, so the short URL stops resolving; there is no API to reverse it.

curl -X DELETE https://evertag.app/api/v1/codes/b7e6b6b0-2a2f-4a2b-9c3d-8f0a2c1e9a11 \
  -H "Authorization: Bearer evtg_..."
{
  "success": true,
  "data": {
    "id": "b7e6b6b0-2a2f-4a2b-9c3d-8f0a2c1e9a11",
    "name": "Table tent",
    "slug": "8f3k2a",
    "type": "url",
    "state": "archived",
    "destination": "https://example.com/menu",
    "config": { "destination": "https://example.com/menu" },
    "shortUrl": "https://evertag.app/s/8f3k2a",
    "externalRef": null,
    "createdAt": "2026-07-01T12:00:00.000Z",
    "updatedAt": "2026-07-01T12:10:00.000Z"
  },
  "error": null
}
GET /api/v1/codes/:id/qr.svg

Render the code's QR as SVG. Read scope suffices. Optional ?size= in pixels, clamped to 64–2048 (default 256).

The render reflects whatever design — colors, module/eye shape, center logo — is currently saved for the code in the dashboard's Design panel; codes with no saved design render with the classic black-on-white defaults. Mutating a code's design via the API is on the roadmap and not yet supported — for now, set it from the dashboard.

Not <img>-embeddable. This endpoint requires the same Authorization bearer header as every other call — browsers do not attach custom headers to an <img src> request, so hotlinking this URL directly in HTML will fail with 401 unauthorized. Fetch it server-side (or with curl) and serve or embed the bytes yourself.

curl -o qr.svg "https://evertag.app/api/v1/codes/b7e6b6b0-2a2f-4a2b-9c3d-8f0a2c1e9a11/qr.svg?size=512" \
  -H "Authorization: Bearer evtg_..."

Exception to the envelope: on success this returns the raw SVG body with content-type: image/svg+xml — not the JSON envelope. Errors (e.g. unknown or foreign id) still return the standard 404 not_found JSON envelope.

<!-- 200 OK, content-type: image/svg+xml, cache-control: private, max-age=300 -->
<svg width="512" height="512" viewBox="0 0 512 512" ...>...</svg>
GET /api/v1/codes/:id/stats

Lifetime scan totals plus a daily series. Read scope suffices. Optional ?days=, clamped to 1–90 (default 14).

curl "https://evertag.app/api/v1/codes/b7e6b6b0-2a2f-4a2b-9c3d-8f0a2c1e9a11/stats?days=7" \
  -H "Authorization: Bearer evtg_..."
{
  "success": true,
  "data": {
    "scans": 142,
    "uniques": 98,
    "series": [
      { "date": "2026-06-25", "scans": 12, "uniques": 9 },
      { "date": "2026-06-26", "scans": 20, "uniques": 15 }
    ]
  },
  "error": null
}
GET /api/v1/codes/:id/scans/summary

Bucketed scan counts for one code, aggregated from hourly rollups. Read scope suffices. Counts only — individual scan events (and their per-visit attributes) are never exposed through the API.

Query paramDefaultNotes
bucketdayday or hour
fromto − 30 daysISO 8601 date or datetime, inclusive
tonowISO 8601 date or datetime, exclusive. Window is capped at 366 days and must be after from.

Buckets are sparse: hours or days with zero scans are omitted, so the payload stays proportional to actual activity. start is YYYY-MM-DD for day buckets and a UTC hour (YYYY-MM-DDTHH:00:00Z) for hour buckets; totals sums the returned window. An unknown or foreign id returns 404 not_found.

curl "https://evertag.app/api/v1/codes/b7e6b6b0-2a2f-4a2b-9c3d-8f0a2c1e9a11/scans/summary?from=2026-07-01&to=2026-07-20&bucket=day" \
  -H "Authorization: Bearer evtg_..."
{
  "success": true,
  "data": {
    "bucket": "day",
    "from": "2026-07-01T00:00:00.000Z",
    "to": "2026-07-20T00:00:00.000Z",
    "totals": { "scans": 15, "uniques": 7 },
    "buckets": [
      { "start": "2026-07-10", "scans": 8, "uniques": 6 },
      { "start": "2026-07-11", "scans": 7, "uniques": 1 }
    ]
  },
  "error": null
}

05

Error codes

HTTP statuserror.codeMeaning
401unauthorizedMissing, malformed, unknown, or revoked API key.
403insufficient_scopeA read-only key attempted a write endpoint.
429rate_limitedAdvisory per-minute burst limit hit (org or pre-auth IP). Retry after the retry-after header.
429quota_exceededMonthly API call quota for the plan reached — the one hard ceiling. Retry after retry-after.
402code_quota_exceededThe org's dynamic-code limit for its plan is full; create fails until a code is archived or the plan is upgraded.
400invalid_requestMalformed JSON, missing/invalid fields, an unknown code type, a file create attempt, or an illegal state value.
404not_foundThe code doesn't exist, or belongs to another organization.
409external_ref_conflictAnother non-archived code in the organization already carries that externalRef. It is always a code GET ?externalRef= can show you.
500internal_errorUnhandled server error. The real cause is logged server-side and never echoed to the caller.

06

Code types

Six of the seven code types can be created through the API. file codes need a multipart R2 upload and are dashboard-only.

TypeConfig fields
urldestination required — an http(s) URL
vcardfullName required; optional title, organization, phone, email, url
wifissid required; security required — one of WPA, WEP, nopass; password (required for WPA/WEP, 8–63 chars; omitted for nopass)
linkpagetitle required; links required — array of 1–20 { title, url } entries
menurestaurant required; sections required — array of 1–10 { name, items }, each with 1–50 { name, price } items
appstorefallback required — http(s) URL; at least one of ios or android (both optional http(s) URLs)
fileDashboard-only — not creatable via POST /codes.

redirect_only is a seventh, system-managed state (not a type) — Evertag flips a code into it automatically in specific fallback scenarios; it can never be set through the API.