Skip to content

QR code API / Docs

QR code API documentation

docs.md

Getting started

QR code API

The QR code API makes QR codes and manages dynamic QR codes over HTTPS. There are two ways to make a code.

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: the same editable, routable, trackable link, for places a link goes rather than a printed code. Ask for a QR code for it later if you want one.

Organise codes the way the dashboard does: your own custom domains and custom links, folders, tags, campaign tags and bulk actions.

Every request needs an API key. The base URL is https://app.qrsalt.com. Requests and responses are JSON, except images.

Getting started

Authentication

Send your API key as a bearer token on every request. Keys come with Pro and above; create one in Dashboard → API. A key is shown once - only a hash is stored.

Call the API from a server or a no-code tool, never from a web page: a key in a page is a key anyone can copy, so the API sends no CORS headers. A missing or wrong key is a 401.

Example

Authorization: Bearer qr_live_...

Getting started

Quickstart

1. Create a key in Dashboard → API and keep it in an environment variable, QRSALT_KEY.

2. Render a static QR code as a PNG:

  • Create a dynamic code: 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

Getting started

Errors

Errors use HTTP status codes and a JSON body with a stable code and a readable message. Some carry more, such as suggestedPlan or resetAt.

Example

{
  "error": {
    "code": "limit_reached",
    "message": "Pro includes 600 dynamic codes.",
    "suggestedPlan": "BUSINESS"
  }
}

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.
403Your 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. Try again after resetAt.

Getting started

Rate limits

Each key can make 600 requests a minute, counted separately, so one busy integration cannot slow down another. Going over answers 429 with resetAt, the time the count starts again.

Renders are deterministic: store a code you reuse instead of asking for it again.

Endpoints

Render a QR code

GET /api/qr · API key

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 old integration 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. The body names it: {"error": "\"ecc\" must be one of: L, M, Q, H.", "docs": "…"}
401No key, or a key that is not valid.
429Too many requests for this key.

Endpoints

Create a dynamic code

POST /api/v1/codes · API key

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 - opens the messages app with the text ready, offers to call the number, shows the network and its password. 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.

An APP_STORE code is always dynamic: its payload is appName, appStore (for iPhone and iPad: an apps.apple.com link or any other https link), googlePlay (for Android: a play.google.com details link or any other https link) and an optional fallback, with at least one of the two. A scan from an iPhone or iPad goes to appStore, one from Android goes to googlePlay, and anything else goes to fallback - or, with none, to 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, which every phone opens; 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 (the account name or link, 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 on buy.stripe.com, donate.stripe.com or the business's own https domain; other must be https), payee (the name people see), and an optional amount, currency and note. 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.

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 | linknoOnly changes the answer. Every dynamic code has both a QR code and a short link; with link, image comes back null because you only asked for the link. Get a code's image answers for every code whenever you want the QR code.
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.
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":"Maya","lastName":"Okafor","email":"[email protected]"}}'

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,
    "image": "https://app.qrsalt.com/api/v1/codes/43f77a11-699c-4180-a173-88d7eb954e4e/image",
    "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.

Endpoints

Shorten a link

POST /api/v1/links · API key

Makes a short link and saves it to your account. Underneath it is a dynamic code - it has a QR code too, whenever you want one - so everything else works on it: change where it goes with Update, send each click somewhere different with Smart routing, read its clicks with Code scans and remove it with Delete. It counts against your plan's dynamic codes, like any editable code.

Send slug to choose the ending yourself - qrsalt.com/autumn-menu - on Business, or on your own domain on Starter and above. An ending is 4 to 24 lowercase letters, numbers and hyphens, starts and ends with a letter or number, and cannot be one of our own page names. Endings of 7 to 10 characters need a hyphen or one of 0, 1, l or o, because that length is kept for automatic links. It is set once and never changes; leave it out and you get a short random one.

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; the custom link is kept and comes back when the domain is fixed.

List your links with GET /api/v1/links or GET /api/v1/codes?output=link - every dynamic code, since every one has a short link. Want the QR code for a link? Get a code's image works for every code.

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,
    "image": null,
    "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.

Endpoints

Get a code

GET /api/v1/codes/{id} · API key

One of your codes, with its short link and scan count. Another workspace's id is a 404.

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,
    "image": null,
    "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.

Endpoints

Get a code's image

GET /api/v1/codes/{id}/image · API key

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.

Endpoints

List codes

GET /api/v1/codes · API key

Your codes, newest first, a page at a time. meta.nextOffset is the offset of the next page, or null.

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. Without it, deleted (ARCHIVED) codes are left out.
outputlink | qrnolink: only codes with a short link (every dynamic code).

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,
      "image": null,
      "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,
      "image": "https://app.qrsalt.com/api/v1/codes/b52d0e98-7a13-4f6c-a2e5-9d8c1b7f4a36/image",
      "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
  }
}

