Skip to content

QR code API documentation

docs.md

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%2Fv1

Quickstart

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, shortUrl and an image URL.
  • 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.png

Errors

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

400Render only: a parameter is wrong. The body names it.
401No key, or a key that is not valid.
402Your 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.
403Your 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.
404No such code, domain, folder or form in your workspace. Another workspace's id is a 404 too.
409A link ending or domain is already taken, or the workspace is paused.
422The body or a field is invalid. The message says which.
429Too 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

NameTypeRequiredDescription
datastring, up to 512 bytesyesThe exact text to encode. text works too.
formatsvg | pngnoDefault svg.
size64 - 512noWidth in pixels. Default 512.
colorhexnoModule colour. Default 000000.
bgcolorhexnoBackground colour. Default FFFFFF.
eccL | M | Q | HnoError correction. Default M.
margin0 - 20noQuiet 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

400A parameter is wrong, or one that needs a key. message names it and hint says where it lives. code: "invalid_input".
429This 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

NameTypeRequiredDescription
formatscomma-separated, or allnoWhich 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

400No image in the request, or an upload we could not read. code: "invalid_input".
413The image is larger than 12 MB. code: "too_large".
415The file is not a PNG, JPEG or WebP. code: "unsupported_media".
422No code of the kinds asked for was found in the image. code: "no_code_found".
429This address has used its allowance for the hour. Retry-After says how long to wait. code: "rate_limited".
503Reading 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

NameTypeRequiredDescription
typeurl | text | wifi | email | phone | sms | vcard | gs1 | review | location | event | paymentnoWhat the code holds. contact and product also work, for vcard and gs1. Required unless you send data.
datastring, up to 2000 bytesnoThe exact text to encode, instead of a type.
formatsvg | png | jpg | webp | pdfnoFile format. Default svg. jpeg works for jpg.
size64 - 2000noWidth in pixels. Default 512. Not used by PDF.
mm5 - 1000noPrinted width of the PDF in millimetres. Default 40.
colorhexnoModule colour. Default 000000.
bgcolorhexnoBackground colour. Default FFFFFF.
eyecolorhexnoColour of the three corner squares.
stylesquare | rounded | dots | classynoModule shape. Also sets the corner shape unless eyes does.
eyessquare | rounded | circlenoCorner square shape.
eyeframesquare | rounded | circle | leaf | leaf-flip | dropnoOuter ring of the corners, on its own.
eyeballsquare | rounded | circle | leaf | leaf-flip | dropnoCentre of the corners, on its own.
eyeturn0 | 180noTurn the corners, in degrees.
eccL | M | Q | HnoError correction. Default M. Use H under a logo or for rough surfaces.
margin0 - 20noQuiet zone in modules. Default 4.

type=url - Website

NameTypeRequiredDescription
urlstring, up to 2048yesWhere should this code go?

type=text - Plain text

NameTypeRequiredDescription
textstring, up to 2000yesText

type=wifi - Wi-Fi network

NameTypeRequiredDescription
ssidstring, up to 64yesNetwork name (SSID)
passwordstring, up to 128noPassword
encryptionWPA | WEP | nopassnoSecurity
hiddenbooleannoHidden network

type=email - Email

NameTypeRequiredDescription
emailstring, up to 200yesEmail address
subjectstring, up to 200noSubject
bodystring, up to 1000noMessage
ccstring, up to 500noCc
bccstring, up to 500noBcc

type=phone - Phone number

NameTypeRequiredDescription
phonestring, up to 40yesPhone number
extensionstring, up to 16noExtension

type=sms - SMS message

NameTypeRequiredDescription
phonestring, up to 40yesPhone number
messagestring, up to 500noPre-filled message

type=vcard - Contact card

NameTypeRequiredDescription
firstNamestring, up to 100yesFirst name
lastNamestring, up to 100noLast name
organizationstring, up to 120noOrganisation
titlestring, up to 120noJob title
phonestring, up to 40noMobile
emailstring, up to 200noEmail
websitestring, up to 300noWebsite
workPhonestring, up to 40noWork phone
streetstring, up to 200noStreet
citystring, up to 100noCity
regionstring, up to 100noRegion or state
postalCodestring, up to 24noPostcode
countrystring, up to 100noCountry
notestring, up to 500noNote

type=gs1 - GS1 Digital Link

