API & MCP reference
Links, folders, wallet, analytics and the whole GS1 surface are available over a REST API
and a native MCP server, metered in the same prepaid credits as the dashboard. Base URL: https://taproute.io
Authentication
Create API keys in Dashboard → API. Every request needs a bearer
header. Keys start with tr_ and are shown exactly once at creation.
A key is read only or read and write, and may carry an
expiry date · both are chosen when you create it and cannot be changed afterwards. A
read-only key can call every GET endpoint below and is refused with 403 on anything that writes · give your dashboards and exporters one
of those. An expired key is refused with 401, so you can rotate by
creating the next key and letting the old one lapse rather than cutting over in one go.
Authorization: Bearer tr_...
curl https://taproute.io/api/v1/links \ -H "Authorization: Bearer tr_your_key_here"
Endpoints
| Method | Path | Body | Returns |
|---|---|---|---|
| POST | /api/v1/links | {url, slug?, domain?, rules?, design?, label?, folder?, folderId?} | 201 · {id, slug, domain, shortUrl} |
| GET | /api/v1/links | ?cursor= &limit=1..100 &q= &status= &folder= &sort=newest|oldest|updated|mostScanned | {links: [...], nextCursor} |
| PATCH | /api/v1/links/:id | {url?, status?, rules?, design?, page?, label?, folderId?} | {id, slug, url, status} |
| DELETE | /api/v1/links/:id | None · published links archive, drafts delete | {ok: true, archived: bool, pageTitles: [...]} |
| POST | /api/v1/links/:id/restore | None · un-archives; 409 if the link is not archived | {id, slug, status} |
| GET | /api/v1/links/export | None · every code, archived and draft included | CSV |
| POST | /api/v1/links/batch | {links: [{url, slug?, …}]} · max 100 | {created: [{index, …}], errors: [{index, message}]} |
| GET | /api/v1/folders | None | {folders: [...], counts} |
| POST | /api/v1/folders | {name, parentId?} | 201 · {id, name, parentId} |
| PATCH | /api/v1/folders/:id | {name?, parentId?} | {id, name, parentId} |
| DELETE | /api/v1/folders/:id | None | {ok: true} |
| GET | /api/v1/wallet | None | {balance, inGrace, rateCard, …} |
| GET | /api/v1/analytics | ?days= | {total, uniques, bots, timeseries, byCountry, byDevice, byBrowser, byOs, byReferrer, byUtmSource, byCity, byHour, topLinks} |
| GET | /api/v1/gs1/items | ?q= &keyAi= &status= &page= | {items, total, page, pageSize} |
| POST | /api/v1/gs1/items | {keyValue, name, defaultUrl, keyAi?, brand?, sku?, attributes?, status?} | 201 · {id, claim, licence, charged} |
| GET | /api/v1/gs1/items/:id | None | {item, entries, licence} |
| PATCH | /api/v1/gs1/items/:id | {name?, attributes?, status?, entries?} · entries replaced wholesale, max 200 | {ok: true} |
| DELETE | /api/v1/gs1/items/:id | None | {ok: true} |
| GET | /api/v1/gs1/items/:id/carrier.svg | ?<AI>=value &attrs=1 &ecc= &scale= &compress=1 &domain= | SVG · X-Gs1-* headers carry the URI, element string and print size |
| POST | /api/v1/gs1/items/:id/serials | {count, lot?, domain?, jobId?} | CSV stream · X-Job-Id, X-Credits-Charged |
| GET | /api/v1/gs1/verify | ?serial= | {issued, jobId, keyAi, keyValue, index, issuedAt} | {issued: false, reason} |
| GET | /api/v1/gs1/licences | None | {licences, registryConfigured} |
| GET | /api/v1/gs1/export | ?links=1 | CSV |
| POST | /api/v1/gs1/import | {csv, dryRun?} · max 5000 rows | {created, updated, errors: [{row, message}], charged, wouldCharge} |
Links
curl -X POST https://taproute.io/api/v1/links \
-H "Authorization: Bearer tr_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/menu",
"slug": "summer-menu",
"rules": { "ios": "https://apps.apple.com/app/id123" }
}'
# 201 Created
# { "id": "…", "slug": "summer-menu", "domain": "_", "shortUrl": "https://tap2u.link/summer-menu" } # Change the destination after printing: the QR keeps working
curl -X PATCH https://taproute.io/api/v1/links/LINK_ID \
-H "Authorization: Bearer tr_..." \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/winter-menu", "status": "active" }' Custom domains
Pass domain to put a link on a hostname you have connected in Dashboard → Domains. Omit it and the link lives on the shared
short domain (https://tap2u.link). The shortUrl we return is the one to encode. A QR built against the
wrong host is printed and unfixable. How connecting works: Custom domains.
# Put the link on one of your own verified domains.
# Slugs are unique PER DOMAIN, so "summer-menu" can exist on both.
curl -X POST https://taproute.io/api/v1/links \
-H "Authorization: Bearer tr_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/menu",
"slug": "summer-menu",
"domain": "qr.yourbrand.com"
}'
# 201 Created
# { "id": "…", "slug": "summer-menu", "domain": "qr.yourbrand.com",
# "shortUrl": "https://qr.yourbrand.com/summer-menu" } Wallet
curl https://taproute.io/api/v1/wallet -H "Authorization: Bearer tr_..."
# { "balance": 48210, "inGrace": false, "graceExhausted": false, … } Analytics
# days: 7 | 30 | 90 | 180 | 365 | all (default 30). Over 90 days is served from the lifetime rollup.
curl "https://taproute.io/api/v1/analytics?days=30" -H "Authorization: Bearer tr_..."
# { "total": 1289, "uniques": 903, "bots": 44,
# "timeseries": [{ "date": "2026-07-01", "count": 42, "uniques": 31 }, …],
# "byCountry": […], "byDevice": […], "byBrowser": […], "byOs": […],
# "byReferrer": […], "byUtmSource": […], "byCity": […], "byHour": […],
# "topLinks": […] } The rules object
Optional per-link routing, evaluated at the edge on every scan. All fields are optional;
anything unmatched falls through to the link's main url. The same
logic, drawn instead of written: Routing. A link that routes
with a flow refuses a raw rules object with 400 (gate rules still apply).
{
"ios": "https://apps.apple.com/app/id123456",
"android": "https://play.google.com/store/apps/details?id=com.example",
// First matching window wins. Absolute (ISO start/end) …
"schedule": [
{ "start": "2026-07-01T08:00:00Z", "end": "2026-07-01T11:00:00Z", "url": "https://example.com/breakfast" },
// … or recurring: days 0=Sun..6=Sat, 'HH:MM' in "tz" (default: the scanner's timezone)
{ "days": [1, 2, 3, 4, 5], "from": "09:00", "to": "17:00", "tz": "Europe/Berlin", "url": "https://example.com/open" }
],
// Country code (from the edge) → destination
"geo": { "DE": "https://example.com/de", "FR": "https://example.com/fr" },
// Accept-Language prefix → destination
"lang": { "he": "https://example.com/he" },
// Hard scan cap — past it, the paused page is shown
"cap": 10000,
// SHA-256 hex of an access password; the edge shows a gate form
"passwordHash": "9f86d081…",
// Geofence gate — "allow": only these countries may open the link (an unknown
// country is refused); "block": these countries are refused. Blocked scanners
// 302 to "url" when set, else see a region notice. Runs before the password gate.
"geoGate": { "mode": "allow", "countries": ["US", "CA"], "url": "https://example.com/elsewhere" },
// Verified-brand preview interstitial (anti-quishing); auto = ms before auto-continue
"preview": { "brand": "Acme Coffee", "domain": "acme.com", "auto": 2000 },
// A/B split over the DEFAULT destination only: an explicit rule above wins outright.
// 'sticky' (default) keeps a visitor on one variant; 'random' rerolls every scan.
"split": {
"mode": "sticky",
"variants": [
{ "name": "a", "url": "https://example.com/a", "weight": 50 },
{ "name": "b", "url": "https://example.com/b", "weight": 50 }
]
},
// Hard expiry, checked before every other rule: after "at", 302 to "url"
// (or an expired notice when absent)
"expire": { "at": "2026-12-31T23:59:59Z", "url": "https://example.com/gone" }
} ios/android· device-specific destinations (App Store / Play Store deep links).schedule· array of time windows with ISO 8601 datetimes.startandendare each optional (open-ended windows). The first matching window wins.- Each rule you add costs 1 credit, once, on top of the 1 credit the code itself costs. Removing a rule is free and is not refunded.
GS1 Digital Link
Register a GS1 identifier as a resolvable Digital Link, attach a linkset to it, print its carrier, and issue per-unit serials. Same bearer key, same credits. The concepts behind these endpoints · keys, licences, qualifiers, the resolver · have their own documentation; what the resolver conforms to and what it does not is on the GS1 overview.
- A catalogue item costs 200 credits, once. It is the product master every serial beneath it inherits, and it is the one exception to the 1-credit code rule.
- A serial costs 1 credit, like any other dynamic code. Re-deriving a batch you already issued is free.
- Editing an item, its linkset and its destinations is free: re-pointing a printed code is the whole product.
- You must register the GS1 Company Prefix you licensed before you can publish a key under it. That check runs on every origin, every time.
# Register a GTIN as a resolvable Digital Link. Costs 200 credits, once.
curl -X POST https://taproute.io/api/v1/gs1/items \
-H "Authorization: Bearer tr_..." \
-H "Content-Type: application/json" \
-d '{
"keyValue": "09506000134352",
"name": "Single Origin Coffee 250g",
"defaultUrl": "https://example.com/coffee",
"attributes": { "17": "261231", "3103": "000250" }
}'
# 201 Created
# { "id": "…", "charged": 200,
# "claim": { "claimed": true },
# "licence": { "licensed": true, "status": "attested" } } Serials
# Issue serialised codes (AI 21). 1 credit each: every serial is a dynamic code.
# Returns a CSV stream: serial,url,compressedUrl,elementString
curl -X POST https://taproute.io/api/v1/gs1/items/ITEM_ID/serials \
-H "Authorization: Bearer tr_..." \
-H "Content-Type: application/json" \
-d '{ "count": 10000, "lot": "LOT4471" }'
# The X-Job-Id header IS the batch. Keep it: re-deriving the identical serials
# later is free, so a lost export never means re-buying the codes.
curl -X POST https://taproute.io/api/v1/gs1/items/ITEM_ID/serials \
-H "Authorization: Bearer tr_..." \
-d '{ "count": 10000, "jobId": "7f2a1b3c-…" }' # 0 credits Verify
# "Did we issue this?" · recomputed from the batch seed, not looked up.
curl "https://taproute.io/api/v1/gs1/verify?serial=7F2A00001AK3VP7QZM" \
-H "Authorization: Bearer tr_..."
# { "issued": true, "jobId": "7f2a1b3c-…", "keyAi": "01",
# "keyValue": "09506000134352", "index": 42, "issuedAt": "2026-07-16T09:12:44.000Z" }
# A serial we never issued, or one edited by hand, fails the HMAC:
# { "issued": false, "reason": "no batch of this workspace issued that serial" } Webhooks
Add endpoints in Dashboard → API to receive a POST for every scan, delivered from the edge in real time. Each request carries two headers:
| X-Taproute-Event | Event name, currently scan |
| X-Taproute-Signature | t=<ms>,v1=<hex hmac-sha256(secret, t + "." + body)> |
Payload
{
"event": "scan",
"data": {
"linkId": "9f1c…",
"slug": "summer-menu",
"domain": "_",
"country": "DE",
"device": "ios",
"ts": 1751884800000
}
} Verifying signatures (Node)
import { createHmac, timingSafeEqual } from 'node:crypto';
// signature header: t=<ms>,v1=<hex>
export function verifyTaproute(secret, signature, rawBody) {
const parts = Object.fromEntries(signature.split(',').map((p) => p.split('=')));
const expected = createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
return timingSafeEqual(Buffer.from(parts.v1, 'hex'), Buffer.from(expected, 'hex'));
} MCP server
A native Model Context Protocol server (Streamable HTTP) lets AI agents (Claude, Cursor, and friends) manage your QR codes directly. Authenticate with the same API keys:
{
"mcpServers": {
"taproute": {
"url": "https://taproute.io/mcp",
"headers": { "Authorization": "Bearer tr_..." }
}
}
} The table below renders the live tool manifest · the exact names and descriptions an agent is given, not a copy that can drift. Three GS1 surfaces are deliberately withheld from MCP: deleting an item (releases its shared-host key claim, which is irreversible), adding a licence (a legal attestation that must record a human user), and CSV import/export (transport size · use the REST routes). MCP also caps a serial run at 10,000 where the API allows 100,000.
| create_link | Create a dynamic QR link. Returns the short URL to encode in a QR code. Costs 1 credit to create, plus 1 credit for each routing rule in `rules`; each scan then consumes 1 credit. Fails with "Not enough credits" if the wallet is short. Call wallet_balance for the current balance and rate card. |
| create_links_batch | Create up to 100 dynamic QR links in one call. Rows are independent: the result lists created links and per-row errors by index. Each created link costs 1 credit, plus 1 per routing rule, charged per row — a row that fails is not charged. Use for campaigns, imports and other mass creation. |
| list_links | List dynamic QR links, newest first, paginated. Filter with folder (folder id or "unfiled"), q (matches label/slug/url) and status. Pass the returned nextCursor as cursor for the next page. |
| create_folder | Create a folder for organizing links. Top-level folders act as projects; pass parentId to nest one level under a top-level folder (max depth 2). Free. |
| list_folders | The workspace folder tree with per-folder link counts, plus unfiled/total counts. |
| move_link | Move one or more links into a folder (folderId: null files them as Unfiled). |
| update_link | Update a link: destination url, routing rules, status (active|paused), label, folder (folderId: null = Unfiled), or QR design. Adding a routing rule costs 1 credit per rule added; removing one is free and is not refunded. NOTE: a link that routes with a flow (a routing graph built in the dashboard) accepts only gate rules — passwordHash, geoGate, cap, expire, preview. Sending ios/android/schedule/geo/lang/split to a flow link fails; those belong in the flow builder, which is dashboard-only. |
| delete_link | Remove a link. A published link is ARCHIVED: it stops resolving, keeps its address forever, shows an "archived" notice to anyone scanning it, and can be restored. A draft (never published) is deleted outright and its slug is freed. Returns {archived} saying which happened. |
| get_qr | Get the QR code for one of your links as an SVG string. The QR encodes the link's own domain. Errors if the slug is not a link in this workspace. Never returns a QR for a link that does not exist. |
| wallet_balance | Get the workspace credit balance, grace-period status, and rate card. 1 credit = 1 scan, always. The returned rateCard gives the current credit cost of every metered action for THIS wallet: linkCreate, customRule, customCode, gs1Item, aiArt, chatMessage, smartPage, smartScan and domainSlotYearly. Check it before planning anything that creates in bulk. |
| analytics_summary | Scan analytics: total, time series, by country, by device, top links. |
| gs1_create_item | Register a GS1 identifier (a GTIN by default) as a resolvable catalogue item, with its product master data and a default destination. COSTS 200 CREDITS, once, per item — a catalogue item is the product master every serial beneath it inherits, and is the one exception to the 1-credit rule. Serials are 1 credit each (see gs1_issue_serials). Call wallet_balance first: rateCard.gs1Item is the live price for this wallet. Fails with 402 if the wallet is short, and 409 if you already registered this identifier. |
| gs1_list_items | List GS1 catalogue items, newest first. Filter with q (key/name/brand/sku), keyAi and status. |
| gs1_get_item | One catalogue item: its master data, every linkset entry registered against it, and how the identifier is licensed (verified | attested | unlicensed). |
| gs1_update_item | Update a catalogue item: master data, status, and/or its linkset. Free — the item was paid for at creation. This is where the GS1 value actually lives: attach a Digital Product Passport (gs1:dpp), a recall notice, instructions, a certificate, per lot or per serial. `entries` REPLACES the whole linkset, so send the full set; call gs1_link_types for the vocabulary and gs1_get_item for what is there now. status:"archived" is the safe withdraw: it stops resolving but keeps your claim on the identifier, and is reversible. |
| gs1_carrier | The printable GS1 Digital Link QR for an item, as an SVG, plus the exact URI it encodes, its GS1 element string, its module count and its physical print width in mm at the target X-dimension. Pass qualifiers to encode a lot or a serial. Free. |
| gs1_issue_serials | Issue serialized codes under a catalogue item (AI 21), each a unique resolvable Digital Link. COSTS 1 CREDIT PER SERIAL AND IS NOT REFUNDABLE once issued — 10,000 serials costs 10,000 credits. Confirm the count with the human before calling. Returns the job id, the cost, and a 5-row sample; it does NOT return the full list, which is a file, not a conversation: fetch it from the returned exportUrl, or re-derive it free with the same jobId. Runs above 10,000 are dashboard/API only. |
| gs1_verify_serial | Was this serial ever issued by this workspace? Answered by recomputing its HMAC, not by a lookup, so a serial that was never issued (or was edited by hand) fails. Returns the batch that issued it. Free. A false does not by itself prove a fake, but it is the first thing worth looking at. |
| gs1_link_types | The GS1 Web vocabulary link types you can attach to an item with gs1_update_item, with their meanings (gs1:pip = product information page, gs1:dpp = Digital Product Passport, gs1:recallStatus, gs1:instructions…). Call this before writing a linkset rather than guessing a CURIE. Free, no side effects. |
| gs1_licences | The GS1 Company Prefixes this workspace has registered, and whether each is verified (confirmed against the GS1 registry) or attested (declared by the user). A key outside every registered prefix cannot claim the shared host — if gs1_create_item returns claim.reason "unlicensed", this is why. Adding a licence is a legal attestation and must be done by a human in the dashboard at /app/gs1/licences. |
Errors
Standard HTTP status codes with a JSON body of the shape { "message": "…" }.
| 400 | Malformed input: missing url, invalid slug, bad JSON. Also a slug that is ambiguous across two of your domains, and a routing rule sent to a link that routes with a flow |
| 401 | Missing or invalid API key |
| 402 | Not enough credits. Creating a code costs 1 credit, plus 1 per routing rule; top up to continue |
| 404 | Link not found (or belongs to another workspace) |
| 409 | Slug already taken on this domain. The same slug can exist on another of your domains |
| 422 | Destination rejected by safety screening (malware / phishing lists) |
| 429 | Link-creation rate limit reached. The hourly limit scales with the credit packs you have bought |