Endpoints

Update or re-point a code

PATCH /api/v1/codes/{id} · API key

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, like spring-menu: go.yourbrand.com/spring-menu. "" puts it back to the short code. The code must be on your domain (send domain in the same request to move it first). Copies printed with the old link stop working.
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,
    "image": null,
    "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).

Endpoints

Delete a code

DELETE /api/v1/codes/{id} · API key

Deletes the code. From then on a scan gets a not-found page, the code leaves your lists, and it no longer counts toward your plan. It cannot be restored. Its short link is never given to anyone else, so a printed code can never lead to a stranger's page. The response shows the code with status ARCHIVED, which is how a deleted code is stored.

Request

curl -sS \
  -X DELETE \
  'https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10' \
  -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,
    "image": "https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10/image",
    "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.

Endpoints

Code scans

GET /api/v1/codes/{id}/scans · API key

Daily scans and unique visitors for one code, and one optional breakdown. The range follows your plan's history, and the answer says when it was shortened. City and region breakdowns come with Pro and above.

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.

Endpoints

Workspace analytics

GET /api/v1/analytics · API key

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.

Endpoints

Bulk actions

POST /api/v1/codes/bulk · API key

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.

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". delete 12 for 12 ids: the number must match. 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.

Endpoints

Folders

GET /api/v1/folders · API key

Your folders, with how many codes each holds. POST the same path with a name to make one. 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.

Endpoints

Tags

GET /api/v1/tags · API key

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

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
  }
}

Endpoints

Campaign presets

GET /api/v1/utm-presets · API key

The campaign-tag presets saved in the dashboard, for action: "utm" in Bulk actions. One code takes its values directly in utm.

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
  }
}

Endpoints

List custom domains

GET /api/v1/domains · API key

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"
  }
}

Endpoints

Add a domain

POST /api/v1/domains · API key

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.

Endpoints

Get a domain

GET /api/v1/domains/{id} · API key

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.

Endpoints

Check DNS now

POST /api/v1/domains/{id}/check · API key

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.

Endpoints

Set the default domain

PATCH /api/v1/domains/{id} · API key

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.

Endpoints

Remove a domain

DELETE /api/v1/domains/{id} · API key

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.

Endpoints

QR Menus

GET /api/v1/pages · API key

Your QR Menus, read-only: title, public address, whether it is published, and views. Build and edit them in the dashboard.

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://app.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
  }
}

Endpoints

QR Forms

GET /api/v1/forms · API key

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
  }
}

Endpoints

Form responses

GET /api/v1/forms/{id}/responses · API key

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": "Maya Okafor",
        "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

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.

Webhooks

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)

Webhooks

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.

form.submitted carries the answers, so your endpoint receives personal data your form collected (names, emails, phone numbers). Nothing identifies the person who answered 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",
    "region": "Oslo",
    "city": "Oslo",
    "device": "mobile",
    "os": "iOS",
    "browser": "Safari",
    "referrerHost": null
  }
}

Webhooks

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)))
}

Webhooks

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.

Guides

Smart routing

Send each scan of one dynamic code somewhere different: by time of day, country, region or city, device, device language, how many scans there have been, or an A/B split. Rules are a list checked from the top. The first rule whose conditions all hold decides, 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 every link in them is checked like a destination. A static code cannot be routed: 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 does not include is a 402.

Location comes from the network the phone is on: country is reliable, region and city are approximate, and a scan whose place is unknown never matches a place condition. Scan counts come from your analytics, a few minutes behind. In a split, the same person rescanning the same day gets the same link.

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.

Guides

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 and switch to the short code if it stops, with warning set. The custom link is kept and 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,
    "image": null,
    "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": {}
}

Guides

Zapier, Make and n8n

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.

  • Zapier: add a Webhooks by Zapier action, choose Custom Request, and set the method, URL, header and, for a new code, the JSON body.
  • 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

Guides

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; keep a code at least 2 cm wide, and more for posters read from a distance. Use ecc=H when a logo or a rough surface covers part of the code, and keep the default margin of 4 modules so phones can find it.

Guides

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
QR Code API docs - endpoints and examples - QRSalt