NameTypeRequiredDescription
domainstring, up to 200yesYour domain
gtinstring, up to 20yesGTIN
batchstring, up to 20noBatch or lot
serialstring, up to 20noSerial
expirydate, YYYY-MM-DDnoExpiry date
productiondate, YYYY-MM-DDnoProduction date

type=review - Review request

NameTypeRequiredDescription
platformgoogle | trustpilot | yelp | facebook | othernoWhere should the review go?
valuestring, up to 500yesYour Google review link

type=location - Location

NameTypeRequiredDescription
querystring, up to 200noAddress or place name
latitudestring, up to 20noLatitude
longitudestring, up to 20noLongitude
mapsLinkstring, up to 1000noOr paste a maps link

type=event - Calendar event

NameTypeRequiredDescription
titlestring, up to 200yesEvent name
startstring, up to 16yesStarts
endstring, up to 16noEnds
timeZonestring, up to 64noTime zone
allDaybooleannoAll day
locationstring, up to 300noWhere
descriptionstring, up to 2000noDetails
urlstring, up to 2048noLink

type=payment - Payment link

NameTypeRequiredDescription
providerpaypal | venmo | cashapp | revolut | wise | bmc | stripe | othernoHow do people pay you?
handlestring, up to 2048yesYour PayPal.me name or link
payeestring, up to 80yesName people will see
amountstring, up to 12noAmount
currency | USD | EUR | GBP | CAD | AUD | NZD | CHF | NOK | SEK | DKK | PLN | CZK | JPY | INR | BRL | MXNnoCurrency
notestring, up to 200noNote

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.png

Response · 200 OK

The image itself, with Content-Type image/svg+xml, image/png, image/jpeg, image/webp or application/pdf.

Errors

400A parameter is wrong. message names it, and code is invalid_input. See Errors.
401No key, or a key that is not valid.
429Too 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

NameTypeRequiredDescription
datastring, up to 1200noWhat the barcode holds. text and q are accepted too.
symbologycode128 | ean13 | ean8 | upca | code39 | itf14 | gs1-128 | isbn | datamatrix | gs1-datamatrix | pdf417 | aztecnoDefault code128. type is accepted too, and so are bwip-js's names such as gs1datamatrix.
formatsvg | png | jpg | webp | pdfnoDefault svg.
mm5 - 1000noPrinted width of the PDF, in millimetres. Default 40. PNG, JPG and WebP ignore it; their width comes from the data and scale.
scale2 - 20noUnits 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.
height4 - 200noBar 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.png

Response · 200 OK

The image itself, with Content-Type image/svg+xml, image/png, image/jpeg or image/webp.

Errors

400A 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.
401No key, or a key that is not valid.
429Too 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

NameTypeRequiredDescription
typeURL | TEXT | WIFI | EMAIL | PHONE | SMS | VCARD | GS1 | REVIEW | LOCATION | EVENT | PAYMENT | APP_STOREnoDefault URL.
namestring, up to 120noFor your list. Named from the content when left out.
kindDYNAMIC | STATICnoDefault DYNAMIC. STATIC puts the content in the pattern: it works with no signal, and it cannot change.
destinationstring, up to 2048noFor a website code: where it goes. A shortcut for payload.url. https:// is added if it is missing.
payloadobjectnoThe type's fields, with the same names as in Render.
folderIdstringnoPut the code in one of your folders.
designobjectnoforeground, background, eyeColor, moduleStyle, eyeStyle, eyeFrameStyle, eyeBallStyle, eyeRotation, errorCorrection, logoScale, quietZone. No frames.
rulesarraynoSmart routing for a dynamic code: send each scan somewhere different by time, place, device or language. See Smart routing.
outputboth | linknoWhat 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.
slugstring, 4 - 24noA chosen ending, like spring-menu. With domain, on Starter and above; on our domain, Business. See Shorten a link for the rules.
gs1object | string | nullnoA 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.
domainstring | nullnoOne 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.
tagsarray of strings, up to 10noTag names. Replaces the code's tags; [] removes them. New names make new tags.
utmobject | nullnoCampaign tags added to the destination on each scan: source, medium, campaign, term, content, each up to 120 characters. null removes them.
statusACTIVE | PAUSEDnoCreate 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

402Your plan's dynamic codes are used up. The body names the plan that has room.
409That link ending is already taken.
422A 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

