API & MCP reference
Everything the dashboard does is also an HTTPS call: links, folders, wallet, analytics and
the whole GS1 surface, metered in the same prepaid credits. If you can send a curl request,
you can run QR codes. Base URL: https://taproute.io/api/v1
A scan is not
a redirect
Every scan of a taproute code walks a chain of gates at the edge before anyone is sent anywhere. Each gate can stop the scan, and each answers with its own honest page. Only a scan that clears them all becomes a 302. The whole chain is a few keys on one JSON object · this page shows you all of them.
Quickstart three requests from zero to a printed, re-pointable code
- 1
Create a key
Dashboard → API → New key. Keys start with
tr_and are shown exactly once, so copy it when you see it. Choose read only for dashboards and exporters, read and write for anything that creates or edits. Both are fixed at creation and cannot be changed later. - 2
Create your first code · costs 1 credit
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" }' # 201 Created # { "id": "…", "slug": "summer-menu", "domain": "_", # "shortUrl": "https://tap2u.link/summer-menu" }The
shortUrlin the answer is your code's permanent address. Encode it with any QR generator, or ask us for the finished image:# The finished QR image, in this link's saved design: curl "https://taproute.io/api/v1/links/LINK_ID/qr?format=svg" \ -H "Authorization: Bearer tr_..." > code.svg
- 3
Re-point it whenever you like · free
# Change the destination after printing: the printed code 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" }'This is the whole product in one request: the printed sticker never changes, the destination does. Editing a destination, a design or a rule never breaks a code that is already on a poster.
How a scan resolves evaluation order is fixed and test-locked
links.rules · they answer the scanner, never routeAuthentication one header on every request
Create API keys in Dashboard → API. A key is read only or read and write, and may carry an expiry date · both are chosen at creation and
cannot be edited afterwards. A read-only key can call every GET below and
is refused with 403 on anything that writes. An expired key is refused
with 401, so you rotate by creating the next key and letting the old one
lapse rather than cutting over in one go.
curl https://taproute.io/api/v1/links \ -H "Authorization: Bearer tr_your_key_here"
Endpoints reads are free · writes are priced in credits, and each price is printed here
This table is generated from the routes that actually exist, and a test fails the build if they ever disagree. If a row is here, it works.
Links
| Method | Path | Body | Returns |
|---|---|---|---|
| POST | /links | {url | destination | kind+fields | template, slug?, domain?, rules?, design?, label?, title?, folder?, folderId?, draft?} · an experience template adds an unlock at first publish: 800 CREDITS, ONCE, then 2 CREDITS per scan | 201 · {id, publicId, type, slug, domain, shortUrl, url, destination, destinationScore, destinationScoreBand} |
| GET | /links | ?cursor= &limit=1..100 &q= &domain= &folder= &status=active|paused|blocked|archived &sort=newest|oldest|updated|mostScanned | {links: [{…, type, mode, url, destination}], nextCursor} |
| PATCH | /links/:id | {url? | destination? | kind?+fields?, status?, rules?, design?, page?, label?, folderId?} | {id, slug, url, destination, status} |
| DELETE | /links/:id | None · published links archive, drafts delete | {ok: true, archived: bool, pageTitles: [...]} |
| POST | /links/:id/restore | None · un-archives; 409 if the link is not archived | {id, slug, status} |
| GET | /links/export | None · every code, archived and draft included | CSV |
| POST | /links/batch | {links: [{url, slug?, …}]} · max 100 | {created: [{index, …}], errors: [{index, message}]} |
| GET | /links/:id/flow | None | {flow, converted, billingBaseline, gates, …} |
| PUT | /links/:id/flow | {flow} · max 50 nodes · 120 edges · 20 branches | {id, slug, flow, billedCredits} · 5 credits per condition CHANGED · a save that moves nothing is free |
| DELETE | /links/:id/flow | None · reverts to plain-url routing, gates survive | {id, slug, flow: null, billedCredits: 0} |
| GET | /links/:id/publish | None | {slug, url, design, warnings, experienceDue?: {unlock, scan}, …} · what is about to go live |
| POST | /links/:id/publish | {at?} · ISO instant schedules go-live; omit to release now · the first release of an experience page also charges its unlock: 800 CREDITS, ONCE | {id, slug, publishedAt, publishAt} |
| PATCH | /links/:id/publish | {at} · ISO instant or null to release immediately · 409 once it has passed | {id, publishAt} |
| GET | /links/:id/showcard.svg | ?card= &size= &headline= &subline= &caption= | SVG · the code on an opaque plate, so it decodes over any background |
| GET | /links/:id/qr | ?size= &scale= &format=svg | {id, slug, domain, shortUrl, qrUrl, svg} · ?format=svg returns the raw image |
Folders · wallet · analytics
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /folders | None | {folders: [...], counts} |
| POST | /folders | {name, parentId?} | 201 · {id, name, parentId} |
| PATCH | /folders/:id | {name?, parentId?} | {id, name, parentId} |
| DELETE | /folders/:id | None | {ok: true} |
| GET | /wallet | None | {balance, inGrace, rateCard, …} |
| GET | /analytics | ?days= | {total, uniques, bots, timeseries, byCountry, byDevice, byBrowser, byOs, byReferrer, byUtmSource, byCity, byHour, topLinks} |
Hosted pages
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /pages | None | {pages: [...]} |
| GET | /pages/templates | None | {categories, templates: [{id, name, category, blocks, tier, unlockCredits?, scanCredits?}], skins: [...], theme} · the create vocabulary |
| GET | /pages/blocks | None | {blocks: [{type, example}]} · every block type with a valid default |
| GET | /pages/:id | None | {id, title, template, linkId, blocks, theme, smartEnabled, …} |
| PATCH | /pages/:id | {title?, blocks?, theme?, chatEnabled?, seoEnabled?} · blocks REPLACE the array | {id, title, updatedAt} |
| DELETE | /pages/:id | None | {ok: true} |
| POST | /pages/:id/attach | {linkId} · null detaches · one page per link · attaching a gated experience page to a LIVE link charges its unlock: 800 CREDITS, ONCE | {id, linkId} |
| POST | /pages/:id/link | {slug?} · gives an orphan page an address | 201 · {id, slug, shortUrl, qrSvg} · costs 1 credit for the link |
| POST | /pages/:id/smart | None · one-way | {id, smartEnabled} · COSTS 200 CREDITS, ONCE, then 2 CREDITS PER SCAN thereafter |
| GET | /pages/:id/leads | None | {leads: [{id, data, createdAt}], limit} |
Static codes
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /static-codes | None | {codes: [...]} |
| POST | /static-codes | {payload | kind + fields, name?, type?, design?} · exactly one of payload or kind | 201 · {id, name, payload, design, …} |
| POST | /static-codes/batch | {codes: [{payload | kind + fields, name?, design?}]} · max 100, free | {created: [{index, id, publicId, …}], errors: [{index, message}]} |
| GET | /static-codes/:id | None | {id, name, payload, …} |
| PATCH | /static-codes/:id | {name?, payload?, design?} | {id, name, payload, …} |
| DELETE | /static-codes/:id | None | {ok: true} |
| POST | /static-codes/:id/tracking | None · needs an http(s) payload | {…, trackingEnabled: true} · COSTS 200 CREDITS, ONCE; beacons are then free |
| DELETE | /static-codes/:id/tracking | None · keeps the token, so re-enabling is not charged | {…, trackingEnabled: false} |
| GET | /static-codes/:id/stats | ?days= | {days, stats} · empty until tracking is enabled |
| GET | /static-codes/export | None | CSV |
GS1 Digital Link
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /gs1/items | ?q= &keyAi= &status= &page= | {items, total, page, pageSize} |
| POST | /gs1/items | {keyValue, name, defaultUrl, keyAi?, brand?, sku?, attributes?, status?} | 201 · {id, claim, licence, charged} |
| GET | /gs1/items/:id | None | {item, entries, licence} |
| PATCH | /gs1/items/:id | {name?, attributes?, status?, entries?} · entries replaced wholesale, max 200 | {ok: true} |
| DELETE | /gs1/items/:id | None | {ok: true} |
| GET | /gs1/items/:id/carrier.svg | ?<AI>=value &attrs=1 &symbology=qr|linear|dual &ink= &paper= &ecc= &scale= &compress=1 &domain= | SVG · X-Gs1-* headers carry the URI, element string, symbology and print geometry. symbology=linear picks the barcode for this key automatically (GS1-128, EAN-13, UPC-A, EAN-8 or ITF-14); dual prints both at GS1 point-of-sale placement |
| POST | /gs1/items/:id/serials | {count, lot?, domain?, jobId?} | CSV stream · X-Job-Id, X-Credits-Charged |
| GET | /gs1/verify | ?serial= | {issued, jobId, keyAi, keyValue, index, issuedAt} | {issued: false, reason} |
| GET | /gs1/items/:id/spec-sheet | same query surface as carrier.svg | {item, spec: {xDimensionMm, widthMm, quietZone, verified, omitted, …}} |
| GET | /gs1/licences | None | {licences, registryConfigured} |
| GET | /gs1/export | ?links=1 | CSV |
| POST | /gs1/import | {csv, dryRun?} · max 5000 rows | {created, updated, errors: [{row, message}], charged, wouldCharge} |
Integrations
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /me | None | {workspaceId, workspaceName} |
| GET | /scans | ?limit= | {scans: [{id, linkId, slug, domain, country, device, ts}]} · recent scans, for trigger samples |
| GET | /webhooks | None | {webhooks: [{id, url, events, filter, active}]} |
| POST | /webhooks | {targetUrl, event, filter?} · event ∈ scan | link.created | gs1.item.claimed | gs1.serial.verified; filter {linkId} | {folderId} | 201 · {id, secret} |
| DELETE | /webhooks/:id | None | {ok: true} |
More
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /flow-presets | None | {presets: [...]} |
| POST | /flow-presets | {name, ctype, params} | 201 · {id, name, ctype, params} |
| PATCH | /flow-presets/:id | {name?, params?} | {id, name, ctype, params} |
| DELETE | /flow-presets/:id | None | {ok: true} |
| GET | /resources | None | {resources: [...]} |
| POST | /resources | {url, name?} | 201 · {id, name, url, kind} |
| GET | /resources/:id | None | {id, name, url, kind, usedBy, usage: [...]} |
| PATCH | /resources/:id | {name?, url?} · a url change republishes every referencing link | {id, name, url} · 409 past MAX_SYNC_REPUBLISH usages, before any write |
| DELETE | /resources/:id | None · 409 while any flow references it | {ok: true} |
| GET | /resources/:id/usage | None | {usedBy, usage: [...]} |
| GET | /rescues | None | {rescues: [...]} |
| POST | /rescues | {printedUrl, linkId, method: cname|redirect|repoint|snippet, staticCodeId?} | 201 · {id, state, …} · COSTS 50 CREDITS, ONCE (buys the daily origin watch) |
| GET | /rescues/:id | None | {id, state, checks, …} |
| DELETE | /rescues/:id | None · stops the watch only; the redirect and the link are untouched | {ok: true} |
| POST | /rescues/:id/check | None · the only way a rescue leaves "pending" | {id, state, lastCheckedAt, …} |
| GET | /showcards | None | {cards: [...], sizes: [...]} · the card and size vocabulary |
| GET | /domains | None | {domains: [...]} |
| GET | /domains/check | ?hostname= | {ok, problem?, apex, recordName, target, dns, slot: {rented, yearlyCredits}} |
| POST | /domains | {hostname} · registers it pending; nothing is provisioned yet | 201 · {id, hostname, rented, yearlyCredits?} · a rented slot COSTS 40,000 CREDITS, ONCE |
| POST | /domains/:id | None · re-checks DNS and provisions only on a real match | {state: norecord|wrongtarget|propagating|active, found?, expected} |
| DELETE | /domains/:id | None · ALSO DELETES EVERY LINK ON THE HOSTNAME, printed codes included | {ok: true, hostname, linksRemoved} |
| POST | /qr-art | {linkId, prompt, qrDataUrl} · qrDataUrl is a PNG data URI the CALLER renders | 202 · {jobId, status, balance, cost} · COSTS 400 CREDITS, refunded on failure |
| GET | /qr-art/jobs/:id | None · polling settles the job, it is not a passive read | {id, status, images: [...], …} |
| DELETE | /qr-art/jobs/:id | None | {id, status: cancelled, …} · the reservation is refunded |
| GET | /qr-art/library | ?before= | {images: [...], nextBefore} |
| DELETE | /qr-art/library | ?id= | {ok: true} |
Worked examples
# 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" } curl https://taproute.io/api/v1/wallet -H "Authorization: Bearer tr_..."
# { "balance": 48210, "inGrace": false, "graceExhausted": false, … } # 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 5 credits per key changed · a save that changes nothing is never charged
One optional object on a link, evaluated at the edge on every scan. Two families live
in it. Gates (expire · geoGate · passwordHash · cap · preview) answer the scanner
directly: a notice, a form, a refusal. Routing keys pick a destination. Anything
unmatched falls through to the link's main url, so a rules object can
never strand a scanner. A link that routes with a flow refuses raw routing keys with 400 · gate
keys 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,
// Access password; the edge shows a gate form. Hash is SHA-256 hex of
// salt + ":" + password. Send a random "passwordSalt" with it — without one the
// hash is a rainbow-table lookup. The salt is part of this rule, not billed as
// another one. Wrong guesses are throttled per link, per client network.
"passwordHash": "9f86d081…",
"passwordSalt": "3f9a1c7e2b4d8a60",
// 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" }
} Adding rules to a link
# The same link, now with two rules (10 credits · 5 credits per rule changed):
# iPhones go to the App Store, scans from Germany go to /de.
curl -X PATCH https://taproute.io/api/v1/links/LINK_ID \
-H "Authorization: Bearer tr_..." \
-H "Content-Type: application/json" \
-d '{ "rules": {
"ios": "https://apps.apple.com/app/id123",
"geo": { "DE": "https://example.com/de" }
} }' schedule windows are checked in order · the first one that matches routes the scanurl is free, and nothing is refundedHosted pages a destination we serve, built from blocks, created with its link
A code does not have to point at a site you run. Send a template instead
of a url and the same call mints a link and the page it resolves
to · there is no bare page-create, because a page is reached only through its link's
address. Everything else about links applies unchanged: routing, analytics, domains,
re-pointing after printing.
# The create vocabulary. Fetch it · never guess a template id.
curl https://taproute.io/api/v1/pages/templates \
-H "Authorization: Bearer tr_..."
# { "categories": ["custom", "experience", "bio", …],
# "templates": [
# { "id": "bio-links", "blocks": ["profile", "button"], "tier": null },
# { "id": "…", "blocks": ["band", "loopvideo", "schedule"],
# "tier": "experience", "unlockCredits": …, "scanCredits": … } ],
# "skins": [ … ], "theme": { … } }
# tier null costs nothing beyond the link. tier "experience" is free to mint and
# free to edit, then 800 credits, once, at first publish · 2 credits per scan. Draft first, publish when it is ready
draft: true is the difference between a code that exists and a code that
resolves. A draft has no edge record at all, so its slug 404s, nothing
can scan it, and its design is still safe to change · which is the point, because a
published code may already be on a box. It also decides when a premium
template is charged.
# Mint the code and its page in one call. "draft": true means the slug 404s until
# you release it · and an experience template's unlock is not charged yet.
curl -X POST https://taproute.io/api/v1/links \
-H "Authorization: Bearer tr_..." \
-H "Content-Type: application/json" \
-d '{ "template": "TEMPLATE_ID", "slug": "arrivals", "draft": true }'
# 201 Created · pageId is the handle every edit below uses
# { "id": "…", "pageId": "…", "slug": "arrivals",
# "destination": { "kind": "page", "pageId": "…" } } # Blocks REPLACE the array, so send the whole page every time. Field names are read
# off GET /api/v1/pages/blocks, which answers with a valid default of every type.
curl -X PATCH https://taproute.io/api/v1/pages/PAGE_ID \
-H "Authorization: Bearer tr_..." \
-H "Content-Type: application/json" \
-d '{ "blocks": [
{ "type": "band", "name": "Today", "cols": "1", "tone": "default" },
{ "type": "heading", "text": "Arrivals", "level": 1 },
{ "type": "schedule", "date": "2026-09-14", "now": true,
"sessions": [{ "time": "10:00", "end": "10:45", "title": "Doors", "track": "Main" }] },
{ "type": "audio", "title": "Lobby set",
"tracks": [{ "title": "Side A", "url": "https://cdn.example/a.mp3", "dur": "3:41" }] },
{ "type": "loopvideo", "src": "https://cdn.example/loop.mp4", "ratio": "wide" },
{ "type": "zoomimage", "src": "https://cdn.example/map.jpg", "label": "Tap to zoom" }
] }' # What the release will do, and what it will cost, before you commit to it.
curl https://taproute.io/api/v1/links/LINK_ID/publish \
-H "Authorization: Bearer tr_..."
# { "slug": "arrivals", "warnings": [],
# "experienceDue": { "unlock": …, "scan": … } } # absent once it is paid
# Release it. This is the call that charges the unlock, once · and it charges BEFORE
# the go-live write, so a 402 leaves the link a draft with nothing spent.
curl -X POST https://taproute.io/api/v1/links/LINK_ID/publish \
-H "Authorization: Bearer tr_..." \
-H "Content-Type: application/json" -d '{}' GET /pages/templates and GET /pages/blocks answer with every template and a valid default of every block · fetch them rather than guessing an id or a field nameStatic codes free to create, free to scan · and permanent
A static code is a different object from a link, and the difference is the whole trade. The payload is the destination, so there is no slug, no KV record and nothing of ours in the scan path: it costs nothing to create, nothing per scan, and it keeps working whatever happens to us. In exchange it can never be repointed once printed, it reports no scans, and it takes no routing rules. Neither shape is the default · the deciding question is whether the content could ever change, not which content kind it is.
payload or a kind + fields · sending both is a 400, and so is sending neitherurl · wifi · vcard · email · sms · tel · whatsapp · text · event · locationwifi code JOINS the network and a static vcard saves offline · the dynamic kinds of the same name cannot, so their fields differ too# A static code carries its payload inside the symbol. Nothing resolves through us,
# so it costs no credits to create and none per scan · and it can never be repointed.
# Give it EITHER a raw payload OR a kind + fields; sending both is a 400.
curl -X POST https://taproute.io/api/v1/static-codes \
-H "Authorization: Bearer tr_..." \
-H "Content-Type: application/json" \
-d '{ "kind": "wifi", "fields": { "ssid": "Lobby", "password": "hunter2" } }'
# 201 Created · escaping handled for you, and named for the SSID
# { "id": "…", "publicId": "QR-7Q4K2P", "type": "wifi", "name": "Lobby",
# "payload": "WIFI:T:WPA;S:Lobby;P:hunter2;;" }
# The raw form still works · for a payload that is not one of the ten kinds,
# or for re-saving one you read back out of the library.
curl -X POST https://taproute.io/api/v1/static-codes \
-H "Authorization: Bearer tr_..." \
-d '{ "payload": "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Dana Levi\r\nEND:VCARD" }' Bulk
# The bulk job this exists for: a table of conference badges, in one call.
# Rows are independent · a bad row never cancels its neighbours, and the
# result reports every failure at its ORIGINAL index. Still 0 credits.
curl -X POST https://taproute.io/api/v1/static-codes/batch \
-H "Authorization: Bearer tr_..." \
-H "Content-Type: application/json" \
-d '{ "codes": [
{ "kind": "vcard", "fields": { "name": "Dana Levi", "tel": "+972501234567" } },
{ "kind": "vcard", "fields": { "name": "Noa Bar", "email": "noa@example.com" } },
{ "payload": "https://example.com/programme", "name": "Programme" }
] }'
# 200 · always this shape, never 207
# { "created": [{ "index": 0, "id": "…", "publicId": "QR-…", "type": "vcard" }, …],
# "errors": [] } A workspace holds up to 5,000 saved codes.
A single create past that fails with the cap in the message; a batch that would cross
it fails whole, before any row is written, naming how many slots are free. PATCH changes what future prints encode and never touches a code already
in the world. The one billable action here is POST /static-codes/:id/tracking: 200 credits, once, and it
measures a page you control rather than the printed symbol, which never reaches us.
GS1 Digital Link barcodes that resolve · same key, same credits
Register a GS1 identifier (a GTIN, the number under a retail barcode) as a resolvable Digital Link, attach destinations to it, print its carrier, and issue per-unit serials. The concepts · keys, licences, qualifiers, the resolver · have their own handbook. The short version of the prices:
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 we call your server when things happen
Add endpoints in Dashboard → API to receive a POST for every scan, delivered from the edge in real time. Verify the signature and you can trust the payload; skip it and anyone who finds your URL can forge scans. Each delivery 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 give an AI agent a key and let it drive
A native Model Context Protocol server (Streamable HTTP) lets agents like Claude and Cursor manage your codes directly: create campaigns, file them into folders, read analytics, issue serials. Authenticate with the same API keys · a read-only key makes the agent read-only too:
{
"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), 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 of any content kind. Returns the short URL to encode in a QR code, plus `destination` (the typed destination, so you can branch on its `kind` without parsing a string) and `type`. Say where it points EXACTLY ONE way: `url` for a web address, `kind` + `fields` for a content kind (WhatsApp, contact card, phone call, WiFi, calendar event, location, plain text), or `template` for a hosted landing page. PRICING IS IDENTICAL FOR EVERY KIND: 1 credit to create, 1 per scan. A kind served as a hosted page (contact card, WiFi, calendar event, plain text) costs exactly what a URL costs · there is no surcharge for any kind, and you must never quote one. Creating also costs 5 credits for each routing rule in `rules`, and a `template` whose page_templates row carries tier "experience" additionally charges its one-time unlock of 800 credits, once at this call · an MCP-created link is born live, so creating IS publishing (see the `template` parameter for the full quote). Fails with "Not enough credits" if the wallet is short. The account also has a total-code inventory cap that scales with its credit pack; a create past it fails with the cap in the message. 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 5 credits per routing rule, charged per row — a row that fails is not charged. PRICING IS IDENTICAL FOR EVERY KIND: 1 credit to create, 1 per scan. A kind served as a hosted page (contact card, WiFi, calendar event, plain text) costs exactly what a URL costs · there is no surcharge for any kind, and you must never quote one. A batch that would pass the account's total-code inventory cap (pack-scaled) fails whole, before any row is created. Use for campaigns, imports and other mass creation. A row is EITHER a `url` or a redirect `kind` + `fields` (url, whatsapp, location); the page-backed kinds are one at a time through create_link, because each one mints a page. |
list_links | List dynamic QR links, paginated, ordered by `sort` (newest first unless you say otherwise). Filter with folder (folder id or "unfiled"), q (matches label/slug/url) and status. Pass the returned nextCursor as cursor for the next page. Each link includes publicId (its stable LNK-XXXXXX handle — echo this to the user, not the internal id), type (url/page/flow/gs1) and mode (always "dynamic" here). |
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: where it points (`url`, or `kind` + `fields` to repoint it at a WhatsApp chat or a map), routing rules, status (active|paused), label, folder (folderId: null = Unfiled), or QR design. Returns the link's `url` and its typed `destination`. ROUTING RULES BILL PER CHANGE: every rule this call adds, edits OR removes costs 5 credits, and nothing is refunded. Send `rules` only when you actually mean to change routing — a save that moves no rule is free, but re-sending an edited table you did not intend to edit is a real charge. Changing where the link points is FREE, at every kind. NOTE: a link that routes with a flow (a routing graph) accepts only gate rules — passwordHash, geoGate, cap, expire, preview. Sending ios/android/schedule/geo/lang/split to a flow link fails: on a flow link those decisions live in the graph, so change them with set_flow. |
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_flow | Read the routing graph for a link: nodes, edges, and the gate rules that sit in front of it. `converted: true` means this link has never been saved as a flow and what you are reading is its simple `rules` expressed as an equivalent graph — nothing has been written, and set_flow is what persists it. `billingBaseline` is the condition count a set_flow call diffs against, so sending this exact graph back is charged nothing — but a set_flow that changes ANY condition bills for each one it moves, added, edited or removed alike. |
set_flow | Replace the routing graph for a link. COSTS 5 credits PER CONDITION CHANGED: added, edited and removed each bill the same, so re-sending an unchanged flow costs 0 and remove-then-re-add costs twice with no refund. Repointing a branch at a different destination is free · it is the conditions that are metered, not where they lead. Call get_flow first and read `billingBaseline` to know what you will be billed, and tell the human the price before you spend it. The flow REPLACES what is there, so send the whole document. Shape: {v: 1, nodes: [...], edges: [...]}. A node is {id, kind: "start"} | {id, kind: "cond", type, params} | {id, kind: "dest", to: {type: "url", url} | {type: "resource", resourceId}}. An edge is {id, from, fromHandle, to}, where fromHandle is "out" on the start node and otherwise a branch handle the condition type declares. Call flow_condition_types for the types, their params and their branch handles. Limits: 50 nodes, 120 edges, 20 branches per condition. Every inline destination is safety-screened, so a rejected URL fails the whole save with 422. Fails with 402 if the wallet is short. |
clear_flow | Remove a link's routing graph and send it back to routing straight to its own url. Gate rules (password, geoGate, cap, expire, preview) survive, because they are not part of the graph. COSTS 5 credits FOR EVERY CONDITION IT RETIRES — a removal is a billable routing change like any other since 2026-08-27 — and refunds nothing. It is never blocked for want of credits: a customer must always be able to switch routing off, on a code that may already be printed. |
flow_condition_types | The condition types a flow graph can use, each with the exact `params` shape it expects, the branch handles it produces in order, and the scan signals it reads. Call this before writing a flow rather than guessing a type id or a handle: a wrong handle wires an edge to a branch that never fires, which is silent at save time and only shows up as traffic going the wrong way. Free, no side effects. |
list_flow_presets | Saved condition steps in this workspace: a name, a condition type and the params a person already tuned. Insert one by copying its `params` into a cond node of the same `type` — a preset is a starting point, not a live reference, so editing the preset later does not change any flow. Free, no side effects. |
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. |
list_pages | Hosted pages in this workspace, each with its template, title, attached link and whether smart evaluation is on. A page is reachable only through its link, so a null linkId means nobody can load it. Free, no side effects. |
get_page | One page in full: its block list, its theme, and its attachment. Fetch this before update_page — blocks are replaced wholesale, so editing without reading first deletes everything you did not send. Free, no side effects. |
update_page | Edit a page: title, theme, and blocks. FREE, including on a live page — editing what a published page says costs nothing, exactly like editing where a printed link points. `blocks` REPLACES the whole array, so send the full set from get_page with your edits applied; a partial array deletes what it omits. Every block is validated, and an unknown type is refused rather than silently dropped at render. Call page_blocks for the vocabulary and a valid example of each type. |
delete_page | Delete a hosted page. Its link survives and falls back to a plain URL destination, so the printed code keeps resolving rather than dying. If the intent is to take the whole code down, pause the LINK instead with update_link. |
attach_page | Attach a page to a link, or detach it by passing linkId null. One page per link: attaching to a link that already serves a page replaces it. A detached page still exists but has no address until it is attached again. |
page_templates | The templates a page can be created from, grouped by category, plus the ready-made skins and the permitted values of every theme field. Every template row carries a `tier`; a row with tier "experience" also carries structured `unlockCredits` and `scanCredits` numbers · the one-time unlock create_link charges when it mints the page (born live, so creation is publication) and the per-scan rate · so read prices from those fields, never out of prose. Call this before create_link with a `template`: the id is not guessable and an unknown one is refused. Free, no side effects. |
page_blocks | Every block type a page can contain, each with a fully-populated valid example of that block. The examples come from the same function the editor uses to add a block, so anything shown here is something update_page will accept. Call it before writing blocks rather than guessing a field name. Free, no side effects. |
list_static_codes | The saved static-code library: codes whose payload IS their destination (WiFi, vCard, plain text, a URL nobody will ever repoint). No slug, no redirect, no per-scan cost. Each row carries publicId (its stable QR-XXXXXX handle · echo this to the user, not the internal id), type (which of the ten payload kinds it is), payload, design and tracking state. Returns the WHOLE library in one call, up to the 5000-code ceiling · there is no cursor. Free, no side effects. |
create_static_code | Save a static code to the library. Say what it encodes EXACTLY ONE WAY: `payload` for a string you already have, or `kind` + `fields` to have one built and correctly escaped for you. Sending both is a 400, and so is sending neither. STATIC (this tool): no credits to create and none per scan, nothing of ours anywhere in the scan path, and the symbol carries only the customer's own data. It can never be repointed once printed, it reports no scans unless enable_static_tracking is called on an http(s) payload, and it takes no routing rules. DYNAMIC (create_link): 1 credit to create and 1 per scan, repointable forever, measured, routable by device, country, language and time, and its symbol encodes only the short link (26 characters, QR version 2) rather than the whole payload (a full vCard is 248 characters, version 11), so it prints a much smaller symbol at the same scan distance · it does resolve through taproute, on our short domain unless the workspace has a custom domain. Neither is the default. The deciding question is whether the content could ever change, not which content kind it is. When that is not obvious, ask. A workspace holds up to 5000 saved codes; a create past that fails with the cap in the message and nothing is written. |
create_static_codes_batch | Save up to 100 static codes in one call, from typed `kind` + `fields` rows or raw `payload` rows, mixed freely. This is the bulk vCard and bulk WiFi job: a table of conference badges, a set of per-room network codes, built and escaped correctly, in one call. Costs no credits · every row is free to create and free to scan, like any static code. Rows are independent: the result lists created codes and per-row errors by index, and a bad row never cancels its neighbours. ALL TEN KINDS are available here, unlike create_links_batch, because a static code mints nothing · each row is one saved payload, so a row can fail on its own. A batch that would take the workspace past its 5000-code library limit fails WHOLE, before any row is written, with the number of free slots in the message. STATIC (this tool): no credits to create and none per scan, nothing of ours anywhere in the scan path, and the symbol carries only the customer's own data. It can never be repointed once printed, it reports no scans unless enable_static_tracking is called on an http(s) payload, and it takes no routing rules. DYNAMIC (create_link): 1 credit to create and 1 per scan, repointable forever, measured, routable by device, country, language and time, and its symbol encodes only the short link (26 characters, QR version 2) rather than the whole payload (a full vCard is 248 characters, version 11), so it prints a much smaller symbol at the same scan distance · it does resolve through taproute, on our short domain unless the workspace has a custom domain. Neither is the default. The deciding question is whether the content could ever change, not which content kind it is. When that is not obvious, ask. The dynamic equivalent is create_links_batch, which charges 1 credit per row. |
get_static_code | One saved static code: its payload, design, tracking state and beacon counts. Free, no side effects. |
update_static_code | Rename, restyle, or change the payload of a saved static code. Costs nothing. Takes `payload` only, never `kind` + `fields`: there is no way to read an existing payload back out as fields, so an edit by kind would have to replace the whole payload from values re-sent in full, which is a create wearing an id. Build a new code and delete the old one instead. NOTE what a payload edit does and does not do: codes printed from now on encode the new payload, and codes ALREADY PRINTED are unaffected forever, because a static symbol carries its destination inside itself. Never tell a user this fixed a code that is already in the world. |
delete_static_code | Remove a static code from the library. Anything already printed from it keeps working: it never needed us to resolve. This deletes the row, not the code. |
enable_static_tracking | Turn on opt-in page analytics for a static code with an http(s) payload. COSTS 200 CREDITS, ONCE. This tracks the PAGE, not the printed symbol: a static scan never reaches us, so what it measures is loads of a page the customer controls, tagged so QR-driven ones can be told apart. Every beacon afterwards is free, which is the opposite of the dynamic model: a tracked static code has a one-off cost and no per-scan cost at all. Call wallet_balance first and read rateCard.enableTracking for the live price. Reversible from the dashboard, and re-enabling later is not charged again. |
list_resources | The destination repository: named URLs (and hosted pages) that many codes can point at. The reason to use one is edit-once — repoint the resource and every code referencing it is recompiled and republished in a single call, instead of one call per code. Free, no side effects. |
create_resource | Add a named URL to the destination repository, so flows can point at it by id instead of repeating the address. Costs nothing. Worth doing BEFORE building a campaign: 200 codes aimed at one resource are repointed with one call, 200 codes with the URL inlined are 200 calls and a partial failure. |
update_resource | Rename a resource, or retarget its URL. Costs nothing. A URL change is a FAN-OUT: every link whose flow references it is recompiled and republished. That is bounded on purpose, so a resource used by more codes than the sync limit is refused with 409 BEFORE anything is written rather than half-republished. Check resource_usage first when retargeting something widely used. |
resource_usage | Which codes point at this destination. The blast radius of a retarget, and the reason a delete would be refused, in one call. Free, no side effects. |
delete_resource | Remove a destination from the repository. Refused with 409 while any flow still references it, so this cannot orphan a live code — repoint or delete those codes first. A page resource is deleted by deleting its page, not here. |
list_rescues | Printed codes this workspace has claimed, each with its current health (pending, live or broken) and when it was last checked. Free, no side effects. |
create_rescue | Claim a code that is ALREADY PRINTED: take over the hop for an address on flyers you cannot reprint. COSTS 50 CREDITS, ONCE. What the 50 buys is the WATCH, not the row — the redirect lives on a server we do not own, so we probe that origin every day for the life of the account and email the owner the day it stops reaching us. That is why it costs more than a routing rule. Scans of the target link bill the ordinary 1 credit, unchanged. The target link must already exist: create it first with create_link. Call wallet_balance and read rateCard.rescue for the live price. |
check_rescue | Verify a rescue now: follow the printed address and see whether it actually reaches the dynamic code. The only way a rescue leaves "pending". Cheap to repeat, so polling it while a DNS or CDN change propagates is the intended use. Free. |
list_domains | Custom domains registered to this workspace, with their verification status (pending or active) and when each was last verified. Codes on a domain that is not active do not resolve. Free, no side effects. |
check_domain | Can this hostname be added, what DNS record does it need, and does it spend a paid slot. Creates nothing: no row, no DNS change, no charge. ALWAYS call this before add_domain — `slot.rented` true means the next domain costs `slot.yearlyCredits`, which is by a wide margin the largest single charge on the rate card. `dns` is advisory and "norecord" is normal for a name nobody has pointed yet. |
add_domain | Register a custom hostname. Within the pack's free allowance this costs no credits; BEYOND it the domain occupies a prepaid annual slot costing rateCard.domainSlotYearly, which is 40,000 credits. Call check_domain first and read `slot.rented` to know which case you are in, and tell the human the number before you call this. Nothing is provisioned yet: this registers the hostname as pending and returns the CNAME target a PERSON must then create at their DNS provider. Call verify_domain once they have. |
verify_domain | Re-check a pending domain's DNS and activate it if it now points at us. Costs nothing and is safe to poll while a record propagates. Returns `state`: "norecord" (nothing created yet), "wrongtarget" (pointing somewhere else, and `found` says where), "propagating" (correct, certificate still issuing) or "active" (live). Only "active" means codes on that domain resolve. |
gs1_spec_sheet | The print specification for an item's carrier: X-dimension and its permitted range, printed size and its range, quiet zone, bar height, magnification, and any AIs the chosen symbology could not carry. Same options as gs1_carrier, so it describes exactly that symbol. Free. `verified` flags which figures are quoted from the GS1 General Specifications and which are taproute engineering defaults: pass a false one on to the human as something to confirm against the standard before a production run, and never present these numbers as a compliance certificate. |
showcards | The ready-made presentation designs a code can be composed onto (poster, table tent, social post, signage, card, sticker), and the output sizes available, with each design's seed copy. Call this before get_showcard: the card id is not guessable and an unknown one is refused. Free, no side effects. |
get_showcard | Compose one of your codes onto a Showcard and return the artwork as an SVG string: background, headline, subline, the code on its plate, and a call to action. Free. The code is always drawn on an opaque plate, so it decodes whatever the background looks like. Returns SVG rather than PNG because there is no rasteriser here; convert it if you need a bitmap. Line breaking is estimated rather than measured, so a very long headline may wrap differently than the dashboard composer shows. |
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, gs1Analyze, rescue, aiArt, chatMessage, smartPage, smartScan, enableTracking, lead and domainSlotYearly. Note customRule bills PER CHANGE — every rule added, edited or removed — so budget it per edit, not once per rule. It also carries gs1ItemFirstFree: when true, the next gs1_create_item call is granted at zero credits rather than costing gs1Item. Check it before planning anything that creates in bulk. |
get_topup_link | List the credit packs on sale, and get a ready checkout URL for one of them. Costs nothing. Call this when a billable action failed with "Not enough credits": pick a pack that covers the shortfall, fetch its checkout URL, and hand that URL to your user — a HUMAN completes the purchase (agents cannot pay). Credits land in the wallet automatically on payment; retry the failed action afterwards. Larger packs also raise the hourly link-creation and API rate limits. |
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). EXCEPTION: this workspace's FIRST catalogue item is granted at zero credits, once ever. Call wallet_balance first: rateCard.gs1Item is the standing price and rateCard.gs1ItemFirstFree tells you whether this next call is the granted one. 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 mark for an item, as an SVG, plus what it encodes and the physical size it must print at. Renders the Digital Link QR (the default), the linear barcode for this key, or both together on one dual-marked artwork at GS1 point-of-sale placement. The linear symbology is chosen automatically from the identifier and its qualifiers — GS1-128, EAN-13, UPC-A, EAN-8 or ITF-14 — and reported back; it is not a setting. 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 every status the API actually returns
Every error is JSON of the shape { "message": "…" } · the message
says what went wrong and what to change. The one to design for on a prepaid API is 402: it is what an ordinary Tuesday looks like when the wallet runs dry
mid-campaign.
Verify five questions every integration should be able to answer
WHO CAN ASK
a Bearer tr_ key,
shown once, stored hashed
WHAT IT COSTS
1 credit per scan ·
5 credits per rule changed · reads free
WHERE IT 302s
the rules decide ·
unmatched falls to url
WHICH GATE FIRED
403 geo · 401 password ·410 expired · 503 paused
HOW TO STOP IT
pause · expire · archive ·
the address is never reissued