Create your first short link with the CodeQR API
Create, read, update and list short links with the CodeQR REST API — real curl requests and responses, rate limits per plan, externalId and the SDK.

By the end of this guide you have created a short link from the command line, read it back, changed its destination, listed your links with pagination, and know the limits your plan applies. Every request below ran against a real workspace on 2026-08-17; only the domain was replaced by go.example.com.
Availability
- Plan: every plan. Requests per minute per key — Free 60, Starter 100, Pro 500, Business 1,000, Scale 10,000.
- Where: base URL
https://api.codeqr.io, key from Settings → API Keys. Endpoint reference on docs.codeqr.io; QR codes have their own guide, Create QR codes with the API, MCP, and automations.
Before you start
- An API key with Links → Write (or All Access). See Create an API key and choose its permissions.
- The key decides the workspace. There is no workspace ID to send;
projectSlugorprojectIdin the query string are ignored when you authenticate with a key. - Send JSON with
Content-Type: application/json. Responses are JSON; errors come as{ "error": { "code", "message", "doc_url" } }. - Updates use
PUT, notPATCH(PATCH /links/{id}answers405).
Steps
- Create a link.
domainandkeyare optional: without them the link goes on the workspace's default domain with a random 7-character key.
curl -X POST https://api.codeqr.io/links \
-H "Authorization: Bearer codeqr_••••••••••••••••••••••••" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/summer-sale?utm_source=newsletter&utm_medium=email&utm_campaign=summer","domain":"go.example.com","key":"docs-summer","comments":"Created from the help-center test"}'Response 200 (trimmed to the fields you will use; the full object is in the reference):
{
"id": "cmsxh367q0003wu8tzav2adsp",
"domain": "go.example.com",
"key": "docs-summer",
"url": "https://example.com/summer-sale?utm_source=newsletter&utm_medium=email&utm_campaign=summer",
"shortLink": "https://go.example.com/docs-summer",
"qrCode": "https://api.codeqr.io/qr?url=https://go.example.com/docs-summer?qr=1",
"archived": false,
"expiresAt": null,
"externalId": null,
"utm_source": "newsletter",
"utm_medium": "email",
"utm_campaign": "summer",
"tags": [],
"comments": "Created from the help-center test",
"clicks": 0,
"createdAt": "2026-08-17T16:53:20.822Z"
}Keep id — it is what every other call uses. utm_* are read from the destination URL; sending them as separate fields does nothing.
- Read it back by domain and key, or by id:
curl "https://api.codeqr.io/links/info?domain=go.example.com&key=docs-summer" \ -H "Authorization: Bearer codeqr_••••••••••••••••••••••••" curl https://api.codeqr.io/links/cmsxh367q0003wu8tzav2adsp \ -H "Authorization: Bearer codeqr_••••••••••••••••••••••••"
Both return the same link object; clicks grows as people open the short link.
- Change the destination with
PUTand only the fields you want to change:
curl -X PUT https://api.codeqr.io/links/cmsxh367q0003wu8tzav2adsp \
-H "Authorization: Bearer codeqr_••••••••••••••••••••••••" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/summer-sale-v2","comments":"updated via PUT"}'Response 200 with "url": "https://example.com/summer-sale-v2" and a new updatedAt. The short link https://go.example.com/docs-summer keeps working and now redirects to the new URL. If the new URL carries no UTM parameters, the previous utm_* values stay on the link until a URL with new ones is set.
- List and search.
pagestarts at 1;pageSizegoes up to 100 (default 100);sortByacceptscreatedAt,clicks,lastClicked.
curl "https://api.codeqr.io/links?search=docs-&page=1&pageSize=2&sortBy=createdAt" \ -H "Authorization: Bearer codeqr_••••••••••••••••••••••••"
Returns an array of link objects (each also carries user and tags). To count instead of list: GET /links/count (add tagIds=<id> to count one tag).
- Make creation idempotent with
externalId. Send your own record id on create; look it up later with theext_prefix; a second create with the sameexternalIdis refused instead of duplicated:
curl -X POST https://api.codeqr.io/links \
-H "Authorization: Bearer codeqr_••••••••••••••••••••••••" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/products/42","domain":"go.example.com","externalId":"row-42"}'
# → 200, "key": "3FSQv8g", "externalId": "row-42"
curl "https://api.codeqr.io/links/info?externalId=ext_row-42" \
-H "Authorization: Bearer codeqr_••••••••••••••••••••••••"
# → 200, the same link
# same externalId again → 409 {"error":{"code":"conflict","message":"A link with this externalId already exists."}}- Delete with
DELETE /links/{id}→200 {"id": "…"}. A deleted short link stops redirecting; its past clicks stay in the workspace totals.
Rate limits and headers
Every authenticated response carries x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset (a Unix timestamp in milliseconds). Above the limit the API answers 429 with Too many requests.; wait until the next minute. The retry-after header on these responses carries the same timestamp, not a number of seconds — do not sleep for its value.
Use the SDK instead of curl
The TypeScript SDK wraps the same endpoints:
import Codeqr from '@codeqr/ts';
const client = new Codeqr({ apiKey: process.env['CODEQR_API_KEY'] });
const link = await client.links.create({ url: 'https://example.com/summer-sale', domain: 'go.example.com' });
console.log(link.shortLink);There is no official Python, Go or PHP SDK; use the HTTP API directly from those languages.
Verify it works
- Open
https://go.example.com/docs-summer(your own short link) in a browser — it redirects to the destination you set. - Two to three minutes later,
GET /links/info?domain=…&key=…shows"clicks": 1, and the link appears in Links in the dashboard with the same count. - In Settings → API Keys, the key's Last used column shows a recent time.
Troubleshooting
PUT works but PATCH returns 405
Only PUT updates a link. Change the verb; the body is the same partial JSON.
tagNames returns 400 with a long database error
Sending tagNames on POST /links fails today even when the tag exists (the error text starts with Invalid prisma.link.create() invocation). Send tagIds instead — get the ids from GET /tags — or attach the tag afterwards with PUT /links/{id} and tagIds. tagNames with an unknown name answers Invalid tagNames detected: <name>.
GET /links/count?search=… returns 500
The count endpoint fails when combined with search. Count without search, or filter by tagIds, or list with pageSize and count on your side.
The response has no workspaceId and my code expected one
Links carry projectId — that is the workspace id. Keys are bound to one workspace, so you never send it.
I get 401 "Unauthorized: Login required." although I sent the key
The Authorization header did not reach the API — most often the header name was misspelled or a proxy stripped it. If you sent the header without the Bearer prefix, the answer is a plain-text Authorization header misconfigured. Did you forget to add 'Bearer '? instead. See Fix CodeQR API errors: 401, 403, 404, 409 and 429.