NameTypeRequiredDescription
urlstring, up to 2048yesWhere the link goes: an http or https address, with https:// added if it is missing. Screened like every destination.
namestring, up to 120noFor your list. Named from the address when left out.
slugstring, 4 - 24noA chosen ending. On your domain: Starter and above. On ours: Business.
domainstring | nullnoOne of your verified custom domains, by hostname. Left out: your default domain. null: ours.
folderIdstringnoPut the link in one of your folders.
rulesarraynoSmart routing. See Smart routing.
tagsarray of strings, up to 10noTag names. Replaces the code's tags; [] removes them. New names make new tags.
utmobject | nullnoCampaign tags added to the destination on each scan: source, medium, campaign, term, content, each up to 120 characters. null removes them.
statusACTIVE | PAUSEDnoCreate 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

402Your plan's dynamic codes are used up, or a chosen ending needs a plan that has them.
409That ending is already taken.
422The 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

404No 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

NameTypeRequiredDescription
formatsvg | png | jpg | webp | pdfnoDefault svg.
size64 - 2000noWidth in pixels. Default 512.
mm5 - 1000noPrinted 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.png

Response · 200 OK

The image itself, with the Content-Type of the format.

Errors

404No code with that id.
422format, 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

NameTypeRequiredDescription
size64 - 512noWidth 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

404The 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.
429This 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

NameTypeRequiredDescription
limit1 - 100noCodes per page. Default 25.
offsetnumbernoCodes to skip.
qstring, up to 200noSearch names and destinations.
folderstringnoOnly codes in this folder, by id. none: codes in no folder. folderId works too.
tagstringnoOnly codes with this tag, by name. Case does not matter.
domainstringnoOnly codes on this custom domain, by hostname. none: codes on our domain.
statusACTIVE | PAUSED | DISABLED | ARCHIVEDnoOnly codes with this status. A deleted code is erased, so it is never listed. ARCHIVED finds only codes deleted before we started erasing them.
outputlink | qrnolink: 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

NameTypeRequiredDescription
namestring, up to 120noA new name.
destinationstring, up to 2048noWhere a dynamic website code should go.
payloadobjectnoNew content for another type, such as a contact card.
notestring, up to 200noWhy it changed, for the history.
statusACTIVE | PAUSEDnoPAUSED shows a neutral page; ACTIVE resumes. To delete, use DELETE.
rulesarraynoReplaces the code's Smart routing rules. [] removes them.
domainstring | nullnoMove 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.
slugstring, 4 - 24noThe 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.
gs1object | string | nullnoA 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.
folderIdstring | nullnoMove the code to one of your folders, or null for none.
tagsarray of strings, up to 10noTag names. Replaces the code's tags; [] removes them. New names make new tags.
utmobject | nullnoCampaign 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

402Tags, folders or campaign tags are not in your plan.
404No code or folder with that id.
409The ending is already taken on that domain.
422Nothing 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

NameTypeRequiredDescription
confirmstringnoThe 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

404No code with that id.
422The 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

NameTypeRequiredDescription
gs1.aistringnoThe GS1 primary key: 253, 255, 401, 402, 414, 417, 8003, 8004, 8006, 8010, 8013, 8017, 8018, 00, 01.
gs1.valuestringnoThe identifier itself, as it is printed.
gs1.qualifiersobjectnoQualifier 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

402Hosted Digital Links start on Starter.
409Another code already answers on that Digital Link.
422The 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

NameTypeRequiredDescription
fromdate, YYYY-MM-DDnoDefault 30 days before to. At most 366 days.
todate, YYYY-MM-DDnoDefault today.
dimensioncountry | region | city | device | os | browser | referrer | hour | weekdaynoOne 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

402That breakdown is not in your plan.
404No code with that id.
409Analytics 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

NameTypeRequiredDescription
fromdate, YYYY-MM-DDnoDefault 30 days before to. At most 366 days.
todate, YYYY-MM-DDnoDefault today.
dimensioncountry | region | city | device | os | browser | referrer | hour | weekdaynoOne 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

402That breakdown is not in your plan.
409Analytics 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

NameTypeRequiredDescription
idsarray of code ids, 1 - 500yesThe codes to change.
actiondomain | folder | tags | status | utm | deleteyesWhat to do. Each takes the field below.

Per action

