QR code API documentation
Every endpoint, parameter and webhook. For an AI assistant or a no-code tool instead, start at the docs home.
QR code API
The QR code API makes QR codes and manages dynamic QR codes over HTTPS. Each endpoint below says which of three levels of access it needs.
To try a request first, the QR code API page builds one and shows the reply from a sample workspace.
No account. GET /api/qr/free returns a QR code as an SVG or a PNG, with no key and nothing to sign up for.
POST /api/qr/decode goes the other way: post an image and it reads the code out. It takes no key either.
A Free key. Create a key on any plan in Dashboard → API and it renders: GET /api/qr with every format, colour, shape and size, and GET /api/barcode.
A key on a plan without API access carries the render scope alone, so it can't read or change your account.
Starter and above. The account: create dynamic codes and short links, re-point what is already printed, smart routing, folders, tags, custom domains, scans and analytics, QR Menus and QR Forms. Webhooks come with Business.
Render (GET /api/qr) returns a static QR code image (SVG, PNG, JPG, WebP or PDF) for a link, Wi-Fi network, contact card, email, phone number, SMS, text or product. Nothing is stored; the same request always returns the same file.
Create (POST /api/v1/codes) saves a code to your account. A dynamic code gets a short link, so you can change where it points after it is printed, and it counts its scans.
Shorten (POST /api/v1/links) makes a short link on its own, with no QR code. It is the same kind of link a dynamic code has: you can edit it, route it and count its clicks.
Use it where you paste a link rather than print a code. You can ask for its QR code later.
Organise codes the way the dashboard does: your own custom domains and custom links, folders, tags, campaign tags and bulk actions.
Read scans for one code or the whole workspace, your QR Menus, your QR Forms and their answers, and hear about each scan and answer as it happens with webhooks.
The base URL is https://app.qrsalt.com. Requests and responses are JSON, except images.
Authentication
Send your API key as a bearer token. Every plan can create one in Dashboard → API, including Free. A key is shown once, and only a hash is stored.
Two things decide what a key can do: its scopes, and what your plan includes. On Free, a key carries render: it makes images and reads nothing.
Everything about the account (codes, links, scans, webhooks) comes with Starter and above. Until then those calls answer 402 with the name of the plan you need.
Call the API from a server or a no-code tool, such as the connectors for n8n and Make, never from a web page.
Anyone can copy a key that sits in a web page, so the API sends no CORS headers. A missing or wrong key is a 401.
An app that people connect to their own workspace can sign in with QRSalt instead, and nobody has to paste a key.
GET /api/qr/free takes no key at all, and GET /api/v1/me works with any key, so a tool can tell whether the key is wrong or the plan does not include what it asked for.
Example
Authorization: Bearer qr_live_...Scopes
A key can do only what you ticked when you made it. A script that prints labels needs to render, but it does not need to delete codes. Give each key only what its job needs, so a script run by mistake can’t delete anything.
A refusal names the missing scope. A 403 always means the key lacks a scope, so make a new key with that box ticked. A 402 means your plan does not include the feature.
Keys made before scopes existed have all four and keep working exactly as they did. On Starter and above you choose a key’s scopes when you make it; on lower plans every key is render only.
render- Static QR codes and barcodes as images. Nothing is stored or read.read- List codes, links, folders, tags, QR Menus, QR Forms and their answers, and read scans.write- Create and change codes, links, folders, domains and webhook endpoints.delete- Delete codes, links, domains and webhook endpoints. A deleted code cannot be restored.
Example
{
"error": {
"code": "forbidden",
"message": "This API key does not have the \"delete\" scope. It has \"render\", \"read\", \"write\". Create a key with it in Dashboard → API.",
"scope": "delete",
"scopes": [
"render",
"read",
"write"
]
}
}Sign in with QRSalt (OAuth)
An app can connect to somebody’s workspace without a key: it signs them in with QRSalt, using OAuth 2.1 with PKCE.
This is how the Claude app and other MCP clients connect to the QR code MCP server, and your own integration can do the same.
Discovery. Every 401 from /api/v1 and /api/mcp has a WWW-Authenticate header with resource_metadata: the address of that endpoint’s metadata (RFC 9728), for example https://app.qrsalt.com/.well-known/oauth-protected-resource/api/v1.
It names the authorization server, whose own metadata is at https://app.qrsalt.com/.well-known/oauth-authorization-server (RFC 8414).
Your client. Register it with POST /oauth/register (RFC 7591), or use the https address of your client metadata document as the client_id. Every client is public: there is no client secret, and PKCE with S256 is required.
Redirect URIs must use https, or http on 127.0.0.1, [::1] or localhost, and must match exactly. A loopback redirect may use any port.
The one other address accepted is Cursor’s desktop callback, cursor://anysphere.cursor-mcp/oauth/callback, exactly as written.
Consent. Send the person to /oauth/authorize with response_type=code, client_id, redirect_uri, state, code_challenge, code_challenge_method=S256, scope and resource, which is https://app.qrsalt.com/api/v1 or https://app.qrsalt.com/api/mcp.
They sign in, choose a workspace where they are an owner or an admin, and tick the scopes. delete is never ticked for them, and the plan limits the rest as it does for a key.
The redirect back carries code, state and iss.
Tokens. Exchange the code at POST /oauth/token within 60 seconds, with the code_verifier. The access token lasts an hour and works only on the resource it was issued for.
The refresh token lasts 90 days and is replaced each time you use it. Using a replaced one again revokes the connection.
POST /oauth/revoke ends a connection, and so does Revoke under Dashboard → API → Connected apps.
A token acts as the person who approved it, with the same scopes, plan limits and rate limits as a key. It stops working if they leave the workspace or stop being an owner or admin.
A token without the scope a call needs gets a 403 whose WWW-Authenticate header says error="insufficient_scope" and names the scope to ask for.
Example
https://app.qrsalt.com/oauth/authorize?response_type=code&client_id=qrc_...&redirect_uri=http%3A%2F%2F127.0.0.1%3A8787%2Fcallback&state=...&code_challenge=...&code_challenge_method=S256&scope=render%20read%20write&resource=https%3A%2F%2Fapp.qrsalt.com%2Fapi%2Fv1Quickstart
0. Or skip all of this: curl -o qr.svg "https://app.qrsalt.com/api/qr/free?data=https://example.com" needs no key at all. The small print.
1. Create a key in Dashboard → API on any plan, and keep it in an environment variable, QRSALT_KEY.
2. Render a static QR code as a PNG:
- Create a dynamic code (Starter and above). The answer carries its
id,shortUrland animageURL. - Fetch that code as an image in any format, or change its destination later with
PATCH.
Example
curl -sS \
'https://app.qrsalt.com/api/qr?type=url&url=https%3A%2F%2Fexample.com&format=png' \
-H "Authorization: Bearer $QRSALT_KEY" \
-o qr.pngErrors
Errors use HTTP status codes and a JSON body with a stable code and a readable message, at the top level.
Every endpoint sends those two fields the same way: the keyed ones under /api/v1, the render endpoints and the keyless ones. One error handler that reads code and message works for all of them.
Older integrations read a field called error, so it is still sent, in two shapes.
Under /api/v1 it is an object repeating code and message and carrying anything extra: suggestedPlan on a 402, resetAt on a 429, scope or scopes on a 403.
A refusal a plan would lift (plan_required, limit_reached, and quota_exceeded below the top plan) also carries upgrade_url, inside error and at the top level. It is the page where that plan is bought, with the plan already chosen.
On the render and keyless endpoints error is the sentence itself, the same string as message. Sometimes a hint sits beside it, with a docs link to the section on the parameter you got wrong.
A blank string in an optional field means "not set", never an error: on create the default is used, on update the field is left alone.
Two fields are exceptions, because a blank there has a meaning: folderId (no folder) and slug on Update (back to the short code). Required fields also refuse a blank.
Paths under /api/v1 that do not exist answer with this body too. GET /api/v1/code (missing the s) gets a 404 with code: "not_found", never an HTML page.
That check runs before your key is read, so a typo does not count against your rate limit. A method an endpoint does not have is different: PUT /api/v1/codes/{id} answers 405 Method Not Allowed with an empty body.
Example
{
"error": {
"code": "limit_reached",
"message": "Pro includes 600 dynamic codes.",
"suggestedPlan": "BUSINESS"
},
"code": "limit_reached",
"message": "Pro includes 600 dynamic codes."
}Errors
| 400 | Render only: a parameter is wrong. The body names it. |
| 401 | No key, or a key that is not valid. |
| 402 | Your plan does not include this, or an allowance is used up. The body names the plan that has it in suggestedPlan, and upgrade_url is where to buy it. |
| 403 | Your key does not carry the scope this needs, your role cannot do this (domains need an owner or admin), or custom domains are not set up yet. |
| 404 | No such code, domain, folder or form in your workspace. Another workspace's id is a 404 too. |
| 409 | A link ending or domain is already taken, or the workspace is paused. |
| 422 | The body or a field is invalid. The message says which. |
| 429 | Too many requests for this key (rate_limited), or the workspace has used its API requests for the month (quota_exceeded). Try again after resetAt. |
Rate limits
A key on Starter or above can make 600 requests a minute. On a plan without API access a key can only render, and gets 60 a minute.
The count is per key, so a nightly import that uses its whole limit does not slow down a different key that your workflow tool uses.
The keyless endpoints count units of work, not requests: an SVG costs 1 unit, a PNG up to 256 pixels 2, a larger PNG 4. One caller gets 480 units an hour, and one address 4,800.
On n8n Cloud, in an office or on a campus network, many callers share one address and so share its 4,800 units an hour.
Send an X-QRSalt-Client header, 1 to 64 characters of A-Za-z0-9._-, and your calls get a budget of their own inside that ceiling.
We do not check the value, so it only splits the shared budget more fairly and gives no other access. A key gives you a limit that only you can use.
Going over answers 429 with resetAt, the time the count starts again, and a Retry-After header saying how many seconds that is.
Each workspace also has a monthly number of requests: 500 API requests a month on Starter, 5,000 on Pro and 100,000 on Business, a fair-use ceiling.
Every call under /api/v1 counts, and so does every tool call over the MCP server. The render and keyless endpoints don't.
The month is the calendar month in UTC, so the count starts again at 00:00 UTC on the 1st.
Every counted response carries X-Quota-Limit, X-Quota-Remaining and X-Quota-Reset, and Check a key shows the same numbers as quota.
When the month is used up, calls answer 429 with code: "quota_exceeded", the limit, resetAt (the first of next month) and a Retry-After header.
On Starter and Pro the answer also names the suggestedPlan and its upgrade_url. The per-minute limit above still applies on every plan.
The same parameters always give the same image, so cache a code you use more than once instead of rendering it on every page view.
Check a key
GET /api/v1/me · any key
Answers with the workspace a key acts for, and what that workspace is allowed to do. Call it when somebody pastes a key in, to tell them straight away whether it works.
It also shows what the plan includes. Reading and changing the account starts on Starter, while webhooks start on Business, so a valid key may still be unable to subscribe to scans.
Read features and offer only what the plan has, so a subscribe call doesn't fail and look like a broken key.
key.scopes is the other half. Even on a plan with everything, a key without delete in that list gets a 403 from DELETE /api/v1/codes/{id}. Check both before you tell anyone their key is broken: it may just be missing a scope.
key.scopes is always what the key can do right now: what it was made with, narrowed to what the workspace’s plan sells today.
A key made on Starter keeps its permissions when the workspace goes back to Free, listed separately as key.granted and key.dormant. Dormant ones answer 402 until the plan returns, then work again with the same key.
It needs no scope, so even a render-only key can call it to check that it works. Nothing in the reply is secret: it shows the key’s prefix, which the dashboard shows too, never the key itself.
key.kind is api_key for a key and oauth for an app signed in with QRSalt, which has no prefix, so key.prefix is null for it.
Response · 200
{
"data": {
"workspace": { "id": "ws_7Qc2", "name": "Northside Cafe", "plan": "STARTER", "planName": "Starter", "status": "ACTIVE" },
"key": { "prefix": "qr_live_a1b2c3", "kind": "api_key", "scopes": ["render", "read", "write"], "granted": ["render", "read", "write"], "dormant": [] },
"features": {
"webhooks": false,
"advancedAnalytics": true,
"gpsTracking": true,
"csvExport": true,
"bulkImport": true,
"formBuilder": true,
"customDomains": 1
},
"rateLimit": { "limit": 600, "remaining": 599, "windowSeconds": 60, "resetAt": "2026-09-01T12:01:00.000Z" }
},
"meta": {}
}Render a QR code without a key
GET /api/qr/free · no key
Returns a QR code as an SVG or a PNG, with no key and no account. Use it to try the API from your own code before you decide anything.
Send data with the text to encode; text works too. There is no type here: build the Wi-Fi or contact-card string yourself, or use GET /api/qr with a key, which does it for you.
It has far fewer options than the keyed endpoint: SVG and PNG only, up to 512 pixels, up to 512 bytes of content, and only colour, margin and error correction for design.
Every other parameter of /api/qr (styles, eye shapes, logos, JPG, WebP, PDF, larger sizes) gets a 400 that names it. You find out on the first call instead of getting an image without the design you asked for.
The allowance is hourly and counted in units of work, not requests. Two limits apply: one for the caller and one for the address it comes from.
Rate limits has the numbers, and the X-QRSalt-Client header for callers behind a shared address. To get past them, use a Free key: it renders everything GET /api/qr renders, with a limit nobody else can spend.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
| data | string, up to 512 bytes | yes | The exact text to encode. text works too. |
| format | svg | png | no | Default svg. |
| size | 64 - 512 | no | Width in pixels. Default 512. |
| color | hex | no | Module colour. Default 000000. |
| bgcolor | hex | no | Background colour. Default FFFFFF. |
| ecc | L | M | Q | H | no | Error correction. Default M. |
| margin | 0 - 20 | no | Quiet zone in modules. Default 4. |
Request
curl -o qr.png "https://app.qrsalt.com/api/qr/free?data=https://example.com&format=png"Response · 200 OK
The image itself, as image/svg+xml or image/png.Errors
| 400 | A parameter is wrong, or one that needs a key. message names it and hint says where it lives. code: "invalid_input". |
| 429 | This address has used its allowance for the hour. Retry-After says how long to wait. code: "rate_limited". |
Read a code out of an image
POST /api/qr/decode · no key
Post an image and get back the text stored in the code. Like the free render, it needs no key and no account.
Post the file as the request body, or as a file field in a multipart form. PNG, JPEG and WebP, up to 12 MB, which fits a full-resolution phone photo.
Anything larger is answered 413 rather than truncated. The type is decided by the first bytes of the file, never by a header or a filename.
The image is read and then discarded. It is held in memory for the decode and dropped: no file is written, no row is created, and neither the picture nor its text is logged.
The answer is sent no-store, so it is not cached on the way back either.
There is no parameter for a URL. If we fetched any address you sent, anyone could make our servers open addresses inside our own network (server-side request forgery). If your image is behind a link, download it and post the bytes.
Before it is read the picture is shrunk so its longest side is 2048 pixels. A file whose real dimensions are over 24 megapixels is refused, so a small file can't expand into a huge image in memory.
A read costs 8 units of the keyless allowance, and one more for each extra symbology asked for. Rate limits has the numbers.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
| formats | comma-separated, or all | no | Which symbologies to look for. Default qr. The rest are code128, code39, ean13, ean8, itf, datamatrix, pdf417 and aztec. A name outside that list is refused by name. |
Request
curl -X POST --data-binary @code.png -H 'Content-Type: image/png' "https://app.qrsalt.com/api/qr/decode?formats=all"Response · 200 OK
{
"data": {
"text": "https://example.com/spring-menu",
"format": "QRCode",
"version": 2,
"errorCorrection": "M",
"mask": 3
}
}Errors
| 400 | No image in the request, or an upload we could not read. code: "invalid_input". |
| 413 | The image is larger than 12 MB. code: "too_large". |
| 415 | The file is not a PNG, JPEG or WebP. code: "unsupported_media". |
| 422 | No code of the kinds asked for was found in the image. code: "no_code_found". |
| 429 | This address has used its allowance for the hour. Retry-After says how long to wait. code: "rate_limited". |
| 503 | Reading that image took too long. code: "timeout". |
Render a QR code
GET /api/qr · any key · render scope
Returns a static QR code image. Send a type and that type's fields, or data with the exact text to encode. The code is not stored and has no short link; to make one you can edit later, create a dynamic code.
The API returns the QR code alone. frame and label are refused with a 400, so an integration that still sends them finds out on its first call.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
| type | url | text | wifi | email | phone | sms | vcard | gs1 | review | location | event | payment | no | What the code holds. contact and product also work, for vcard and gs1. Required unless you send data. |
| data | string, up to 2000 bytes | no | The exact text to encode, instead of a type. |
| format | svg | png | jpg | webp | pdf | no | File format. Default svg. jpeg works for jpg. |
| size | 64 - 2000 | no | Width in pixels. Default 512. Not used by PDF. |
| mm | 5 - 1000 | no | Printed width of the PDF in millimetres. Default 40. |
| color | hex | no | Module colour. Default 000000. |
| bgcolor | hex | no | Background colour. Default FFFFFF. |
| eyecolor | hex | no | Colour of the three corner squares. |
| style | square | rounded | dots | classy | no | Module shape. Also sets the corner shape unless eyes does. |
| eyes | square | rounded | circle | no | Corner square shape. |
| eyeframe | square | rounded | circle | leaf | leaf-flip | drop | no | Outer ring of the corners, on its own. |
| eyeball | square | rounded | circle | leaf | leaf-flip | drop | no | Centre of the corners, on its own. |
| eyeturn | 0 | 180 | no | Turn the corners, in degrees. |
| ecc | L | M | Q | H | no | Error correction. Default M. Use H under a logo or for rough surfaces. |
| margin | 0 - 20 | no | Quiet zone in modules. Default 4. |
type=url - Website
| Name | Type | Required | Description |
|---|---|---|---|
| url | string, up to 2048 | yes | Where should this code go? |
type=text - Plain text
| Name | Type | Required | Description |
|---|---|---|---|
| text | string, up to 2000 | yes | Text |
type=wifi - Wi-Fi network
| Name | Type | Required | Description |
|---|---|---|---|
| ssid | string, up to 64 | yes | Network name (SSID) |
| password | string, up to 128 | no | Password |
| encryption | WPA | WEP | nopass | no | Security |
| hidden | boolean | no | Hidden network |
type=email - Email
| Name | Type | Required | Description |
|---|---|---|---|
| string, up to 200 | yes | Email address | |
| subject | string, up to 200 | no | Subject |
| body | string, up to 1000 | no | Message |
| cc | string, up to 500 | no | Cc |
| bcc | string, up to 500 | no | Bcc |
type=phone - Phone number
| Name | Type | Required | Description |
|---|---|---|---|
| phone | string, up to 40 | yes | Phone number |
| extension | string, up to 16 | no | Extension |
type=sms - SMS message
| Name | Type | Required | Description |
|---|---|---|---|
| phone | string, up to 40 | yes | Phone number |
| message | string, up to 500 | no | Pre-filled message |
type=vcard - Contact card
| Name | Type | Required | Description |
|---|---|---|---|
| firstName | string, up to 100 | yes | First name |
| lastName | string, up to 100 | no | Last name |
| organization | string, up to 120 | no | Organisation |
| title | string, up to 120 | no | Job title |
| phone | string, up to 40 | no | Mobile |
| string, up to 200 | no | ||
| website | string, up to 300 | no | Website |
| workPhone | string, up to 40 | no | Work phone |
| street | string, up to 200 | no | Street |
| city | string, up to 100 | no | City |
| region | string, up to 100 | no | Region or state |
| postalCode | string, up to 24 | no | Postcode |
| country | string, up to 100 | no | Country |
| note | string, up to 500 | no | Note |
type=gs1 - GS1 Digital Link
| Name | Type | Required | Description |
|---|---|---|---|
| domain | string, up to 200 | yes | Your domain |
| gtin | string, up to 20 | yes | GTIN |
| batch | string, up to 20 | no | Batch or lot |
| serial | string, up to 20 | no | Serial |
| expiry | date, YYYY-MM-DD | no | Expiry date |
| production | date, YYYY-MM-DD | no | Production date |
type=review - Review request
| Name | Type | Required | Description |
|---|---|---|---|
| platform | google | trustpilot | yelp | facebook | other | no | Where should the review go? |
| value | string, up to 500 | yes | Your Google review link |
type=location - Location
| Name | Type | Required | Description |
|---|---|---|---|
| query | string, up to 200 | no | Address or place name |
| latitude | string, up to 20 | no | Latitude |
| longitude | string, up to 20 | no | Longitude |
| mapsLink | string, up to 1000 | no | Or paste a maps link |
type=event - Calendar event
| Name | Type | Required | Description |
|---|---|---|---|
| title | string, up to 200 | yes | Event name |
| start | string, up to 16 | yes | Starts |
| end | string, up to 16 | no | Ends |
| timeZone | string, up to 64 | no | Time zone |
| allDay | boolean | no | All day |
| location | string, up to 300 | no | Where |
| description | string, up to 2000 | no | Details |
| url | string, up to 2048 | no | Link |
type=payment - Payment link
| Name | Type | Required | Description |
|---|---|---|---|
| provider | paypal | venmo | cashapp | revolut | wise | bmc | stripe | other | no | How do people pay you? |
| handle | string, up to 2048 | yes | Your PayPal.me name or link |
| payee | string, up to 80 | yes | Name people will see |
| amount | string, up to 12 | no | Amount |
| currency | | USD | EUR | GBP | CAD | AUD | NZD | CHF | NOK | SEK | DKK | PLN | CZK | JPY | INR | BRL | MXN | no | Currency |
| note | string, up to 200 | no | Note |
Request
curl -sS \
'https://app.qrsalt.com/api/qr?type=wifi&ssid=Cafe-Guest&password=espresso2026&format=png' \
-H "Authorization: Bearer $QRSALT_KEY" \
-o qr.pngResponse · 200 OK
The image itself, with Content-Type image/svg+xml, image/png, image/jpeg, image/webp or application/pdf.Errors
| 400 | A parameter is wrong. message names it, and code is invalid_input. See Errors. |
| 401 | No key, or a key that is not valid. |
| 429 | Too many requests for this key. |
Render a barcode
GET /api/barcode · any key · render scope
Draws a barcode in one of 12 symbologies and returns the image. Nothing is stored.
The data lives in the bars, so there is nothing to re-point once it is printed. If the destination might move, use a dynamic QR code.
gs1-datamatrix takes GS1 application identifiers in brackets, like (01)09506000134352(17)271231(10)LOT42, and writes the FNC1 and separators itself. gs1datamatrix, bwip-js's own name for it, is accepted too.
Each field is checked before anything is drawn: the check digit of a GTIN, SSCC or GLN, real dates in YYMMDD (day 00 allowed), length and character set, and which fields need which.
A refusal names the field, as in (01) Product number: The check digit should be 2, not 9.
The image is the symbol alone. The human-readable lines are yours to set, or come with the download from the generator.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
| data | string, up to 1200 | no | What the barcode holds. text and q are accepted too. |
| symbology | code128 | ean13 | ean8 | upca | code39 | itf14 | gs1-128 | isbn | datamatrix | gs1-datamatrix | pdf417 | aztec | no | Default code128. type is accepted too, and so are bwip-js's names such as gs1datamatrix. |
| format | svg | png | jpg | webp | pdf | no | Default svg. |
| mm | 5 - 1000 | no | Printed width of the PDF, in millimetres. Default 40. PNG, JPG and WebP ignore it; their width comes from the data and scale. |
| scale | 2 - 20 | no | Units per narrow bar. Default 2, rounded up to an even number, because an odd scale puts bar edges on half-units and single-unit bars stop scanning. There is no pixel width: a barcode is as wide as its data. |
| height | 4 - 200 | no | Bar height, for the strip symbologies only. Matrix symbologies such as Data Matrix are always square and refuse it. |
Request
curl -sS \
'https://app.qrsalt.com/api/barcode?symbology=ean13&data=5901234123457&format=png' \
-H "Authorization: Bearer $QRSALT_KEY" \
-o qr.pngResponse · 200 OK
The image itself, with Content-Type image/svg+xml, image/png, image/jpeg or image/webp.Errors
| 400 | A parameter is wrong, or the value is not one this symbology can carry. The body names it, and where a check digit is wrong it says which character the number should have ended in. |
| 401 | No key, or a key that is not valid. |
| 429 | Too many requests for this key. |
Create a dynamic code
POST /api/v1/codes · Starter · write scope
Saves a code to your account, as if you had made it in the dashboard: the same checks on the destination, the same plan limits and the same history. It works for every type.
Every type is dynamic by default. A website or product code redirects. A contact card, Wi-Fi, text, email, phone or SMS code opens a small page we host that does the thing, such as offering to call the number.
Either way you get a shortUrl, you can change the content later, and it counts against your plan's dynamic codes. Send kind: "STATIC" to put the content in the pattern instead.
The answer includes an image URL for the code's image, and a publicImage URL that opens without a key, for an email or a sheet. See Public image link.
An APP_STORE code is always dynamic. Its payload is appName, appStore (an apps.apple.com or other https link), googlePlay (a play.google.com details or other https link) and an optional fallback. Send at least one store link.
A scan from an iPhone or iPad goes to appStore, one from Android to googlePlay, and anything else to fallback. With no fallback, it opens a page we host with a button for each link.
A LOCATION code opens a map. Its payload is any of query (an address or place name), latitude and longitude together, or mapsLink (a Google Maps or Apple Maps link).
A static one holds a Google Maps link. A dynamic one sends an iPhone to Apple Maps and everything else to Google Maps.
An EVENT code adds a date to a calendar. Its payload is title and start (a wall-clock time, 2026-10-03T19:00), with timeZone (an IANA zone, default UTC), end (default an hour later), allDay, location, description and url.
A static one holds an iCalendar VEVENT with the times in UTC. A dynamic one opens a page we host with an .ics file, Google Calendar and Outlook.
A PAYMENT code opens a payment or tip page. Its payload is provider (paypal, venmo, cashapp, revolut, wise, bmc, stripe or other), handle, payee (the name people see), and an optional amount, currency and note.
handle is checked by that provider's own rules: a PayPal.me name, a Venmo username, a $cashtag, a Revtag, a wise.com/pay/me link, a Buy Me a Coffee page name, or a Stripe Payment Link.
A Stripe link is on buy.stripe.com, donate.stripe.com or the business's own https domain, and an other link must be https.
A dynamic one, the default, opens a page we host showing the payee beside the account before it hands over. A static one holds the provider's link itself. Every payment link is screened when the code is saved.
A PDF code needs a file uploaded in the dashboard, so it is made there. The API reads, renames, pauses, moves, files and deletes it like any other code.
Body
| Name | Type | Required | Description |
|---|---|---|---|
| type | URL | TEXT | WIFI | EMAIL | PHONE | SMS | VCARD | GS1 | REVIEW | LOCATION | EVENT | PAYMENT | APP_STORE | no | Default URL. |
| name | string, up to 120 | no | For your list. Named from the content when left out. |
| kind | DYNAMIC | STATIC | no | Default DYNAMIC. STATIC puts the content in the pattern: it works with no signal, and it cannot change. |
| destination | string, up to 2048 | no | For a website code: where it goes. A shortcut for payload.url. https:// is added if it is missing. |
| payload | object | no | The type's fields, with the same names as in Render. |
| folderId | string | no | Put the code in one of your folders. |
| design | object | no | foreground, background, eyeColor, moduleStyle, eyeStyle, eyeFrameStyle, eyeBallStyle, eyeRotation, errorCorrection, logoScale, quietZone. No frames. |
| rules | array | no | Smart routing for a dynamic code: send each scan somewhere different by time, place, device or language. See Smart routing. |
| output | both | link | no | What you are making. Default both: a QR code, with its short link when it is dynamic. link saves a short link instead: it needs a dynamic code, GET /api/v1/links lists it, and image is null. Get a code's image still works. |
| slug | string, 4 - 24 | no | A chosen ending, like spring-menu. With domain, on Starter and above; on our domain, Business. See Shorten a link for the rules. |
| gs1 | object | string | null | no | A GS1 Digital Link for the code: { "ai": "01", "value": "05012345678900" }, or a Digital Link URI to read the key out of. null removes it. Starter and above. |
| domain | string | null | no | One of your verified custom domains, by hostname, like go.yourbrand.com. The short link and QR code use it. Left out: your default domain. null: ours. Dynamic codes only. |
| tags | array of strings, up to 10 | no | Tag names. Replaces the code's tags; [] removes them. New names make new tags. |
| utm | object | null | no | Campaign tags added to the destination on each scan: source, medium, campaign, term, content, each up to 120 characters. null removes them. |
| status | ACTIVE | PAUSED | no | Create it paused. A paused code shows a neutral page. |
Request
curl -sS \
-X POST \
'https://app.qrsalt.com/api/v1/codes' \
-H "Authorization: Bearer $QRSALT_KEY" \
-H "Content-Type: application/json" \
-d '{"type":"VCARD","name":"Sales card","payload":{"firstName":"John","lastName":"Pork","email":"john.pork@example.com"}}'Response · 201 Created
{
"data": {
"id": "43f77a11-699c-4180-a173-88d7eb954e4e",
"name": "Table tents",
"kind": "DYNAMIC",
"type": "URL",
"status": "ACTIVE",
"slug": "B3NSWDb",
"output": "both",
"shortUrl": "https://qrsalt.com/B3NSWDb",
"shortCodeUrl": "https://qrsalt.com/B3NSWDb",
"customUrl": null,
"domain": null,
"warning": null,
"gs1": null,
"gs1Url": null,
"image": "https://app.qrsalt.com/api/v1/codes/43f77a11-699c-4180-a173-88d7eb954e4e/image",
"publicImage": "https://app.qrsalt.com/i/B3NSWDb/9sQw2mK7bV1xZr4NtHc6JpLd.png",
"destination": "https://example.com/menu",
"folderId": null,
"tags": [],
"utm": null,
"scanCount": 0,
"lastScanAt": null,
"createdAt": "2026-09-01T12:00:00.000Z",
"updatedAt": "2026-09-01T12:00:00.000Z"
},
"meta": {}
}Errors
| 402 | Your plan's dynamic codes are used up. The body names the plan that has room. |
| 409 | That link ending is already taken. |
| 422 | A field is invalid. |
Shorten a link
POST /api/v1/links · Starter · write scope
Makes a short link and saves it to your account. Underneath it is a dynamic code, so everything else works on it, and you can get its QR code whenever you want.
Change where it goes with Update, route each click with Smart routing, read its clicks with Code scans and remove it with Delete. It counts against your plan’s dynamic codes.
The id that comes back answers on /api/v1/links/{id} and on /api/v1/codes/{id} alike.
GET, PATCH and DELETE are the same endpoint under either name, so a shortener integration never has to leave the links path.
Send slug to pick the ending, as in qrsalt.com/autumn-menu. On our domain that needs Business; on your own domain, Starter and above. Leave slug out and you get a random one.
An ending is 4 to 24 characters of lowercase letters, digits and hyphens, starts and ends with a letter or digit, and can't be one of our page names such as pricing.
A 7 to 10 character ending must contain a hyphen or one of 0, 1, l or o, because automatic links use that length. Once set, the ending never changes.
With domain, the link is on your custom domain, and an ending only has to be free there: go.yourbrand.com/menu works even if someone else has menu on ours. There, only a few words are kept for us, like help and pricing.
A code on your domain has two links. customUrl is the one on your domain; shortCodeUrl is its short code on ours, which always works and also answers on your domain.
shortUrl is the one to print: the custom link while your domain works, otherwise the short code, and the QR image follows it. If your domain stops verifying, warning says so, and the custom link comes back when the domain is fixed.
List your links with GET /api/v1/links, newest first, with limit (1 - 100, default 25), offset and q to search names and addresses. It answers with short links made here (output: "link") and nothing else.
List codes answers with everything you have made. Its output=link filter returns every code that has a short link, which is every dynamic code. For the QR code of any link, use Get a code's image.
Body
| Name | Type | Required | Description |
|---|---|---|---|
| url | string, up to 2048 | yes | Where the link goes: an http or https address, with https:// added if it is missing. Screened like every destination. |
| name | string, up to 120 | no | For your list. Named from the address when left out. |
| slug | string, 4 - 24 | no | A chosen ending. On your domain: Starter and above. On ours: Business. |
| domain | string | null | no | One of your verified custom domains, by hostname. Left out: your default domain. null: ours. |
| folderId | string | no | Put the link in one of your folders. |
| rules | array | no | Smart routing. See Smart routing. |
| tags | array of strings, up to 10 | no | Tag names. Replaces the code's tags; [] removes them. New names make new tags. |
| utm | object | null | no | Campaign tags added to the destination on each scan: source, medium, campaign, term, content, each up to 120 characters. null removes them. |
| status | ACTIVE | PAUSED | no | Create it paused. A paused code shows a neutral page. |
Request
curl -sS \
-X POST \
'https://app.qrsalt.com/api/v1/links' \
-H "Authorization: Bearer $QRSALT_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/autumn-menu","name":"Instagram bio","slug":"autumn-menu"}'Response · 201 Created
{
"data": {
"id": "41b5d347-02de-4cc4-afb3-2e9910ca0efe",
"name": "Instagram bio",
"kind": "DYNAMIC",
"type": "URL",
"status": "ACTIVE",
"slug": "autumn-menu",
"output": "link",
"shortUrl": "https://qrsalt.com/autumn-menu",
"shortCodeUrl": "https://qrsalt.com/autumn-menu",
"customUrl": null,
"domain": null,
"warning": null,
"gs1": null,
"gs1Url": null,
"image": null,
"publicImage": "https://app.qrsalt.com/i/autumn-menu/9sQw2mK7bV1xZr4NtHc6JpLd.png",
"destination": "https://example.com/autumn-menu",
"folderId": null,
"tags": [],
"utm": null,
"scanCount": 0,
"lastScanAt": null,
"createdAt": "2026-09-01T12:00:00.000Z",
"updatedAt": "2026-09-01T12:00:00.000Z"
},
"meta": {}
}Errors
| 402 | Your plan's dynamic codes are used up, or a chosen ending needs a plan that has them. |
| 409 | That ending is already taken. |
| 422 | The address or the ending is not valid. The message says which rule. |
Get a code
GET /api/v1/codes/{id} · Starter · read scope
One of your codes, with its short link and scan count. Another workspace's id is a 404.
scanCount and lastScanAt come from a summary we update every few minutes, so they can be up to about five minutes behind. That way they always match Code scans and Workspace analytics.
Request
curl -sS \
'https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10' \
-H "Authorization: Bearer $QRSALT_KEY"Response · 200 OK
{
"data": {
"id": "d7a4c2e9-1b3f-4e8a-b6d5-0f2c9e7a1b84",
"name": "Instagram bio - autumn beans",
"kind": "DYNAMIC",
"type": "URL",
"status": "ACTIVE",
"slug": "autumn-beans",
"output": "link",
"shortUrl": "https://qrsalt.com/autumn-beans",
"shortCodeUrl": "https://qrsalt.com/autumn-beans",
"customUrl": null,
"domain": null,
"warning": null,
"gs1": null,
"gs1Url": null,
"image": null,
"publicImage": "https://app.qrsalt.com/i/autumn-beans/9sQw2mK7bV1xZr4NtHc6JpLd.png",
"destination": "https://fieldhousecoffee.example/autumn",
"folderId": null,
"tags": [],
"utm": null,
"scanCount": 418,
"lastScanAt": "2026-09-01T10:00:00.000Z",
"createdAt": "2026-08-23T12:00:00.000Z",
"updatedAt": "2026-08-25T12:00:00.000Z"
},
"meta": {}
}Errors
| 404 | No code with that id in your workspace. |
Get a code's image
GET /api/v1/codes/{id}/image · Starter · read scope
Your code as an image, with its saved colours, shapes and logo, and no frame. A dynamic code encodes its short link (on your custom domain if it has one); a static code encodes its content.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
| format | svg | png | jpg | webp | pdf | no | Default svg. |
| size | 64 - 2000 | no | Width in pixels. Default 512. |
| mm | 5 - 1000 | no | Printed width of the PDF. Default 40. |
Request
curl -sS \
'https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10/image?format=png&size=1024' \
-H "Authorization: Bearer $QRSALT_KEY" \
-o qr.pngResponse · 200 OK
The image itself, with the Content-Type of the format.Errors
| 404 | No code with that id. |
| 422 | format, size or mm is out of range. |
A code’s public image link
GET /i/{slug}/{token}.png · no key
Every code comes back with a publicImage address beside image. It is the same picture at a link that opens for anyone, with no key and no Authorization header.
Use image from a program that can send your key. Use publicImage in an email, a spreadsheet cell, a Slack message or an <img src>, where nothing can send a header.
You never build this address yourself. It contains a signature, so nobody can guess it from a short link ending.
The finished URL comes back in publicImage on every code you create, get or list. It is the same address every time, so it is safe to write into a sheet.
Treat it as public. Anyone you give it to can open it and pass it on. It renders one picture and does nothing else: no name, no destination, no scan count, no JSON. The address ends .png or .svg, and size sets the width.
It stops working when the code is deleted or disabled. Delete a code and the link answers 404 from that moment, and its ending is never given to another code. A code we have disabled for abuse stops too.
A paused code keeps rendering: pausing stops the redirect, but the printed code is unchanged, so its image still works.
Because it needs no key, it has the same caps as the keyless endpoints: PNG and SVG only, up to 512 pixels, spending the same hourly allowance as the free render. For JPG, WebP, PDF and larger sizes, use the keyed endpoint.
The answer is cached publicly for five minutes behind an ETag, and carries X-Robots-Tag: noindex.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
| size | 64 - 512 | no | Width in pixels. Default 512. |
Request
curl -o qr.png "https://app.qrsalt.com/i/{slug}/{token}.png"Response · 200 OK
The image itself, as image/png or image/svg+xml.Errors
| 404 | The signature does not match, the code has been deleted, or we have disabled it. All three get the same answer, so nobody can use this link to find out which codes exist. |
| 429 | This address has used its keyless allowance for the hour. Retry-After says how long to wait. |
List codes
GET /api/v1/codes · Starter · read scope
Everything you have made, newest first, a page at a time: QR codes and short links together. meta.nextOffset is the offset of the next page, or null. For the short links on their own, use GET /api/v1/links under Shorten a link.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
| limit | 1 - 100 | no | Codes per page. Default 25. |
| offset | number | no | Codes to skip. |
| q | string, up to 200 | no | Search names and destinations. |
| folder | string | no | Only codes in this folder, by id. none: codes in no folder. folderId works too. |
| tag | string | no | Only codes with this tag, by name. Case does not matter. |
| domain | string | no | Only codes on this custom domain, by hostname. none: codes on our domain. |
| status | ACTIVE | PAUSED | DISABLED | ARCHIVED | no | Only codes with this status. A deleted code is erased, so it is never listed. ARCHIVED finds only codes deleted before we started erasing them. |
| output | link | qr | no | link: only codes with a short link, which is every dynamic code. Not the same as GET /api/v1/links, which lists only what was made as a short link. |
Request
curl -sS \
'https://app.qrsalt.com/api/v1/codes?limit=2' \
-H "Authorization: Bearer $QRSALT_KEY"Response · 200 OK
{
"data": [
{
"id": "d7a4c2e9-1b3f-4e8a-b6d5-0f2c9e7a1b84",
"name": "Instagram bio - autumn beans",
"kind": "DYNAMIC",
"type": "URL",
"status": "ACTIVE",
"slug": "autumn-beans",
"output": "link",
"shortUrl": "https://qrsalt.com/autumn-beans",
"shortCodeUrl": "https://qrsalt.com/autumn-beans",
"customUrl": null,
"domain": null,
"warning": null,
"gs1": null,
"gs1Url": null,
"image": null,
"publicImage": "https://app.qrsalt.com/i/autumn-beans/9sQw2mK7bV1xZr4NtHc6JpLd.png",
"destination": "https://fieldhousecoffee.example/autumn",
"folderId": null,
"tags": [],
"utm": null,
"scanCount": 418,
"lastScanAt": "2026-09-01T10:00:00.000Z",
"createdAt": "2026-08-23T12:00:00.000Z",
"updatedAt": "2026-08-25T12:00:00.000Z"
},
{
"id": "b52d0e98-7a13-4f6c-a2e5-9d8c1b7f4a36",
"name": "Wholesale price list",
"kind": "DYNAMIC",
"type": "URL",
"status": "PAUSED",
"slug": "Wp9mK3d",
"output": "both",
"shortUrl": "https://qrsalt.com/Wp9mK3d",
"shortCodeUrl": "https://qrsalt.com/Wp9mK3d",
"customUrl": null,
"domain": null,
"warning": null,
"gs1": null,
"gs1Url": null,
"image": "https://app.qrsalt.com/api/v1/codes/b52d0e98-7a13-4f6c-a2e5-9d8c1b7f4a36/image",
"publicImage": "https://app.qrsalt.com/i/Wp9mK3d/9sQw2mK7bV1xZr4NtHc6JpLd.png",
"destination": "https://fieldhousecoffee.example/wholesale",
"folderId": null,
"tags": [],
"utm": null,
"scanCount": 57,
"lastScanAt": "2026-08-26T06:00:00.000Z",
"createdAt": "2026-08-10T12:00:00.000Z",
"updatedAt": "2026-08-12T12:00:00.000Z"
}
],
"meta": {
"total": 4,
"limit": 2,
"offset": 0,
"nextOffset": 2
}
}Update or re-point a code
PATCH /api/v1/codes/{id} · Starter · write scope
Rename a code, change where a dynamic code points, pause and resume it, move it to a domain, change its custom link, file it, tag it or set its campaign tags. Send only what changes.
A printed dynamic code opens the new destination straight away. The old one is kept as a version you can roll back to in the dashboard.
Body
| Name | Type | Required | Description |
|---|---|---|---|
| name | string, up to 120 | no | A new name. |
| destination | string, up to 2048 | no | Where a dynamic website code should go. |
| payload | object | no | New content for another type, such as a contact card. |
| note | string, up to 200 | no | Why it changed, for the history. |
| status | ACTIVE | PAUSED | no | PAUSED shows a neutral page; ACTIVE resumes. To delete, use DELETE. |
| rules | array | no | Replaces the code's Smart routing rules. [] removes them. |
| domain | string | null | no | Move a dynamic code's short link to one of your verified domains, by hostname, or null for ours. Copies printed with the old domain stop working. Its link on our domain always works. |
| slug | string, 4 - 24 | no | The custom link on the code's domain: spring-menu gives go.yourbrand.com/spring-menu, and "" goes back to the short code. The code must be on your domain; send domain too to move it. Copies printed with the old link stop working. |
| gs1 | object | string | null | no | A GS1 Digital Link for the code: { "ai": "01", "value": "05012345678900" }, or a Digital Link URI to read the key out of. null removes it. Starter and above. |
| folderId | string | null | no | Move the code to one of your folders, or null for none. |
| tags | array of strings, up to 10 | no | Tag names. Replaces the code's tags; [] removes them. New names make new tags. |
| utm | object | null | no | Campaign tags added to the destination on each scan: source, medium, campaign, term, content, each up to 120 characters. null removes them. |
Request
curl -sS \
-X PATCH \
'https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10' \
-H "Authorization: Bearer $QRSALT_KEY" \
-H "Content-Type: application/json" \
-d '{"destination":"https://example.com/winter"}'Response · 200 OK
{
"data": {
"id": "d7a4c2e9-1b3f-4e8a-b6d5-0f2c9e7a1b84",
"name": "Instagram bio - autumn beans",
"kind": "DYNAMIC",
"type": "URL",
"status": "ACTIVE",
"slug": "autumn-beans",
"output": "link",
"shortUrl": "https://qrsalt.com/autumn-beans",
"shortCodeUrl": "https://qrsalt.com/autumn-beans",
"customUrl": null,
"domain": null,
"warning": null,
"gs1": null,
"gs1Url": null,
"image": null,
"publicImage": "https://app.qrsalt.com/i/autumn-beans/9sQw2mK7bV1xZr4NtHc6JpLd.png",
"destination": "https://example.com/winter",
"folderId": null,
"tags": [],
"utm": null,
"scanCount": 418,
"lastScanAt": "2026-09-01T10:00:00.000Z",
"createdAt": "2026-08-23T12:00:00.000Z",
"updatedAt": "2026-08-25T12:00:00.000Z"
},
"meta": {}
}Errors
| 402 | Tags, folders or campaign tags are not in your plan. |
| 404 | No code or folder with that id. |
| 409 | The ending is already taken on that domain. |
| 422 | Nothing to change, an invalid field, an unverified domain, a custom link on a code that is not on your domain, or a static code (its content is in the pattern). |
Delete a code
DELETE /api/v1/codes/{id} · Starter · delete scope
Your key needs the delete scope, and no key has it unless somebody ticked that box. You can’t undo this call, so only a key made with that permission can make it.
The code leaves your lists, stops counting toward your plan, and can’t be restored, because we erase the record. What comes back is the code as it was just before, with status ARCHIVED, the name this API has always used for a deleted code.
What happens to the printed code depends on its kind. Scans of a dynamic code come through us, so from then on they land on a not-found page. Its short link is never handed to anyone else.
A static code has its destination in the pattern and never touches our servers. Deleting it only removes our record, and every printed copy keeps working. If you’re writing a clean-up job, check kind first.
?confirm= is optional, but we check it when you send it: it has to be the code’s name or its short link ending, or nothing is deleted.
Send it when something else decides what to delete, such as an AI model acting on text it read. The MCP connector always sends it. A workflow you wrote yourself can leave it out.
Short links are codes too, so this deletes them. DELETE /api/v1/links/{id} is the same endpoint under the shortener’s own name, for callers that have only ever made links.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
| confirm | string | no | The code’s name or its short link ending. Optional; when it is present it has to match, or the request is refused and the code is left alone. |
Request
curl -sS \
-X DELETE \
'https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10?confirm=Spring+menu' \
-H "Authorization: Bearer $QRSALT_KEY"Response · 200 OK
{
"data": {
"id": "8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10",
"name": "Table tents - Pearl District",
"kind": "DYNAMIC",
"type": "URL",
"status": "ARCHIVED",
"slug": "Tb7kQ2x",
"output": "both",
"shortUrl": "https://qrsalt.com/Tb7kQ2x",
"shortCodeUrl": "https://qrsalt.com/Tb7kQ2x",
"customUrl": null,
"domain": null,
"warning": null,
"gs1": null,
"gs1Url": null,
"image": "https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10/image",
"publicImage": "https://app.qrsalt.com/i/Tb7kQ2x/9sQw2mK7bV1xZr4NtHc6JpLd.png",
"destination": "https://fieldhousecoffee.example/menu",
"folderId": null,
"tags": [],
"utm": null,
"scanCount": 1284,
"lastScanAt": "2026-09-01T11:00:00.000Z",
"createdAt": "2026-06-29T12:00:00.000Z",
"updatedAt": "2026-09-01T12:00:00.000Z"
},
"meta": {
"note": "Deleted: scans now get a not-found page. Deleted codes cannot be restored, and the short link is never reused."
}
}Errors
| 404 | No code with that id. |
| 422 | The confirm you sent is neither the name nor the short link ending. Nothing was deleted. |
GS1 Digital Link
A GS1 Digital Link is a URL that a till scanner and a phone camera can both read. The identifier sits in the path, so /01/05012345678900 names the product itself. You can use one here in two ways, and only the second costs money.
Free, on every plan. Put the Digital Link inside the pattern: send type: "GS1" with a payload, or call GET /api/qr?type=gs1 with any key, a free one included. The address is yours, and we store nothing.
Reading one back needs no key at all. POST /api/qr/decode splits GS1 content into its application identifiers, whether it arrives as a Digital Link, a GS1-128 or a GS1 DataMatrix.
Paid: a Digital Link we host and answer. Send gs1 when you create a code, or PATCH it onto an existing one, and the code answers on a second address of ours as well as its short link.
A printed pack then gets two things it can’t have otherwise: you can re-point it later, and its scans are counted. Starter and above.
The primary keys we host are 253, 255, 401, 402, 414, 417, 8003, 8004, 8006, 8010, 8013, 8017, 8018, 00, 01. We store a GTIN padded to 14 digits, so the 13 under a barcode and the 14 a scanner reads are one address.
Qualifiers go in qualifiers, keyed by AI (22, 10 and 21 after a GTIN), and come out in the order the standard fixes.
If the key has a check digit we verify it, and a wrong one is refused with the digit it should have ended in.
Each Digital Link belongs to one code, so if another workspace already hosts that key you get a 409. Removing one with gs1: null stops every printed copy resolving at once, so we honour ?confirm= on that request exactly as on DELETE.
We answer with a plain redirect. It isn’t a GS1-conformant resolver: there’s no linkset, no linkType and no /.well-known/gs1resolver yet, because each of those promises a set of links and a code has one destination.
Body
| Name | Type | Required | Description |
|---|---|---|---|
| gs1.ai | string | no | The GS1 primary key: 253, 255, 401, 402, 414, 417, 8003, 8004, 8006, 8010, 8013, 8017, 8018, 00, 01. |
| gs1.value | string | no | The identifier itself, as it is printed. |
| gs1.qualifiers | object | no | Qualifier values by AI, such as { "10": "LOT7", "21": "SER1" }. |
Example
curl -sS \
-X PATCH \
'https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10' \
-H "Authorization: Bearer $QRSALT_KEY" \
-H "Content-Type: application/json" \
-d '{"gs1":{"ai":"01","value":"5012345678900","qualifiers":{"10":"LOT7"}}}'Response · 200 OK
{
"data": {
"id": "d7a4c2e9-1b3f-4e8a-b6d5-0f2c9e7a1b84",
"name": "Instagram bio - autumn beans",
"kind": "DYNAMIC",
"type": "URL",
"status": "ACTIVE",
"slug": "autumn-beans",
"output": "link",
"shortUrl": "https://qrsalt.com/autumn-beans",
"shortCodeUrl": "https://qrsalt.com/autumn-beans",
"customUrl": null,
"domain": null,
"warning": null,
"gs1": "/01/05012345678900/10/LOT7",
"gs1Url": "https://qrsalt.com/01/05012345678900/10/LOT7",
"image": null,
"publicImage": "https://app.qrsalt.com/i/autumn-beans/9sQw2mK7bV1xZr4NtHc6JpLd.png",
"destination": "https://fieldhousecoffee.example/autumn",
"folderId": null,
"tags": [],
"utm": null,
"scanCount": 418,
"lastScanAt": "2026-09-01T10:00:00.000Z",
"createdAt": "2026-08-23T12:00:00.000Z",
"updatedAt": "2026-08-25T12:00:00.000Z"
},
"meta": {}
}Errors
| 402 | Hosted Digital Links start on Starter. |
| 409 | Another code already answers on that Digital Link. |
| 422 | The check digit is wrong, the key is not one we host, or the qualifier does not belong to it. |
Code scans
GET /api/v1/codes/{id}/scans · Starter · read scope
Daily scans and unique visitors for one code, and one optional breakdown. City and region breakdowns come with Pro and above.
The date range can’t go back further than your plan keeps scan history, and the answer says when the range was cut short.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
| from | date, YYYY-MM-DD | no | Default 30 days before to. At most 366 days. |
| to | date, YYYY-MM-DD | no | Default today. |
| dimension | country | region | city | device | os | browser | referrer | hour | weekday | no | One breakdown. Hours and weekdays are UTC; weekday runs 1 (Monday) to 7 (Sunday). |
Request
curl -sS \
'https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10/scans?from=2026-08-25&to=2026-08-31&dimension=country' \
-H "Authorization: Bearer $QRSALT_KEY"Response · 200 OK
{
"data": {
"codeId": "8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10",
"summary": {
"scans": 143,
"uniques": 119,
"firstScanAt": "2026-08-25T08:14:00.000Z",
"lastScanAt": "2026-08-31T17:42:00.000Z"
},
"series": [
{
"day": "2026-08-25",
"scans": 27,
"uniques": 22
},
{
"day": "2026-08-26",
"scans": 16,
"uniques": 13
},
{
"day": "2026-08-27",
"scans": 20,
"uniques": 17
},
{
"day": "2026-08-28",
"scans": 26,
"uniques": 22
},
{
"day": "2026-08-29",
"scans": 19,
"uniques": 16
},
{
"day": "2026-08-30",
"scans": 22,
"uniques": 18
},
{
"day": "2026-08-31",
"scans": 13,
"uniques": 11
}
],
"dimension": "country",
"breakdown": [
{
"value": "US",
"count": 56
},
{
"value": "CA",
"count": 32
},
{
"value": "GB",
"count": 23
},
{
"value": "NO",
"count": 18
},
{
"value": "JP",
"count": 14
}
]
},
"meta": {
"from": "2026-08-25",
"to": "2026-08-31",
"clamped": false
}
}Errors
| 402 | That breakdown is not in your plan. |
| 404 | No code with that id. |
| 409 | Analytics are paused while the subscription is inactive. |
Workspace analytics
GET /api/v1/analytics · Starter · read scope
Scans across every code in your workspace: the same numbers as the Analytics page in the dashboard, with the same plan rules.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
| from | date, YYYY-MM-DD | no | Default 30 days before to. At most 366 days. |
| to | date, YYYY-MM-DD | no | Default today. |
| dimension | country | region | city | device | os | browser | referrer | hour | weekday | no | One breakdown. Hours and weekdays are UTC; weekday runs 1 (Monday) to 7 (Sunday). |
Request
curl -sS \
'https://app.qrsalt.com/api/v1/analytics?from=2026-08-25&to=2026-08-31&dimension=device' \
-H "Authorization: Bearer $QRSALT_KEY"Response · 200 OK
{
"data": {
"summary": {
"scans": 711,
"uniques": 590,
"firstScanAt": "2026-08-25T08:14:00.000Z",
"lastScanAt": "2026-08-31T17:42:00.000Z"
},
"series": [
{
"day": "2026-08-25",
"scans": 82,
"uniques": 68
},
{
"day": "2026-08-26",
"scans": 112,
"uniques": 93
},
{
"day": "2026-08-27",
"scans": 103,
"uniques": 85
},
{
"day": "2026-08-28",
"scans": 102,
"uniques": 85
},
{
"day": "2026-08-29",
"scans": 101,
"uniques": 84
},
{
"day": "2026-08-30",
"scans": 125,
"uniques": 104
},
{
"day": "2026-08-31",
"scans": 86,
"uniques": 71
}
],
"dimension": "device",
"breakdown": [
{
"value": "mobile",
"count": 356
},
{
"value": "desktop",
"count": 208
},
{
"value": "tablet",
"count": 147
}
]
},
"meta": {
"from": "2026-08-25",
"to": "2026-08-31",
"clamped": false
}
}Errors
| 402 | That breakdown is not in your plan. |
| 409 | Analytics are paused while the subscription is inactive. |
Bulk actions
POST /api/v1/codes/bulk · Starter · write scope
Change up to 500 codes in one request, as the bulk bar on the Codes page does. Send ids and an action.
Each code is checked like a single change: another workspace's id, a deleted code or one under review is skipped with a reason, and the rest go ahead.
results has one entry per id. Webhooks go out for every changed code. Bulk requests are limited to 60 an hour per workspace, on top of the per-key limit.
action: "delete" needs the delete scope instead of write: the scope follows what the request does, not the HTTP method.
It needs no confirmation, because no key has the delete scope unless somebody ticked it. Every code it deletes is gone for good, and none of their short links is ever reused.
Body
| Name | Type | Required | Description |
|---|---|---|---|
| ids | array of code ids, 1 - 500 | yes | The codes to change. |
| action | domain | folder | tags | status | utm | delete | yes | What to do. Each takes the field below. |
Per action
| Name | Type | Required | Description |
|---|---|---|---|
| domain | string | null | no | action: "domain". A verified domain of yours, by hostname, or null for ours. A kept custom link is used again when it is free there; otherwise the code uses its short code. |
| folderId | string | null | no | action: "folder". One of your folders, or null for none. |
| add, remove | arrays of tag names | no | action: "tags". Tags to add and tags to take off. A code carries up to 10. |
| status | active | paused | no | action: "status", for example { "action": "status", "status": "paused" }. Pause or resume; either case works. Resuming more codes than your plan allows is refused as a whole. |
| presetId | string | no | action: "utm". A campaign preset. Only editable website codes take campaign tags. |
| confirm | string | no | action: "delete". Optional, and checked when you send it: delete 12 for 12 ids, the number matching. Leave it off and the delete goes ahead. Deleted codes cannot be restored. |
Request
curl -sS \
-X POST \
'https://app.qrsalt.com/api/v1/codes/bulk' \
-H "Authorization: Bearer $QRSALT_KEY" \
-H "Content-Type: application/json" \
-d '{"ids":["0b6f3c2a-9d41-4e7b-a8c5-1f2e3d4c5b6a","7c8d9e0f-1a2b-4c3d-9e4f-5a6b7c8d9e0f","c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f"],"action":"tags","add":["Spring"]}'Response · 200 OK
{
"data": {
"action": "tags",
"done": 2,
"unchanged": 0,
"skipped": 1,
"notes": [],
"results": [
{
"id": "0b6f3c2a-9d41-4e7b-a8c5-1f2e3d4c5b6a",
"ok": true
},
{
"id": "7c8d9e0f-1a2b-4c3d-9e4f-5a6b7c8d9e0f",
"ok": true
},
{
"id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
"ok": false,
"reason": "it no longer exists"
}
]
},
"meta": {}
}Errors
| 402 | Folders, tags or presets are not in your plan, or resuming would pass your plan's dynamic codes. |
| 404 | The folder or preset is not in your workspace. |
| 409 | Another code took one of the links just now. Try again. |
| 422 | The body is invalid, more than 500 ids, the wrong confirm, or a domain that is not verified. |
| 429 | Too many bulk requests this hour. |
Folders
GET /api/v1/folders · Starter · read scope
Your folders, with how many codes each holds. POST the same path with a name to make one. One folder is read, renamed and removed at /api/v1/folders/{id}.
File codes with folderId on Create and Update, or many at once with Bulk actions.
Request
curl -sS \
'https://app.qrsalt.com/api/v1/folders' \
-H "Authorization: Bearer $QRSALT_KEY"Response · 200 OK
{
"data": [
{
"id": "2e4c6a8b-0d1f-4a3c-9e5b-7d9f1b3d5f7a",
"name": "Spring menus",
"codes": 14,
"createdAt": "2026-08-12T10:00:00.000Z"
}
],
"meta": {
"total": 1
}
}Errors
| 402 | Folders are not in your plan. |
| 409 | A folder with that name exists. Names ignore case. |
| 422 | The name is empty or too long. |
Get a folder
GET /api/v1/folders/{id} · Starter · read scope
One folder, with how many codes it holds. To see the codes themselves, use List codes with folderId.
Request
curl -sS \
'https://app.qrsalt.com/api/v1/folders/2e4c6a8b-0d1f-4a3c-9e5b-7d9f1b3d5f7a' \
-H "Authorization: Bearer $QRSALT_KEY"Response · 200 OK
{
"data": {
"id": "2e4c6a8b-0d1f-4a3c-9e5b-7d9f1b3d5f7a",
"name": "Spring menus",
"codes": 14,
"createdAt": "2026-08-12T10:00:00.000Z"
},
"meta": {}
}Errors
| 404 | No folder with that id in your workspace. An id belonging to someone else and an id that is not an id both answer this. |
Rename a folder
PATCH /api/v1/folders/{id} · Starter · write scope
Changes the name. The codes in it stay where they are. A name your workspace already uses is refused rather than merged into.
Body
| Name | Type | Required | Description |
|---|---|---|---|
| name | string, 1 - 60 | yes | The new name. |
Request
curl -sS \
-X PATCH \
'https://app.qrsalt.com/api/v1/folders/2e4c6a8b-0d1f-4a3c-9e5b-7d9f1b3d5f7a' \
-H "Authorization: Bearer $QRSALT_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Autumn menus"}'Response · 200 OK
{
"data": {
"id": "2e4c6a8b-0d1f-4a3c-9e5b-7d9f1b3d5f7a",
"name": "Autumn menus",
"codes": 14,
"createdAt": "2026-08-12T10:00:00.000Z"
},
"meta": {}
}Errors
| 402 | Folders are not in your plan. |
| 404 | No folder with that id. |
| 409 | A folder with that name exists. Names ignore case. |
| 422 | The name is empty or longer than 60 characters, or the body carries a field that is not name. |
Remove a folder
DELETE /api/v1/folders/{id} · Starter · delete scope
Removes the folder. The codes in it are not deleted: they move to no folder in the same write, and keep their destination, their short link and their scans. codesUnfiled says how many moved. The dashboard can undo it for 24 hours.
Request
curl -sS \
-X DELETE \
'https://app.qrsalt.com/api/v1/folders/2e4c6a8b-0d1f-4a3c-9e5b-7d9f1b3d5f7a' \
-H "Authorization: Bearer $QRSALT_KEY"Response · 200 OK
{
"data": {
"id": "2e4c6a8b-0d1f-4a3c-9e5b-7d9f1b3d5f7a",
"deleted": true,
"codesUnfiled": 14
},
"meta": {
"note": "The folder is gone. The codes that were in it are unchanged and now sit in no folder."
}
}Errors
| 402 | Folders are not in your plan. |
| 404 | No folder with that id. |
Get a tag
GET /api/v1/tags/{id} · Starter · read scope
One tag, with how many codes carry it. For the codes themselves, use List codes with tag and the name.
Request
curl -sS \
'https://app.qrsalt.com/api/v1/tags/5b7d9f1a-3c5e-4a7b-9d1f-3a5c7e9b1d3f' \
-H "Authorization: Bearer $QRSALT_KEY"Response · 200 OK
{
"data": {
"id": "5b7d9f1a-3c5e-4a7b-9d1f-3a5c7e9b1d3f",
"name": "Spring",
"codes": 14
},
"meta": {}
}Errors
| 404 | No tag with that id in your workspace. An id belonging to someone else and an id that is not an id both answer this. |
Rename a tag
PATCH /api/v1/tags/{id} · Starter · write scope
Changes the word on every code that carries it. A name another tag already has is refused, not merged, because a merge would change which codes carry which tag and could not be undone. Delete one of the two tags instead.
Body
| Name | Type | Required | Description |
|---|---|---|---|
| name | string, 1 - 40 | yes | The new name. No commas: that’s how a list of tags is written. Anything past 40 characters is cut, as it is in the dashboard's tag box. |
Request
curl -sS \
-X PATCH \
'https://app.qrsalt.com/api/v1/tags/5b7d9f1a-3c5e-4a7b-9d1f-3a5c7e9b1d3f' \
-H "Authorization: Bearer $QRSALT_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Autumn"}'Response · 200 OK
{
"data": {
"id": "5b7d9f1a-3c5e-4a7b-9d1f-3a5c7e9b1d3f",
"name": "Autumn",
"codes": 14
},
"meta": {}
}Errors
| 402 | Tags are not in your plan. |
| 404 | No tag with that id. |
| 409 | There is already a tag with that name. Names are compared ignoring case. |
| 422 | The name is empty or contains a comma, or the body carries a field that is not name. |
Remove a tag
DELETE /api/v1/tags/{id} · Starter · delete scope
Removes the tag and takes the word off every code that carried it. Those codes are not deleted and nothing else about them changes.
codesUntagged says how many lost the word. If your workspace moves to a plan without tags, you can still remove the tags it has.
Request
curl -sS \
-X DELETE \
'https://app.qrsalt.com/api/v1/tags/5b7d9f1a-3c5e-4a7b-9d1f-3a5c7e9b1d3f' \
-H "Authorization: Bearer $QRSALT_KEY"Response · 200 OK
{
"data": {
"id": "5b7d9f1a-3c5e-4a7b-9d1f-3a5c7e9b1d3f",
"name": "Spring",
"deleted": true,
"codesUntagged": 14
},
"meta": {
"note": "The tag is gone. The codes that carried it are unchanged apart from losing the word."
}
}Errors
| 404 | No tag with that id. |
Campaign presets
GET /api/v1/utm-presets · Starter · read scope
The campaign-tag presets saved in the dashboard, for action: "utm" in Bulk actions. To set campaign tags on a single code, send the values in utm instead.
Request
curl -sS \
'https://app.qrsalt.com/api/v1/utm-presets' \
-H "Authorization: Bearer $QRSALT_KEY"Response · 200 OK
{
"data": [
{
"id": "8a0c2e4f-6b8d-4f1a-a3c5-e7a9c1e3a5c7",
"name": "Spring posters",
"utm": {
"source": "poster",
"medium": "print",
"campaign": "spring"
}
}
],
"meta": {
"total": 1
}
}List custom domains
GET /api/v1/domains · Starter · read scope
Your custom domains, with their status and the DNS records to add. meta.limit is how many your plan includes; meta.defaultDomain is the domain new codes get, or null for ours.
Each domain needs a TXT record that proves it is yours and a CNAME that points it at us. Add both at your DNS provider as dns shows them. See Custom domains for the whole flow.
Request
curl -sS \
'https://app.qrsalt.com/api/v1/domains' \
-H "Authorization: Bearer $QRSALT_KEY"Response · 200 OK
{
"data": [
{
"id": "6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14",
"hostname": "go.yourbrand.com",
"status": "VERIFIED",
"default": true,
"codes": 12,
"dns": [
{
"type": "TXT",
"name": "_qr-verify.go.yourbrand.com",
"value": "k3v9x2m7q8w1z5r4tq6y"
},
{
"type": "CNAME",
"name": "go.yourbrand.com",
"value": "your-cname-target.example"
}
],
"lastError": null,
"lastCheckedAt": "2026-09-01T11:55:00.000Z",
"nextCheckAt": "2026-09-02T11:55:00.000Z",
"verifiedAt": "2026-08-20T09:12:00.000Z",
"createdAt": "2026-08-20T09:00:00.000Z"
},
{
"id": "1d3f5b7d-9f1b-4d3f-8b5d-7f9b1d3f5b7d",
"hostname": "links.yourbrand.com",
"status": "PENDING",
"default": false,
"codes": 0,
"dns": [
{
"type": "TXT",
"name": "_qr-verify.links.yourbrand.com",
"value": "k3v9x2m7q8w1z5r4tq6y"
},
{
"type": "CNAME",
"name": "links.yourbrand.com",
"value": "your-cname-target.example"
}
],
"lastError": null,
"lastCheckedAt": null,
"nextCheckAt": null,
"verifiedAt": null,
"createdAt": "2026-08-20T09:00:00.000Z"
}
],
"meta": {
"total": 2,
"limit": 10,
"defaultDomain": "go.yourbrand.com"
}
}Add a domain
POST /api/v1/domains · Starter · write scope
Adds a domain you own, like go.yourbrand.com. It starts PENDING. Add the two records in dns; we check every few minutes, or check it now. Owners and admins only. How many domains you can add depends on your plan.
Body
| Name | Type | Required | Description |
|---|---|---|---|
| hostname | string, up to 253 | yes | Just the address: no https://, no path. A subdomain like go.yourbrand.com is best. |
Request
curl -sS \
-X POST \
'https://app.qrsalt.com/api/v1/domains' \
-H "Authorization: Bearer $QRSALT_KEY" \
-H "Content-Type: application/json" \
-d '{"hostname":"go.yourbrand.com"}'Response · 201 Created
{
"data": {
"id": "6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14",
"hostname": "go.yourbrand.com",
"status": "PENDING",
"default": false,
"codes": 0,
"dns": [
{
"type": "TXT",
"name": "_qr-verify.go.yourbrand.com",
"value": "k3v9x2m7q8w1z5r4tq6y"
},
{
"type": "CNAME",
"name": "go.yourbrand.com",
"value": "your-cname-target.example"
}
],
"lastError": null,
"lastCheckedAt": null,
"nextCheckAt": null,
"verifiedAt": null,
"createdAt": "2026-08-20T09:00:00.000Z"
},
"meta": {}
}Errors
| 402 | Your plan's domains are used up. The body names the plan with more. |
| 403 | Only an owner or admin can add domains, or custom domains are not set up yet. |
| 409 | Someone already added that domain. Contact support if it is yours. |
| 422 | Not a hostname, or one of ours. |
Get a domain
GET /api/v1/domains/{id} · Starter · read scope
One domain, with its status, records and the number of codes on it. lastError says why the last check failed.
Request
curl -sS \
'https://app.qrsalt.com/api/v1/domains/6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14' \
-H "Authorization: Bearer $QRSALT_KEY"Response · 200 OK
{
"data": {
"id": "6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14",
"hostname": "go.yourbrand.com",
"status": "VERIFIED",
"default": true,
"codes": 12,
"dns": [
{
"type": "TXT",
"name": "_qr-verify.go.yourbrand.com",
"value": "k3v9x2m7q8w1z5r4tq6y"
},
{
"type": "CNAME",
"name": "go.yourbrand.com",
"value": "your-cname-target.example"
}
],
"lastError": null,
"lastCheckedAt": "2026-09-01T11:55:00.000Z",
"nextCheckAt": "2026-09-02T11:55:00.000Z",
"verifiedAt": "2026-08-20T09:12:00.000Z",
"createdAt": "2026-08-20T09:00:00.000Z"
},
"meta": {}
}Errors
| 404 | No domain with that id in your workspace. |
Check DNS now
POST /api/v1/domains/{id}/check · Starter · write scope
Checks the records now: the same check as Check DNS now in the dashboard, once every 30 seconds per domain. meta.verified says whether it passed and meta.error what is missing.
Request
curl -sS \
-X POST \
'https://app.qrsalt.com/api/v1/domains/6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14/check' \
-H "Authorization: Bearer $QRSALT_KEY"Response · 200 OK
{
"data": {
"id": "6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14",
"hostname": "go.yourbrand.com",
"status": "VERIFIED",
"default": true,
"codes": 12,
"dns": [
{
"type": "TXT",
"name": "_qr-verify.go.yourbrand.com",
"value": "k3v9x2m7q8w1z5r4tq6y"
},
{
"type": "CNAME",
"name": "go.yourbrand.com",
"value": "your-cname-target.example"
}
],
"lastError": null,
"lastCheckedAt": "2026-09-01T11:55:00.000Z",
"nextCheckAt": "2026-09-02T11:55:00.000Z",
"verifiedAt": "2026-08-20T09:12:00.000Z",
"createdAt": "2026-08-20T09:00:00.000Z"
},
"meta": {
"verified": true,
"error": null
}
}Errors
| 404 | No domain with that id. |
| 409 | The workspace is paused. |
| 429 | Checked less than 30 seconds ago. Try again after resetAt. |
Set the default domain
PATCH /api/v1/domains/{id} · Starter · write scope
Make a verified domain the default: new codes and links get it unless you send domain. false puts new codes back on our domain. Codes you already made stay where they are. Owners and admins only.
Body
| Name | Type | Required | Description |
|---|---|---|---|
| default | boolean | yes | true to make it the default, false to stop. |
Request
curl -sS \
-X PATCH \
'https://app.qrsalt.com/api/v1/domains/6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14' \
-H "Authorization: Bearer $QRSALT_KEY" \
-H "Content-Type: application/json" \
-d '{"default":true}'Response · 200 OK
{
"data": {
"id": "6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14",
"hostname": "go.yourbrand.com",
"status": "VERIFIED",
"default": true,
"codes": 12,
"dns": [
{
"type": "TXT",
"name": "_qr-verify.go.yourbrand.com",
"value": "k3v9x2m7q8w1z5r4tq6y"
},
{
"type": "CNAME",
"name": "go.yourbrand.com",
"value": "your-cname-target.example"
}
],
"lastError": null,
"lastCheckedAt": "2026-09-01T11:55:00.000Z",
"nextCheckAt": "2026-09-02T11:55:00.000Z",
"verifiedAt": "2026-08-20T09:12:00.000Z",
"createdAt": "2026-08-20T09:00:00.000Z"
},
"meta": {}
}Errors
| 403 | Only an owner or admin can change the default. |
| 404 | No domain with that id. |
| 422 | The domain is not verified yet. |
Remove a domain
DELETE /api/v1/domains/{id} · Starter · delete scope
Removes a domain. Its codes move back to our domain, where their short code always worked; copies printed with this domain stop working. When codes use it, send confirm with the hostname, as the dashboard asks you to type it. Owners and admins only.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
| confirm | string | no | The domain's hostname. Required when codes use it. |
Request
curl -sS \
-X DELETE \
'https://app.qrsalt.com/api/v1/domains/6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14?confirm=go.yourbrand.com' \
-H "Authorization: Bearer $QRSALT_KEY"Response · 200 OK
{
"data": {
"id": "6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14",
"hostname": "go.yourbrand.com",
"deleted": true,
"codesMoved": 12
},
"meta": {
"note": "Removed. Its codes now use their short code on our domain; copies printed with this domain stop working."
}
}Errors
| 403 | Only an owner or admin can remove domains. |
| 404 | No domain with that id. |
| 422 | Codes use the domain and confirm is missing or not its hostname. |
QR Menus
GET /api/v1/pages · Starter · read scope
Your QR Menus, read-only: title, public address, whether it is published, and views. Build and edit them in the dashboard.
A menu's url is on qrsalt.com, which is the one address a menu has. The old app.qrsalt.com/p/… form is permanently redirected there, so anything already stored or shared keeps working.
Request
curl -sS \
'https://app.qrsalt.com/api/v1/pages' \
-H "Authorization: Bearer $QRSALT_KEY"Response · 200 OK
{
"data": [
{
"id": "4c6e8a0c-2e4a-4c6e-8a0c-2e4a6c8e0a2c",
"title": "Lunch menu",
"slug": "lunch-menu",
"url": "https://qrsalt.com/p/lunch-menu",
"published": true,
"disabled": false,
"blocks": 9,
"views": 1284,
"createdAt": "2026-07-02T08:00:00.000Z",
"updatedAt": "2026-08-30T15:20:00.000Z"
}
],
"meta": {
"total": 1
}
}QR Forms
GET /api/v1/forms · Starter · read scope
Your QR Forms, read-only, with their response counts. Read the answers with Form responses, or get each one as it arrives with the form.submitted webhook.
Request
curl -sS \
'https://app.qrsalt.com/api/v1/forms' \
-H "Authorization: Bearer $QRSALT_KEY"Response · 200 OK
{
"data": [
{
"id": "a3e9b7c1-2f4d-4a6b-8c0e-5d7f9a1b3c2e",
"title": "Event RSVP",
"slug": "event-rsvp",
"url": "https://app.qrsalt.com/f/event-rsvp",
"published": true,
"responses": 42,
"views": 310,
"createdAt": "2026-08-01T09:00:00.000Z",
"updatedAt": "2026-08-28T17:45:00.000Z"
}
],
"meta": {
"total": 1
}
}Form responses
GET /api/v1/forms/{id}/responses · Starter · read scope
A form's answers, newest first, a page at a time. answers is keyed by question id; meta.questions names each one. Answers are personal data your form collected, so keep them as carefully as you would the CSV.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
| limit | 1 - 100 | no | Responses per page. Default 25. |
| offset | number | no | Responses to skip. |
Request
curl -sS \
'https://app.qrsalt.com/api/v1/forms/a3e9b7c1-2f4d-4a6b-8c0e-5d7f9a1b3c2e/responses?limit=1' \
-H "Authorization: Bearer $QRSALT_KEY"Response · 200 OK
{
"data": [
{
"id": "9e1a3c5e-7a9c-4e1a-8c3e-5a7c9e1a3c5e",
"answers": {
"name": "John Pork",
"guests": 2
},
"country": "NO",
"createdAt": "2026-08-31T18:04:00.000Z"
}
],
"meta": {
"total": 42,
"limit": 1,
"offset": 0,
"nextOffset": 1,
"questions": [
{
"id": "name",
"kind": "text",
"label": "Your name",
"required": true
},
{
"id": "guests",
"kind": "number",
"label": "How many guests?",
"required": false
}
]
}
}Errors
| 404 | No form with that id in your workspace. |
Webhooks
A webhook is a web address on your server. We send it a message the moment something happens, like a scan or a form answer, so your other tools find out right away.
Webhooks come with Business. Add an endpoint in Dashboard → Webhooks, pick the events, and copy its signing secret.
Events
Subscribe an endpoint to any of these:
code.created- A code was createdcode.updated- A code was renamed or re-pointedcode.disabled- A code was paused, deleted or disabledscan.recorded- A code was scannedform.submitted- A QR Form was answered (the answers are included)
Payload
Each event is a POST of JSON to your URL, with x-qr-signature, x-qr-event-id (the same on every retry) and x-qr-event headers. Location is country, region and city only; no IP address leaves us.
In scan.recorded the place fields are country (an ISO 3166-1 country code, NO), regionCode and city (a name). Any of them can be null, and how precise they are depends on the phone’s network.
regionCode is the ISO 3166-2 subdivision code without its country prefix, such as 03 or CA. It is a code, which is why it is not called region.
form.submitted carries the answers, so your endpoint receives personal data your form collected (names, emails, phone numbers). Nothing identifies the person beyond what they typed.
Every question is listed in form order, with answer: null when it was skipped. An answer over 1,000 characters is cut and marked truncated; the full response is on the results page and in the CSV, matched by responseId.
Example
{
"id": "evt_8f3b2c1d9a7e6f5b",
"event": "scan.recorded",
"createdAt": "2026-09-10T09:41:07.000Z",
"data": {
"codeId": "c0de1d00-0000-4000-8000-000000000000",
"slug": "k3Tq9x",
"scannedAt": "2026-09-10T09:41:07.000Z",
"country": "NO",
"regionCode": "03",
"city": "Oslo",
"device": "mobile",
"os": "iOS",
"browser": "Safari",
"referrerHost": null
}
}Verifying signatures
Paste one of these into your server. It checks that a request is from us and less than five minutes old. Pass it the raw body, before you parse it, the x-qr-signature header and your endpoint's secret.
How it works: the header is t=<unix seconds>,v1=<hex>, and v1 is the HMAC-SHA256 of t + "." + body, keyed with the whole secret.
Example
import { createHmac, timingSafeEqual } from 'node:crypto'
// rawBody: the request body exactly as received, before JSON.parse.
function verify(rawBody, header, secret) {
let t = NaN
const offered = []
for (const part of String(header).split(',')) {
const [key, value] = part.split('=')
if (key === 't') t = Number(value)
if (key === 'v1' && value) offered.push(value)
}
// Reject anything older than five minutes: that is what stops a replay.
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false
const expected = createHmac('sha256', secret).update(t + '.' + rawBody).digest('hex')
return offered.some((v1) =>
v1.length === expected.length &&
timingSafeEqual(Buffer.from(v1), Buffer.from(expected)))
}Retries
Answer with any 2xx within 10 seconds. Anything else is a failure.
- We send at once, then retry after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 2 hours and 6 hours — 7 attempts across about 9 hours.
- A 4xx other than 408 or 429 is not retried. Redirects are not followed.
- After the last attempt, or on 410 Gone, the endpoint is switched off and the workspace owner is emailed.
- Delivery is at least once, so deduplicate on
x-qr-event-id.
Smart routing
Rules send the scans of one dynamic code to different places by time of day, country, region or city, device, device language, scan count, or an A/B split.
We check the list from the top. The first rule whose conditions all hold wins, and a scan no rule catches goes to the code’s own destination.
Set them with rules on Create or Update; [] removes them. They take effect within a minute on every printed copy, and we check every link in them like a destination.
You can’t route a static code: its content is in the pattern and never reaches us.
Device, country, language and dates are on every plan. Weekly schedules need Starter or above; region, city, scan counts and splits need Pro or above. A condition your plan doesn’t include gets a 402.
Location comes from the network the phone is on. Country is reliable, region and city are approximate, and a scan whose place we don’t know never matches a place condition.
Scan counts come from your analytics, a few minutes behind. In a split, somebody who rescans on the same day gets the same link as the first time.
A rule
| Name | Type | Required | Description |
|---|---|---|---|
| when | object | yes | Its conditions, below. Every one given must hold. {} matches every scan. |
| then | string | yes | Where a matching scan goes. With split, the first link. |
| split | array | no | 2 to 5 of { "percent": 50, "then": "https://…" }. Whole percentages that add up to 100. |
| label | string, up to 60 | no | Your name for it, shown in the code's scan breakdown. |
Conditions (`when`)
| Name | Type | Required | Description |
|---|---|---|---|
| os | ios | android | other | no | iPhones and iPads, Android phones, anything else. |
| device | phone | tablet | desktop | no | The kind of device. |
| country | array of ISO codes | no | For example ["US", "CA"]. Any one matches. |
| region | array of strings | no | Region or state as scans report it, usually a code like CA. Accents and case do not matter. |
| city | array of strings | no | City names, for example ["Oslo", "Bergen"]. |
| language | array of codes | no | The device's language, for example ["es"]. |
| after, before | ISO date-time | no | A date window: from after, until the moment before begins. |
| schedule | object | no | { "days": [1,2,3,4,5], "from": "07:00", "to": "11:00", "tz": "America/New_York" }. Days 0-6 from Sunday. to earlier than from runs overnight. |
| scansBelow | integer | no | While the code has had fewer scans than this: the first N. |
| scansFrom | integer | no | Once the code has had at least this many scans. |
Example
curl -sS \
-X PATCH \
'https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10' \
-H "Authorization: Bearer $QRSALT_KEY" \
-H "Content-Type: application/json" \
-d '{"rules":[{"label":"Spanish menu","when":{"language":["es"]},"then":"https://example.com/es/menu"},{"label":"Breakfast","when":{"schedule":{"days":[0,1,2,3,4,5,6],"from":"07:00","to":"11:00","tz":"America/New_York"}},"then":"https://example.com/menu/breakfast"},{"label":"iPhones","when":{"os":"ios"},"then":"https://apps.apple.com/app/id000000000"}]}'Errors
| 402 | A condition your plan does not include. The body names the plan that has it. |
| 422 | A rule cannot be read, one of its links is refused, or the code is static. |
Custom domains and custom links
Put your own domain on codes and links, like go.yourbrand.com/menu. Custom links on your domain come with Starter and above; a chosen ending on our domain needs Business.
- Add the domain.
- Add the
TXTandCNAMErecords fromdnsat your DNS provider. On Cloudflare, set the CNAME to DNS only. - Check it until
statusisVERIFIED, or wait: we check every few minutes. - Create codes and links with
domain, andslugfor the custom link. Or make it the default and leavedomainout. - Move codes you already have with
domainon Update, or many at once with Bulk actions. - A custom link only has to be free on your domain. Taken is a
409, a plan without custom links is a402, and a few words are kept for us, likehelp. - Every code on your domain keeps its short code on ours.
shortUrland the QR image use the custom link while the domain is verified. - If it stops verifying, they switch to the short code and
warningis set. The custom link comes back when the domain is fixed.
Example
curl -sS \
-X POST \
'https://app.qrsalt.com/api/v1/codes' \
-H "Authorization: Bearer $QRSALT_KEY" \
-H "Content-Type: application/json" \
-d '{"destination":"https://example.com/menu","domain":"go.yourbrand.com","slug":"menu"}'Response · 201 Created
{
"data": {
"id": "d7a4c2e9-1b3f-4e8a-b6d5-0f2c9e7a1b84",
"name": "Instagram bio - autumn beans",
"kind": "DYNAMIC",
"type": "URL",
"status": "ACTIVE",
"slug": "autumn-beans",
"output": "link",
"shortUrl": "https://go.yourbrand.com/menu",
"shortCodeUrl": "https://qrsalt.com/autumn-beans",
"customUrl": "https://go.yourbrand.com/menu",
"domain": "go.yourbrand.com",
"warning": null,
"gs1": null,
"gs1Url": null,
"image": null,
"publicImage": "https://app.qrsalt.com/i/autumn-beans/9sQw2mK7bV1xZr4NtHc6JpLd.png",
"destination": "https://example.com/menu",
"folderId": null,
"tags": [],
"utm": null,
"scanCount": 418,
"lastScanAt": "2026-09-01T10:00:00.000Z",
"createdAt": "2026-08-23T12:00:00.000Z",
"updatedAt": "2026-08-25T12:00:00.000Z"
},
"meta": {}
}No-code tools
Any tool with an HTTP step can call the API. Put your key in a header named Authorization with the value Bearer and your key, and use the method and URL from the endpoint you want.
- Make: add HTTP → Make a request with the same settings. Set the response to binary to save an image.
- n8n: add an HTTP Request node and a Header Auth credential for the key.
- Google Sheets or Airtable: trigger on a new row, create a dynamic code, then write its
shortUrlandimageback to the row.
Example
Method: POST
URL: https://app.qrsalt.com/api/v1/codes
Header: Authorization = Bearer <your API key>
Header: Content-Type = application/json
Body: {"type":"URL","name":"Row 42","destination":"https://example.com/42"}
Returns: JSONFormats and print
Use SVG for websites and design tools, PNG for documents and email, JPG when a tool asks for it, WebP for small web images, and PDF for print. SVG and PDF are vector, so they stay sharp at any size.
For print, send format=pdf and the printed width in mm. Use ecc=H when a logo or a rough surface covers part of the code.
Keep the default margin of 4 modules, so phones can find the code.
Examples in every language
The same request, a Wi-Fi QR code as a PNG, in each language. Keep the key in QRSALT_KEY.
Example
curl -sS \
'https://app.qrsalt.com/api/qr?type=wifi&ssid=Cafe-Guest&password=espresso2026&format=png' \
-H "Authorization: Bearer $QRSALT_KEY" \
-o qr.png