Build on TangoQR
Create codes, repoint them, and catch every scan. One REST API. Your servers, your workflow.
Authentication
The API is a Pro feature. Mint a key in your dashboard, then send it as a Bearer token. You see the key once, so store it somewhere safe. Keys carry read and write scopes; mint a read-only key for anything that only needs to look.
curl https://tangoqr.com/api/public/v1/codes \ -H "Authorization: Bearer tq_live_your_key_here"
Base URL: https://tangoqr.com/api/public/v1
Errors
Every error returns the same envelope with a stable type you can branch on.
{ "error": { "type": "plan_required", "message": "The TangoQR API requires a Pro plan." } }
| Status | type | When |
|---|---|---|
| 401 | unauthorized | Missing or invalid key. |
| 403 | plan_required | The account is not on Pro. |
| 403 | insufficient_scope | The key lacks the scope for that action. |
| 404 | not_found | No such resource on your account. |
| 422 | invalid_request | The request failed validation. |
| 429 | (none) | Rate limited. Honor the Retry-After header. |
Rate limits
120 requests per minute and 5,000 per hour, per key. Over the limit you get a 429 with a Retry-After header. Scans on your printed codes are never rate limited, so your audience always gets through.
Codes
A code is a redirect you control. Print the image once, change where it points whenever you want.
Create a code
curl -X POST https://tangoqr.com/api/public/v1/codes \
-H "Authorization: Bearer tq_live_…" \
-H "Content-Type: application/json" \
-d '{"destination_url":"https://example.com/menu","label":"Spring menu"}'
{
"token": "aBc123_-",
"short_url": "https://tgo.sh/aBc123_-",
"destination_url": "https://example.com/menu",
"label": "Spring menu",
"scan_count": 0,
"frame_style": null,
"module_shape": null,
"eye_shape": null,
"colors": { "foreground": null, "background": null },
"image": {
"svg": "https://tangoqr.com/r/aBc123_-.svg",
"png": "https://tangoqr.com/r/aBc123_-.png"
},
"disabled": false,
"created_at": "2026-06-08T19:48:18Z",
"updated_at": "2026-06-08T19:48:18Z"
}
Optional fields: label, foreground_color, background_color, frame_style (one of none, scan_me, rounded), module_shape (square, rounded, dot), and eye_shape (square, rounded, leaf, circle). Shapes apply to the SVG image; the PNG renders square. The short_url is what the QR encodes. The image URLs are cached and safe to hotlink.
Repoint a code
This is the whole point. Send a new destination; every printed code follows instantly.
curl -X PATCH https://tangoqr.com/api/public/v1/codes/aBc123_- \
-H "Authorization: Bearer tq_live_…" \
-H "Content-Type: application/json" \
-d '{"destination_url":"https://example.com/summer-menu"}'
List, fetch, and image
GET /codes?page=1&per=25 # paginated, newest first GET /codes/aBc123_- # one code GET /codes/aBc123_-/image.png # 302 to the rendered image (.svg or .png)
Disable and enable
Need to pause a code? Disable it and the redirect returns 410 until you turn it back on. There is no hard delete on purpose. Deleting a token would break every code you already printed, so disable is the reversible way to take one offline.
POST /codes/aBc123_-/disable POST /codes/aBc123_-/enable
Webhooks
Register an https endpoint in your dashboard and we POST a scan.recorded event on every scan of your codes. You get the signing secret once. For privacy we send the country and a coarse device class, never the raw IP or user agent.
{
"id": "evt_84217",
"type": "scan.recorded",
"created": "2026-06-08T14:30:45Z",
"data": {
"code": {
"token": "aBc123_-",
"short_url": "https://tgo.sh/aBc123_-",
"destination_url": "https://example.com/summer-menu",
"label": "Spring menu"
},
"scan": {
"scanned_at": "2026-06-08T14:30:45Z",
"country_code": "US",
"device_class": "mobile"
}
}
}
Verify the signature
Each delivery carries a Tango-Signature: t=<unix>,v1=<hex> header. Recompute the HMAC-SHA256 of "<t>.<raw body>" with your secret and compare. Reject anything older than a few minutes to block replays.
// Node.js (Express)
const crypto = require("crypto")
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")))
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex")
const a = Buffer.from(expected), b = Buffer.from(parts.v1 || "")
// Compare lengths first: timingSafeEqual throws on a length mismatch.
const ok = a.length === b.length && crypto.timingSafeEqual(a, b)
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300
return ok && fresh
}
Retries
Return any 2xx to acknowledge. Non-2xx and timeouts are retried with backoff. An endpoint that keeps failing is disabled automatically, and you can re-enable it from the dashboard once it is healthy.
Versioning
The version is in the path (/api/public/v1). We add fields without bumping it, so ignore fields you do not recognize. Breaking changes ship under a new version with notice.
Questions? Contact us.