NameTypeRequiredDescription
domainstring | nullnoaction: "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.
folderIdstring | nullnoaction: "folder". One of your folders, or null for none.
add, removearrays of tag namesnoaction: "tags". Tags to add and tags to take off. A code carries up to 10.
statusactive | pausednoaction: "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.
presetIdstringnoaction: "utm". A campaign preset. Only editable website codes take campaign tags.
confirmstringnoaction: "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

402Folders, tags or presets are not in your plan, or resuming would pass your plan's dynamic codes.
404The folder or preset is not in your workspace.
409Another code took one of the links just now. Try again.
422The body is invalid, more than 500 ids, the wrong confirm, or a domain that is not verified.
429Too 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

402Folders are not in your plan.
409A folder with that name exists. Names ignore case.
422The 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

404No 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

NameTypeRequiredDescription
namestring, 1 - 60yesThe 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

402Folders are not in your plan.
404No folder with that id.
409A folder with that name exists. Names ignore case.
422The 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

402Folders are not in your plan.
404No folder with that id.

Tags

GET /api/v1/tags · Starter · read scope

Your tags, with how many codes carry each. Tags are made by naming them: tags on a code, or add in Bulk actions. Filter the list of codes with ?tag=. One tag is read, renamed and removed at /api/v1/tags/{id}.

Request

curl -sS \
  'https://app.qrsalt.com/api/v1/tags' \
  -H "Authorization: Bearer $QRSALT_KEY"

Response · 200 OK

{
  "data": [
    {
      "id": "5b7d9f1a-3c5e-4a7b-9d1f-3a5c7e9b1d3f",
      "name": "Spring",
      "codes": 14
    }
  ],
  "meta": {
    "total": 1
  }
}

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

404No 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

NameTypeRequiredDescription
namestring, 1 - 40yesThe 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

402Tags are not in your plan.
404No tag with that id.
409There is already a tag with that name. Names are compared ignoring case.
422The 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

404No 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

NameTypeRequiredDescription
hostnamestring, up to 253yesJust 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

402Your plan's domains are used up. The body names the plan with more.
403Only an owner or admin can add domains, or custom domains are not set up yet.
409Someone already added that domain. Contact support if it is yours.
422Not 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

404No 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

404No domain with that id.
409The workspace is paused.
429Checked 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

NameTypeRequiredDescription
defaultbooleanyestrue 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

403Only an owner or admin can change the default.
404No domain with that id.
422The 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

NameTypeRequiredDescription
confirmstringnoThe 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

403Only an owner or admin can remove domains.
404No domain with that id.
422Codes 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

NameTypeRequiredDescription
limit1 - 100noResponses per page. Default 25.
offsetnumbernoResponses 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

404No 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 created
  • code.updated - A code was renamed or re-pointed
  • code.disabled - A code was paused, deleted or disabled
  • scan.recorded - A code was scanned
  • form.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

NameTypeRequiredDescription
whenobjectyesIts conditions, below. Every one given must hold. {} matches every scan.
thenstringyesWhere a matching scan goes. With split, the first link.
splitarrayno2 to 5 of { "percent": 50, "then": "https://…" }. Whole percentages that add up to 100.
labelstring, up to 60noYour name for it, shown in the code's scan breakdown.

Conditions (`when`)

NameTypeRequiredDescription
osios | android | othernoiPhones and iPads, Android phones, anything else.
devicephone | tablet | desktopnoThe kind of device.
countryarray of ISO codesnoFor example ["US", "CA"]. Any one matches.
regionarray of stringsnoRegion or state as scans report it, usually a code like CA. Accents and case do not matter.
cityarray of stringsnoCity names, for example ["Oslo", "Bergen"].
languagearray of codesnoThe device's language, for example ["es"].
after, beforeISO date-timenoA date window: from after, until the moment before begins.
scheduleobjectno{ "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.
scansBelowintegernoWhile the code has had fewer scans than this: the first N.
scansFromintegernoOnce 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

402A condition your plan does not include. The body names the plan that has it.
422A 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 TXT and CNAME records from dns at your DNS provider. On Cloudflare, set the CNAME to DNS only.
  • Check it until status is VERIFIED, or wait: we check every few minutes.
  • Create codes and links with domain, and slug for the custom link. Or make it the default and leave domain out.
  • Move codes you already have with domain on 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 a 402, and a few words are kept for us, like help.
  • Every code on your domain keeps its short code on ours. shortUrl and the QR image use the custom link while the domain is verified.
  • If it stops verifying, they switch to the short code and warning is 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 shortUrl and image back 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:  JSON

Formats 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