Overview
The KeptQR API lets your own software create and edit dynamic codes, manage folders, download QR images and read scan totals. Every request goes to one base URL:
https://api.keptqr.com/v1- Requests and responses are JSON over HTTPS. Field names are
snake_case. Timestamps are ISO 8601 in UTC; dates areYYYY-MM-DDUTC days. - A key belongs to a workspace, not to a person. Anything it does is recorded as the key: a code it creates shows “Created by API key” and the key’s name in the dashboard.
- The API is included on the Advanced, Professional and Business plans.
- Your codes keep redirecting whatever happens on the API side. An expired key, a reached limit or an error never stops a printed code from working.
API keys
Workspace Owners create keys in the dashboard, under Settings → API, with Create key. Each key has:
- a name, so you can tell your integrations apart;
- access: Read only, or Read and write (a write key can create, edit and delete codes and folders, as an Editor can in the dashboard);
- a mode: Live (
kq_live_…) or Test (kq_test_…, see test keys); - a lifetime: 30 days, 90 days or 1 year.
The key is shown once, when you create it. KeptQR stores only a hash, so a lost key can’t be recovered: revoke it and create another. A workspace can have up to 25 active keys.
Roll key replaces a key with a new one that has the same name, access and mode and a fresh lifetime. The old key keeps working for 7 more days (or until it expires, if that is sooner), so you can deploy the new one without downtime. Revoke stops a key at once, permanently.
Owners get an email 30 days before a key expires (keys with a longer lifetime than 30 days), 7 days before, and when it expires; a key that has never been used gets only the last. In a key’s last 30 days every response carries KeptQR-Key-Expires with the time it stops working.
Send the key in the Authorization header as a bearer token. Keys in the URL, in Basic auth or in any other header are ignored.
Keep keys on your server. Never put a key in a web page, a mobile app or a public repository. The API refuses any request that carries an Origin header (403 browser_not_allowed), so a key can’t be used from a browser. If a key leaks, revoke it.
Your first call
Put the key in an environment variable rather than typing it into commands. In a shell, read -rs takes it without echoing it or saving it in your history:
read -rs KEPTQR_API_KEY # paste the key, then press Enter
export KEPTQR_API_KEYThen ask the API who you are. GET /v1/me returns the key, its workspace and plan, the workspace’s limits and this month’s usage:
curl https://api.keptqr.com/v1/me \
-H "Authorization: Bearer $KEPTQR_API_KEY"{
"object": "key_context",
"key": {
"name": "Inventory sync",
"prefix": "kq_live_AbCd",
"scope": "write",
"mode": "live",
"expires_at": "2027-10-18T09:12:00.000Z"
},
"workspace": { "id": "5d0c…", "name": "Harbor Coffee" },
"plan": { "id": "advanced", "name": "Advanced" },
"limits": { … },
"usage": { "api_requests_this_month": 1, … }
}Next, list the five newest codes in the workspace:
curl "https://api.keptqr.com/v1/codes?limit=5" \
-H "Authorization: Bearer $KEPTQR_API_KEY"{
"object": "list",
"data": [
{
"object": "code",
"id": "8f3e…",
"name": "Spring flyer",
"kind": "dynamic",
"status": "active",
"short_url": "https://keptqr.to/k7m2xq9",
"destination": "https://example.com/spring",
…
}
],
"has_more": true,
"next_cursor": "eyJv…"
}Every operation, with its parameters and fields, is in the API reference.
Using an AI assistant. Claude, Claude Code and Cursor can call the API for you through KeptQR’s MCP server, with a sign-in or with your key. See the MCP server docs.
Test keys
A test key (kq_test_…) lets you build an integration without changing anything.
- Reads are real: a test key sees the workspace’s actual codes, folders and stats.
- Writes run every check a live write runs (validation, plan limits, a destination check) and then save nothing. The response is the object that would have been created or changed, with
"test": trueat the top level. An id from a test create doesn’t exist, so fetching it returns404. - Test keys share the workspace’s limits, and their requests count toward its monthly quota.
Test keys don’t run the real destination safety check. Two destinations stand in for its refusals, so you can test how your code handles them:
https://unsafe.example.test/is refused with422 destination_flagged;https://unverifiable.example.test/is refused with503 destination_unverifiableandRetry-After: 30.
Any other destination passes the check.
Errors and request ids
Successful calls answer 2xx. Anything else answers with the matching HTTP status and one error object:
{
"error": {
"type": "invalid_request",
"code": "invalid_parameter",
"message": "Enter a destination.",
"param": "destination",
"request_id": "req_01jabc…"
}
}typeis the broad class andcodethe exact reason. Branch oncode;messageis for people and may be reworded.param, when present, names the parameter or field at fault.- Every code, its type and its status is listed in the reference’s error table.
Every response, errors included, carries an X-Request-Id header (and an error repeats it as request_id). Log it, and quote it if you contact support: it finds the request in our logs. You may send your own X-Request-Id (up to 64 letters, digits, dots, dashes or underscores); we record it beside ours, never instead of it.
A 500, or a 503 of type api_error, is a failure on our side; its message is always the same and never includes details. A 503 destination_unverifiable means we couldn’t check the destination; retry after Retry-After.
PATCH is not transactional. If one field is refused, the fields listed in the error’s applied array were already saved.
Rate limits and quotas
Limits apply to the workspace as a whole, across all of its keys, test keys included. There are three:
- Requests per minute. Every call is one request; a bulk create is one request. You can use a full minute’s allowance at once, and it refills steadily.
- Writes per minute. Creating, editing or deleting a code or folder is one write, and each item in a bulk create is one write. The write allowance holds at least 100, so a full bulk create fits in one go, then refills at your plan’s rate.
- A monthly quota of requests, set by your plan. It resets at 00:00 UTC on the 1st of each month. Workspace Owners get an email at 80 % and at 100 %.
| Current limits, may change | |||
|---|---|---|---|
| Plan | Requests / month | Requests / min | Code writes / min |
| Advanced | 50,000 | 60 | 10 |
| Professional | 200,000 | 120 | 30 |
| Business | 500,000 | 300 | 60 |
Your workspace’s current values are under limits in GET /v1/me, with this month’s count under usage. The per-minute values are current limits and may change.
A request over a limit answers 429 with one of these codes, and nothing is done:
requests_per_minuteorwrites_per_minute: wait and retry;monthly_quota(typequota_exceeded): requests resume on the 1st, or sooner if the workspace’s limit is raised;too_many_failed_auth: after 30 requests with a missing or invalid key from one address in a minute, every request from that address is refused until the minute is up, a valid key included.
Every 429 carries Retry-After in seconds. It is authoritative: wait that long before retrying. Responses that pass the key and permission checks also carry RateLimit-Policy and RateLimit headers (IETF draft format) showing what remains. They are advisory, as the draft may still change.
RateLimit-Policy: "requests";q=60;w=60, "writes";q=10;w=60
RateLimit: "requests";r=57;t=0What counts toward the monthly quota: every request that passes authentication and the per-minute limits, whatever it then answers, except 5xx errors. 401, 403 and 429 answers never count, and neither does GET /v1/openapi.json.
Limits apply only to the API. Your codes keep redirecting.
Pagination
List operations return a page at a time, newest first. Ask for up to 100 items with limit (1 to 100, default 25):
{
"object": "list",
"data": [ … ],
"has_more": true,
"next_cursor": "eyJv…"
}When has_more is true, pass next_cursor back as cursor with the same filters to get the next page. On the last page next_cursor is null.
curl "https://api.keptqr.com/v1/codes?limit=100&cursor=eyJv…" \
-H "Authorization: Bearer $KEPTQR_API_KEY"Cursors are opaque: don’t build or edit them. A cursor works only with the filters it was issued for; changing the filters, or using it with another workspace’s key, answers 400 invalid_cursor.
Retries and idempotency
Networks fail mid-request. To retry a POST without creating something twice, send an Idempotency-Key header: any unique string of 1 to 255 printable ASCII characters, such as a UUID, one per operation.
curl https://api.keptqr.com/v1/codes \
-H "Authorization: Bearer $KEPTQR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7b1e4f0a-2c3d-4e5f-8a9b-0c1d2e3f4a5b" \
-d '{"name": "Spring flyer", "destination": "https://example.com/spring"}'- For 24 hours, a request with the same key and the same body returns the first response’s status and body again, with
Idempotent-Replayed: true. Nothing is created twice. A stored answer can be a4xx, such as a validation error, which replays too: once you correct a request, send it with a new key. - The same key with a different body or route is refused:
400 idempotency_key_reused. - While the first request is still running, a duplicate answers
409 idempotency_in_progress. Retry shortly. - A
5xxis never stored, so retrying it with the same key runs the request again. Refusals by authentication or a limit are not stored either.
PATCH and DELETE don’t need the header (it is ignored). Sending the same PATCH again sets the same values, and deleting a dynamic code that is already deleted answers 200 again, so both are safe to retry.
Bulk create
POST /v1/codes/batch creates up to 100 dynamic codes in one request. It needs a write key and a plan that includes bulk creation. Each item takes the same fields as POST /v1/codes:
{
"items": [
{ "name": "Table 1", "destination": "https://example.com/menu?t=1" },
{ "name": "Table 2", "destination": "https://example.com/menu?t=2" }
]
}The answer is 207 with one result per item, in the order sent. Each item succeeds or fails on its own:
{
"object": "batch_result",
"created": 1,
"failed": 1,
"results": [
{ "index": 0, "status": 201, "code": { "object": "code", … } },
{ "index": 1, "status": 409, "error": { "code": "plan_limit_reached", … } }
]
}- Items are created in order, so if the workspace runs out of codes, the earliest items win.
- The batch is one request and each item is one write. If there aren’t enough writes left for every item, the whole batch is refused with
429 writes_per_minuteand nothing is created. - Items not started within 55 seconds answer
503 timeout, and nothing is saved for them. - A problem with the whole request (authentication, plan, limits, a malformed body, more than 100 items) answers with a normal error instead of
207.
To finish a partly completed batch, resend only the failed items, with a new Idempotency-Key.
Versioning
This is version 1, and every path starts with /v1. Additive changes (a new operation, a new field in a response, a new optional parameter) ship without a new version, so write your client to ignore fields it doesn’t know.
A breaking change means a new version, /v2, and /v1 keeps working for at least 12 months after we announce it.
The machine-readable description of the API (OpenAPI 3.1) is public at https://api.keptqr.com/v1/openapi.json. It needs no key and doesn’t count toward your quota.