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| Plan | Price | Dynamic codes | Seats | Organizations | Co-owners | API calls / month |
|---|
| Free | Free | 5 | 1 | 1 | No | 100 |
| Starter | $9/mo | 50 | 3 | 3 | Yes | 1,000 |
| Growth | $19/mo | 250 | 10 | 10 | Yes | 10,000 |
| Business | $39/mo | 1,000 | 25 | 25 | Yes | 50,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.
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.
| Field | Type | Notes |
|---|
name | string | required, non-empty |
type | string | one of url, vcard, wifi, linkpage, menu, appstore (not file) — see Code types below |
config | object | shape depends on type, validated server-side |
externalRef | string | optional — 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.
| Field | Type | Notes |
|---|
config | object | revalidated against the code's (immutable) type |
state | string | 'active' or 'paused' only — 'redirect_only' is a system-managed state and is rejected with 400 invalid_request if requested explicitly |
externalRef | string | null | set (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 param | Default | Notes |
|---|
bucket | day | day or hour |
from | to − 30 days | ISO 8601 date or datetime, inclusive |
to | now | ISO 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
}