Reticle QR Code API
Create and manage dynamic QR code links straight from your own scripts: mint a link, list your links, read or update one, delete it, and pull its scan stats, all over plain HTTPS and JSON. The API is included with Reticle Pro at $7 a month or $60 a year, with no separate API tier and no metered add-on charge.
Authentication
Every request is authenticated with a bearer API key, minted from your account page. A key is shown to you exactly once at creation time; Reticle stores only a hash of it, never the key itself, so if you lose it there's nothing to recover, only a new key to mint. Each account can hold a small number of named keys at once (see the limits table below) and revoke any of them individually at any time; a revoked key stops working on its very next request.
Send the key as Authorization: Bearer rk_live_<your key>. The API is
server-to-server by design: it sends no CORS headers, so a browser page cannot call it
directly, which is deliberate, since a page that could would be shipping your bearer key to
anyone who opens dev tools. Call it from a server, a script, or a scheduled job instead.
Any request with a body must send valid JSON with Content-Type: application/json.
A malformed body (invalid JSON) is rejected with a plain 400 before it reaches the API's own
logic. The wrong content type still reaches the handler, which reports it with the same JSON
error shape used everywhere else, described below.
Quick start
Create a dynamic link that redirects to a destination URL:
curl -X POST https://reticle.cloud/api/v1/links \
-H "Authorization: Bearer rk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"destination": "https://example.com/menu", "name": "menu"}'
which returns:
{
"slug": "aB3xY9",
"shortUrl": "https://open-qr.link/aB3xY9",
"kind": "redirect",
"destination": "https://example.com/menu",
"name": "menu",
"active": true,
"labels": [],
"customSlug": null,
"linkDomain": null,
"createdAt": "2026-08-12T19:04:05.000Z",
"scanCount": 0,
"lastScanAt": null,
"reclaimed": false,
"reclaimedAt": null
}
That response shape, the "public link" shape, is what every endpoint below returns for a link:
a fixed, closed set of fields. Anything not in that list (routing rules, password hashes,
Safe Browsing verdicts, the owner id) is never returned by the API, no matter what's on the
underlying document. Timestamps are always ISO 8601 UTC strings, or null when
not yet set.
Endpoints
All six routes live under https://reticle.cloud/api/v1:
| Method + path | What it does |
|---|---|
POST /links | Create a new redirect link from { destination, name?, linkDomain? }. linkDomain (optional string): one of your active custom domains; omitted means your default domain. Send the bare hostname exactly as it appears on your Account page, with no scheme or path (for example go.acme.com). |
GET /links | List your links, ordered by slug, not by creation date. Paginate with ?limit= (default 100, max 500) and ?cursor=. |
GET /links/{slug} | Read one link you own. |
PATCH /links/{slug} | Update destination, name, and/or active. Any other field in the body is rejected. |
DELETE /links/{slug} | Delete a link and its scan history. |
GET /links/{slug}/stats | Aggregate scan stats: total count, last scan time, and per-day/device/hour/country breakdowns. |
Reading works for every kind of link: GET /links/{slug} and its
/stats return a link's public shape or its stats whether the link is a redirect,
a file link, a page, or a dynamic vCard. Writing is redirect-only: PATCH and
DELETE both return wrong_kind on any link that isn't a plain redirect.
A slug that doesn't exist, or belongs to a different account, always returns
not_found, never a permission error, so the API never confirms whether someone
else's link exists.
Errors
Every error response is JSON, in the same shape:
{ "error": { "code": "rate_limited", "message": "Too many requests. Please slow down and try again." } }
| Code | Status | Meaning |
|---|---|---|
unauthorized | 401 | The API key is missing, invalid, or revoked. |
plan_required | 403 | The account is not on an active Pro plan, or (on create) its email is not yet verified. |
not_found | 404 | No such link, or the slug belongs to a different account. |
method_not_allowed | 405 | That HTTP method isn't supported on this path. |
wrong_kind | 409 | This link isn't a redirect link, so it can't be patched or deleted through the API (reading it, and reading its stats, still work). |
invalid_request | 422 | The request body is missing a required field, has the wrong type, or includes an unknown field. |
link_limit_reached | 422 | The account has reached its dynamic link limit. |
unsafe_destination | 422 | The destination URL failed a safety check. |
domain_not_active | 422 | That domain isn't active on this account. |
rate_limited | 429 | Too many requests; the response includes a Retry-After header. |
internal | 500 | Something went wrong on our end. Please try again. |
Rate limits
Two ceilings apply, and they're separate budgets: an overall request ceiling that covers every
call you make, and a smaller creation budget that link creation shares with the ordinary
create-a-link flow in the app itself (so a script and a signed-in session drawing from the same
account draw from the same creation budget). Both fail open: if the rate limiter itself has
trouble, your request still goes through rather than getting refused by an outage on our side.
A request that's over a limit gets back 429 rate_limited with a
Retry-After header telling you how many seconds to wait.
| Limit | Amount |
|---|---|
| API keys per account | 5 |
| Requests per hour, all endpoints combined | 3600 |
| Requests per day, all endpoints combined | 20000 |
| Link creations per hour | 250 |
| Link creations per day | 400 |
Webhooks
A webhook sends every scan to a system of yours the moment it happens. Add up to three HTTPS endpoints on your account page and Reticle POSTs one JSON event per scan, on every dynamic link the account owns. Events usually arrive within a few seconds. The first event after adding or changing an endpoint can take up to a minute. Webhooks are part of Reticle Pro.
An event is sent for every scan that Reticle records in your per-scan analytics, and only those. That means dynamic links on a Pro account, with crawler and link-preview fetches excluded: those still count toward a link's scan total, but no person looked at the code, so no event is sent. If your account leaves Pro, per-scan records stop and so do webhooks.
The event
{
"id": "k3Yx9QpB2tLm7VcN4wRz",
"type": "scan",
"createdAt": "2026-08-29T17:03:12.412Z",
"link": { "slug": "ab3Kq9", "name": "Spring menu", "kind": "redirect", "url": "https://open-qr.link/ab3Kq9" },
"scan": {
"at": "2026-08-29T17:03:12.104Z",
"device": "mobile", "os": "iOS 17", "browser": "Safari",
"country": "US", "region": "California", "city": "Oakland", "lat": 37.8, "lon": -122.27,
"target": "destination", "referrer": "instagram.com", "returning": false
}
}
Every field is present on every event; a value we do not have is null. Delivery is
at least once, so treat a repeated id as the same scan. The event never includes
the scanner's IP address or user agent; Reticle does not keep them.
| Field | Type | What it holds |
|---|---|---|
id | string | The scan record's id, 20 characters. Use it as your deduplication key. |
type | string | scan for a real scan, or test for the sample event the Test button on your account page sends. |
createdAt | string | ISO 8601 UTC. When Reticle built the event. Compare it with scan.at to measure delivery lag. |
link.slug | string | The link's short code. |
link.name | string or null | The name you gave the link, or null if it has none. |
link.kind | string | One of redirect, vcard, file, bio. |
link.url | string | The link's public URL, on your own custom domain when it has one. |
scan.at | string | ISO 8601 UTC. When the scan was recorded. |
scan.device | string | One of mobile, tablet, desktop, other. |
scan.os | string or null | Operating system family with a major version where the device states one, for example iOS 17, Android 14, macOS, Windows, Linux. |
scan.browser | string or null | One of Safari, Chrome, Firefox, Edge, Samsung, Instagram, Facebook, TikTok, Snapchat, LinkedIn, WeChat, Other. |
scan.country | string or null | ISO 3166-1 alpha-2 country code, resolved at the CDN edge. null when the country could not be resolved. |
scan.region | string or null | State or region name. null when unknown. |
scan.city | string or null | City name. null when unknown. |
scan.lat | number or null | Approximate latitude from our geolocation provider, city level, never a device GPS reading. null when unknown. |
scan.lon | number or null | Approximate longitude, same source and precision as lat. null when unknown. |
scan.target | string | What was served. destination: the link's usual destination (also what a page or vCard link reports, since its hosted page is the destination). route: a smart-routing rule matched. ios: the iOS override destination. android: the Android override. file: a file link served its stored file. |
scan.referrer | string or null | The referring site's host only, never the full URL, for example instagram.com. null when the scan arrived with no referrer, which is the normal case for a camera scan. |
scan.returning | boolean | true when this visitor has scanned one of your links before. |
Headers and signature
Each request carries Reticle-Event-Id, Reticle-Event-Type, and
Reticle-Signature in the form t=<unix seconds>,v1=<hex>, where
v1 is HMAC-SHA256 over <t>.<raw body> using the endpoint's
signing secret. Verify it against the raw request bytes, compare in constant time, and reject
timestamps more than five minutes old:
const crypto = require("node:crypto");
function verify(secret, rawBody, header) {
const m = /^t=(\d+),v1=([0-9a-f]+)$/.exec(header || "");
if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(m[1] + "." + rawBody).digest("hex");
return expected.length === m[2].length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]));
}
Responding
Answer with any 2xx status within 3 seconds and do the real work afterwards. Redirects are not followed and count as failures. After 20 failed deliveries in a row the endpoint is paused and you get one email; fix the receiver and press Resume on your account page. Deliveries are not retried. An account can send up to 1000 scans per hour to its endpoints (up to 3000 deliveries across three endpoints).
Zapier and Make
In Zapier, start a Zap with Webhooks by Zapier, trigger Catch Hook, and paste the URL it gives you as a Reticle endpoint. In Make, add a Webhooks module, Custom webhook, and do the same. Both accept events without checking the signature, so verification there is optional.
Frequently asked questions
Is the API included in Pro?
Yes. The API is included with Reticle Pro at no extra cost, whether you're on the $7 a month or $60 a year plan. There is no separate API tier and no metered usage billing on top of the subscription.
Can I create file or bio links through the API?
No. Version 1 of the API creates and manages ordinary redirect links only. File QR codes, pages, and dynamic vCards stay UI-only for now; they may be added to a future API version.
What happens to my links if I stop using the API?
Nothing. Links and QR codes you created through the API keep working and keep resolving. If your account leaves Pro, a link that then records no scans for 90 days becomes eligible for inactivity reclaim like any free link, after email warnings. Printed codes are never tied to ongoing API use.
Is there a free API tier?
No. The QR code generator and static codes are free with no account required, but the API itself requires an active Reticle Pro plan; there is no free tier or trial for API access specifically.
Ready to script your codes? Upgrade to Reticle Pro, then open your account page to mint your first API key.