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." } }
StatustypeWhen
401unauthorizedMissing or invalid key.
403plan_requiredThe account is not on Pro.
403insufficient_scopeThe key lacks the scope for that action.
404not_foundNo such resource on your account.
422invalid_requestThe 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.