{"openapi":"3.1.0","info":{"title":"QR. API","version":"0.1.0","summary":"Mint, print, route, and observe dynamic QR codes","description":"Developer API spine: mint → print → live link → route → observe → brand → ship safe.\nCreate codes over HTTP, export assets, PATCH destinations without reprinting,\nroute at the edge, and observe scans via analytics and signed webhooks.\n\n**Guides:** [Quickstart](https://qrdot.dev/docs/quickstart/) · [Authentication](https://qrdot.dev/docs/authentication/) · [Errors](https://qrdot.dev/docs/errors/) · [Webhooks](https://qrdot.dev/docs/webhooks/)\n\n**Auth:** `Authorization: Bearer sk_live_…`\n(create keys in the [dashboard](https://app.qrdot.dev/keys/)).\n\n**Analytics:** eventually consistent (~5s lag). Redirects never wait on analytics.\n\n**Logos (Starter+):** upload via `POST /v1/assets/logo/presign` → PUT to\n`upload_url` → `POST /v1/assets/logo/:id/complete`, then pass `style.logo_asset_id`.\nAlso managed in the [dashboard Assets](https://app.qrdot.dev/assets/) page.\n\n**Not in this reference:** API keys, custom domains, account/team, and billing\nare dashboard-only and are not part of the public product API.\n\n**Clients:** `npm i @qrdot/sdk` · `pip install qrdot` ·\n`go get github.com/intuitix/qrdot-clients/go@v0.1.1` ·\n`composer require intuitix/qrdot-php` · `gem install qrdot` ·\n**CLI:** `npx @qrdot/cli` — [libraries](https://qrdot.dev/libraries/)"},"servers":[{"url":"https://api.qrdot.dev","description":"Production"}],"tags":[{"name":"QR","description":"Dynamic QR codes"},{"name":"Assets","description":"Logo library (Starter+) — presign upload, list, delete; reuse via logo_asset_id"},{"name":"Analytics","description":"Scan pull APIs"},{"name":"Webhooks","description":"Push signed events (qr.scanned, qr.created, qr.updated, qr.deleted)"},{"name":"Usage","description":"Quota and plan usage"},{"name":"Meta","description":"Public metadata"}],"paths":{"/health":{"get":{"tags":["Meta"],"summary":"Health check","security":[],"responses":{"200":{"description":"OK","content":{"application/json":{"example":{"ok":true,"service":"qrdot-api"}}}}}}},"/v1/meta":{"get":{"tags":["Meta"],"summary":"Product meta + plans","security":[],"responses":{"200":{"description":"Meta","content":{"application/json":{"example":{"name":"qrdot","goDomain":"go.qrdot.dev","freeMonthlyQuota":20,"openapi":"/v1/openapi.json"}}}}}}},"/v1/openapi.json":{"get":{"tags":["Meta"],"summary":"OpenAPI document","security":[],"responses":{"200":{"description":"OpenAPI 3.1 JSON"}}}},"/v1/qr":{"get":{"tags":["QR"],"summary":"List QR codes","security":[{"bearerAuth":[]}],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":20},"description":"Page size (max 100). Omit to return the full list (backward compatible)."},{"name":"cursor","in":"query","schema":{"type":"string"},"description":"Opaque cursor — pass nextCursor from the previous response"},{"name":"metadata[campaign]","in":"query","schema":{"type":"string"},"description":"Filter by metadata key (AND across keys). Also accepts metadata.campaign=…","example":"summer"}],"responses":{"200":{"description":"List","content":{"application/json":{"example":{"data":[{"id":"qr_…","shortUrl":"https://go.qrdot.dev/AbCdEf12","targetUrl":"https://example.com","metadata":{"campaign":"summer"},"env":"live"}],"total":47,"hasMore":true,"nextCursor":"qr_…"}}}}}},"post":{"tags":["QR"],"summary":"Create QR","security":[{"bearerAuth":[]}],"parameters":[{"name":"Idempotency-Key","in":"header","schema":{"type":"string"},"description":"24h dedupe per account"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateQr"},"examples":{"campaign":{"summary":"Campaign tag","value":{"targetUrl":"https://example.com/lp","name":"Summer poster","metadata":{"campaign":"summer"}}},"linkWithUtm":{"summary":"Link with UTM","value":{"targetUrl":"https://example.com/lp","utm":{"source":"qr","medium":"print","campaign":"summer"}}}}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"example":{"id":"qr_…","shortUrl":"https://go.qrdot.dev/AbCdEf12","targetUrl":"https://example.com/lp?utm_source=qr&utm_medium=print&utm_campaign=summer","metadata":{"campaign":"summer"}}}}}}}},"/v1/qr/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"get":{"tags":["QR"],"summary":"Get QR","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"QR","content":{"application/json":{"example":{"id":"qr_…","shortUrl":"https://go.qrdot.dev/AbCdEf12","targetUrl":"https://example.com","name":"Launch","metadata":{"campaign":"summer"},"env":"live"}}}},"404":{"$ref":"#/components/responses/NotFound"}}},"patch":{"tags":["QR"],"summary":"Update QR (URL, rules, metadata, expire)","security":[{"bearerAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchQr"},"example":{"targetUrl":"https://example.com/new","rules":{"device":{"ios":"https://apps.apple.com/app/x"},"geo":{"countries":{"IN":"https://example.in"}}}}}}},"responses":{"200":{"description":"Updated"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}},"delete":{"tags":["QR"],"summary":"Delete QR","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Deleted"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/v1/qr/export/images":{"post":{"tags":["QR"],"summary":"Export images as ZIP","description":"Renders up to 100 QR images into a ZIP. Entry names are `{name}_{shortCode}.{format}` plus `manifest.csv`. Free plans include a small qrdot.dev watermark on each image.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["ids"],"properties":{"ids":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":100,"example":["qr_abc","qr_def"]},"format":{"type":"string","enum":["png","svg","pdf"],"default":"png"}}}}}},"responses":{"200":{"description":"ZIP archive","headers":{"X-Qrdot-Exported-Count":{"schema":{"type":"integer"},"description":"Number of images included"}},"content":{"application/zip":{"schema":{"type":"string","format":"binary"}}}},"400":{"description":"Invalid export payload","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/v1/qr/{id}/image.png":{"get":{"tags":["QR"],"summary":"Export PNG","description":"Renders with persisted style. Free plans always include a small qrdot.dev watermark.","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"PNG bytes","content":{"image/png":{"schema":{"type":"string","format":"binary"}}}},"404":{"$ref":"#/components/responses/NotFound"}}}},"/v1/qr/{id}/image.svg":{"get":{"tags":["QR"],"summary":"Export SVG","description":"Renders with persisted style. Free plans always include a small qrdot.dev watermark.","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"SVG markup","content":{"image/svg+xml":{"schema":{"type":"string"}}}}}}},"/v1/qr/{id}/image.pdf":{"get":{"tags":["QR"],"summary":"Export PDF","description":"Renders with persisted style. Free plans always include a small qrdot.dev watermark.","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"PDF bytes","content":{"application/pdf":{"schema":{"type":"string","format":"binary"}}}}}}},"/v1/qr/{id}/duplicate":{"post":{"tags":["QR"],"summary":"Duplicate QR","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"201":{"description":"Clone created"}}}},"/v1/qr/batch":{"post":{"tags":["QR"],"summary":"Batch create (up to 100)","security":[{"bearerAuth":[]}],"parameters":[{"name":"Idempotency-Key","in":"header","schema":{"type":"string"},"description":"24h dedupe for the whole batch response"}],"requestBody":{"content":{"application/json":{"example":{"items":[{"targetUrl":"https://event.example/seat","metadata":{"campaign":"conf-2026","seat":"A1"},"utm":{"source":"badge","campaign":"conf-2026"}},{"targetUrl":"https://event.example/seat","metadata":{"campaign":"conf-2026","seat":"A2"}},{"targetUrl":"https://event.example/seat","metadata":{"campaign":"conf-2026","seat":"A3"}}]}}}},"responses":{"201":{"description":"All created"},"207":{"description":"Partial success — see errors[]"}}}},"/v1/analytics/summary":{"get":{"tags":["Analytics"],"summary":"Account scan summary","security":[{"bearerAuth":[]}],"parameters":[{"name":"from","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","schema":{"type":"string","format":"date-time"}}],"responses":{"200":{"description":"Summary","content":{"application/json":{"example":{"totals":{"scans":1284,"unique_approx":1284},"source":"day_stats","lag_seconds_typical":5,"timeseries":[{"t":"2026-08-01","scans":120},{"t":"2026-08-02","scans":340}],"breakdown":{"countries":[{"code":"IN","scans":800}],"devices":[{"type":"ios","scans":520}]}}}}}}}},"/v1/analytics/qr/{id}":{"get":{"tags":["Analytics"],"summary":"QR analytics (timeseries + breakdown)","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"group_by","in":"query","schema":{"type":"string","enum":["day","hour"]}}],"responses":{"200":{"description":"Chart-ready analytics"}}}},"/v1/analytics/qr/{id}/scans":{"get":{"tags":["Analytics"],"summary":"List scans for a QR","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer","default":25}},{"name":"cursor","in":"query","schema":{"type":"string"},"description":"Opaque Dynamo cursor from next_cursor"},{"name":"starting_after","in":"query","schema":{"type":"string"},"description":"Deprecated — prefer cursor"},{"name":"from","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"country","in":"query","schema":{"type":"string","minLength":2,"maxLength":2},"description":"ISO-2 country filter (e.g. IN)"},{"name":"device","in":"query","schema":{"type":"string","enum":["ios","android","desktop"]}}],"responses":{"200":{"description":"Scan list (same schema as webhook data)"}}}},"/v1/logs":{"get":{"tags":["Analytics"],"summary":"Account logs","security":[{"bearerAuth":[]}],"parameters":[{"name":"type","in":"query","schema":{"type":"string","enum":["scan"],"default":"scan"}},{"name":"limit","in":"query","schema":{"type":"integer","default":25}},{"name":"cursor","in":"query","schema":{"type":"string"},"description":"Opaque Dynamo cursor from next_cursor"},{"name":"starting_after","in":"query","schema":{"type":"string"},"description":"Deprecated — prefer cursor"},{"name":"from","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"country","in":"query","schema":{"type":"string","minLength":2,"maxLength":2}},{"name":"device","in":"query","schema":{"type":"string","enum":["ios","android","desktop"]}}],"responses":{"200":{"description":"Log list"}}}},"/v1/webhooks":{"get":{"tags":["Webhooks"],"summary":"List webhooks","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"List"}}},"post":{"tags":["Webhooks"],"summary":"Create webhook","description":"Returns `secret` once (`whsec_…`). URL must be public HTTPS (localhost/private IPs rejected). We do not probe reachability on create — use `POST /v1/webhooks/:id/test`. Verify `X-Qrdot-Signature: t=…,v1=…` over `${t}.${rawBody}`. Events: `qr.scanned`, `qr.created`, `qr.updated`, `qr.deleted`.","security":[{"bearerAuth":[]}],"requestBody":{"content":{"application/json":{"example":{"url":"https://example.com/hooks/qrdot","events":["qr.scanned","qr.created"]}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"example":{"id":"hook_…","url":"https://example.com/hooks/qrdot","secret":"whsec_…"}}}}}}},"/v1/webhooks/{id}":{"get":{"tags":["Webhooks"],"summary":"Get webhook","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Webhook (secret omitted)","content":{"application/json":{"example":{"id":"hook_…","url":"https://example.com/hooks/qrdot","events":["qr.scanned"],"enabled":true}}}},"404":{"$ref":"#/components/responses/NotFound"}}},"patch":{"tags":["Webhooks"],"summary":"Update webhook","description":"Partial update of `url`, `events`, `description`, and/or `enabled`. Signing secret is immutable (not accepted). At least one field required; `events` must be non-empty when provided. Same HTTPS / public-URL rules as create.","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"example":{"url":"https://example.com/hooks/qrdot-v2","events":["qr.scanned","qr.updated"],"enabled":true}}}},"responses":{"200":{"description":"Updated webhook (secret omitted)","content":{"application/json":{"example":{"id":"hook_…","url":"https://example.com/hooks/qrdot-v2","events":["qr.scanned","qr.updated"],"enabled":true}}}},"404":{"$ref":"#/components/responses/NotFound"}}},"delete":{"tags":["Webhooks"],"summary":"Delete webhook","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted"}}}},"/v1/webhooks/{id}/deliveries":{"get":{"tags":["Webhooks"],"summary":"List recent delivery attempts","description":"Metadata-only recent attempts (7-day retention). Sources: `scan`, `test`, `replay`. Bodies are not stored.","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"maximum":100}}],"responses":{"200":{"description":"Delivery list","content":{"application/json":{"example":{"object":"list","data":[{"id":"att_…","object":"webhook_delivery","webhook_id":"hook_…","type":"qr.scanned","source":"test","ok":true,"status_code":200,"latency_ms":42,"ts":"2026-08-08T10:00:00.000Z"}]}}}},"404":{"$ref":"#/components/responses/NotFound"}}}},"/v1/webhooks/{id}/test":{"post":{"tags":["Webhooks"],"summary":"Send a synthetic test event to a webhook","description":"Delivers a signed sample payload (not a real scan). Optional body `{ \"type\": \"qr.scanned\" }` must be one of the endpoint’s subscribed events (defaults to the first).","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"example":{"type":"qr.scanned"}}}},"responses":{"200":{"description":"Delivery attempt result","content":{"application/json":{"example":{"ok":true,"type":"qr.scanned","webhook_id":"hook_…","status_code":200}}}},"404":{"$ref":"#/components/responses/NotFound"}}}},"/v1/webhooks/{id}/replay":{"post":{"tags":["Webhooks"],"summary":"Replay a stored scan to a webhook","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"example":{"qr_id":"qr_…","scan_id":"scan_…","ts":"2026-08-06T12:00:00.000Z"}}}},"responses":{"200":{"description":"Delivery attempt result"}}}},"/v1/usage":{"get":{"tags":["Usage"],"summary":"Current period usage","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Usage","content":{"application/json":{"example":{"used":4,"limit":20,"period":"2026-08","planId":"free","resetsAt":"2026-09-01T00:00:00.000Z"}}}}}}},"/v1/assets/logo/presign":{"post":{"tags":["Assets"],"summary":"Presign logo upload (Starter+)","description":"Creates a pending logo id and short-lived S3 PUT URL. Then PUT the bytes with the returned headers, then call complete. Max 1MB; max 50 ready logos per account.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["content_type"],"properties":{"content_type":{"type":"string","enum":["image/png","image/jpeg","image/webp","image/svg+xml"]},"filename":{"type":"string"}}},"example":{"content_type":"image/png","filename":"mark.png"}}}},"responses":{"200":{"description":"Upload URL","content":{"application/json":{"example":{"asset_id":"logo_…","upload_url":"https://…","content_type":"image/png","headers":{"Content-Type":"image/png"},"max_bytes":1048576,"expires_in":900}}}},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/v1/assets/logo/{id}/complete":{"post":{"tags":["Assets"],"summary":"Finalize logo upload","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"filename":{"type":"string"}}}}}},"responses":{"200":{"description":"Ready LogoAsset with optional preview_url"},"400":{"description":"Upload missing or invalid"}}}},"/v1/assets/logo":{"get":{"tags":["Assets"],"summary":"List ready logos","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Logo library","content":{"application/json":{"example":{"data":[{"id":"logo_…","filename":"mark.png","content_type":"image/png","bytes":1200,"created_at":"2026-08-08T12:00:00.000Z","preview_url":"https://…"}]}}}}}}},"/v1/assets/logo/{id}":{"get":{"tags":["Assets"],"summary":"Get one logo","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"LogoAsset"},"404":{"$ref":"#/components/responses/NotFound"}}},"delete":{"tags":["Assets"],"summary":"Delete logo","description":"Removes S3 object + metadata. QRs that still reference the id fall back to no logo on export.","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Deleted"},"404":{"$ref":"#/components/responses/NotFound"}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"sk_live_…","description":"Secret API key from https://app.qrdot.dev/keys/ (shown once at creation)"}},"responses":{"Unauthorized":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"unauthorized","message":"Invalid API key"}}}}},"Forbidden":{"description":"Not allowed for this plan or principal","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"forbidden","message":"Custom domains require Pro or Scale"}}}}},"NotFound":{"description":"Resource not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_found","message":"QR not found"}}}}}},"schemas":{"Error":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","forbidden","not_found","validation_error","quota_exceeded","rate_limited","conflict","internal"]},"message":{"type":"string"},"details":{}}}}},"QrStyle":{"type":"object","description":"Visual style. Free: colors + forced qrdot.dev watermark. Starter+: colors + optional logo_asset_id from Assets API or dashboard.","properties":{"dark_color":{"type":"string","pattern":"^#[0-9A-Fa-f]{6}$","example":"#0A0A0A"},"light_color":{"type":"string","pattern":"^#[0-9A-Fa-f]{6}$","example":"#FFFFFF"},"logo_asset_id":{"type":"string","description":"Ready logo id from Assets (presign → PUT → complete). Pass null on PATCH to clear.","nullable":true}}},"CreateQr":{"type":"object","required":["targetUrl"],"properties":{"targetUrl":{"type":"string","format":"uri"},"name":{"type":"string"},"metadata":{"type":"object","additionalProperties":{"type":"string"},"description":"Use metadata.campaign to group codes in a campaign"},"utm":{"type":"object","description":"Merged into targetUrl as utm_* query params; campaign mirrors to metadata.campaign if unset","properties":{"source":{"type":"string"},"medium":{"type":"string"},"campaign":{"type":"string"},"term":{"type":"string"},"content":{"type":"string"}}},"rules":{"type":"object","description":"device / geo / time / ab routing (ab requires Pro+; weights must sum to 100). Precedence: device → geo → time → ab → targetUrl"},"gate":{"type":"object","description":"Password gate (all plans). Send plaintext password; API stores a hash. Never returned.","properties":{"type":{"type":"string","enum":["password"]},"password":{"type":"string"},"hint":{"type":"string","maxLength":80}},"required":["type","password"]},"expireAt":{"type":"string","format":"date-time"},"maxScans":{"type":"integer"},"style":{"$ref":"#/components/schemas/QrStyle"}}},"PatchQr":{"type":"object","properties":{"targetUrl":{"type":"string","format":"uri"},"name":{"type":"string"},"metadata":{"type":"object"},"utm":{"type":"object"},"rules":{"oneOf":[{"type":"object"},{"type":"null"}],"description":"Pass null to clear"},"gate":{"oneOf":[{"type":"object","properties":{"type":{"type":"string","enum":["password"]},"password":{"type":"string"},"hint":{"type":"string"}},"required":["type","password"]},{"type":"null"}],"description":"Pass null to clear password gate"},"expireAt":{"oneOf":[{"type":"string"},{"type":"null"}]},"maxScans":{"oneOf":[{"type":"integer"},{"type":"null"}]},"style":{"oneOf":[{"$ref":"#/components/schemas/QrStyle"},{"type":"null"}],"description":"Pass null to clear all style"}}}}}}