QR code API / Docs
QR code API documentation
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,shortUrland animageURL. - Fetch that code as an image in any format, or change its destination later with
PATCH.
Example
curl -sS \
'https://app.qrsalt.com/api/qr?type=url&url=https%3A%2F%2Fexample.com&format=png' \
-H "Authorization: Bearer $QRSALT_KEY" \
-o qr.pngGetting 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
| 400 | Render only: a parameter is wrong. The body names it. |
| 401 | No key, or a key that is not valid. |
| 402 | Your plan does not include this, or an allowance is used up. |
| 403 | Your role cannot do this (domains need an owner or admin), or custom domains are not set up yet. |
| 404 | No such code, domain, folder or form in your workspace. Another workspace's id is a 404 too. |
| 409 | A link ending or domain is already taken, or the workspace is paused. |
| 422 | The body or a field is invalid. The message says which. |
| 429 | Too many requests for this key. 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
| Name | Type | Required | Description |
|---|---|---|---|
| type | url | text | wifi | email | phone | sms | vcard | gs1 | review | location | event | payment | no | What the code holds. contact and product also work, for vcard and gs1. Required unless you send data. |
| data | string, up to 2000 bytes | no | The exact text to encode, instead of a type. |
| format | svg | png | jpg | webp | pdf | no | File format. Default svg. jpeg works for jpg. |
| size | 64 - 2000 | no | Width in pixels. Default 512. Not used by PDF. |
| mm | 5 - 1000 | no | Printed width of the PDF in millimetres. Default 40. |
| color | hex | no | Module colour. Default 000000. |
| bgcolor | hex | no | Background colour. Default FFFFFF. |
| eyecolor | hex | no | Colour of the three corner squares. |
| style | square | rounded | dots | classy | no | Module shape. Also sets the corner shape unless eyes does. |
| eyes | square | rounded | circle | no | Corner square shape. |
| eyeframe | square | rounded | circle | leaf | leaf-flip | drop | no | Outer ring of the corners, on its own. |
| eyeball | square | rounded | circle | leaf | leaf-flip | drop | no | Centre of the corners, on its own. |
| eyeturn | 0 | 180 | no | Turn the corners, in degrees. |
| ecc | L | M | Q | H | no | Error correction. Default M. Use H under a logo or for rough surfaces. |
| margin | 0 - 20 | no | Quiet zone in modules. Default 4. |
type=url - Website
| Name | Type | Required | Description |
|---|---|---|---|
| url | string, up to 2048 | yes | Where should this code go? |
type=text - Plain text
| Name | Type | Required | Description |
|---|---|---|---|
| text | string, up to 2000 | yes | Text |
type=wifi - Wi-Fi network
| Name | Type | Required | Description |
|---|---|---|---|
| ssid | string, up to 64 | yes | Network name (SSID) |
| password | string, up to 128 | no | Password |
| encryption | WPA | WEP | nopass | no | Security |
| hidden | boolean | no | Hidden network |
type=email - Email
| Name | Type | Required | Description |
|---|---|---|---|
| string, up to 200 | yes | Email address | |
| subject | string, up to 200 | no | Subject |
| body | string, up to 1000 | no | Message |
| cc | string, up to 500 | no | Cc |
| bcc | string, up to 500 | no | Bcc |
type=phone - Phone number
| Name | Type | Required | Description |
|---|---|---|---|
| phone | string, up to 40 | yes | Phone number |
| extension | string, up to 16 | no | Extension |
type=sms - SMS message
| Name | Type | Required | Description |
|---|---|---|---|
| phone | string, up to 40 | yes | Phone number |
| message | string, up to 500 | no | Pre-filled message |
type=vcard - Contact card
| Name | Type | Required | Description |
|---|---|---|---|
| firstName | string, up to 100 | yes | First name |
| lastName | string, up to 100 | no | Last name |
| organization | string, up to 120 | no | Organisation |
| title | string, up to 120 | no | Job title |
| phone | string, up to 40 | no | Mobile |
| string, up to 200 | no | ||
| website | string, up to 300 | no | Website |
| workPhone | string, up to 40 | no | Work phone |
| street | string, up to 200 | no | Street |
| city | string, up to 100 | no | City |
| region | string, up to 100 | no | Region or state |
| postalCode | string, up to 24 | no | Postcode |
| country | string, up to 100 | no | Country |
| note | string, up to 500 | no | Note |
type=gs1 - GS1 Digital Link
| Name | Type | Required | Description |
|---|---|---|---|
| domain | string, up to 200 | yes | Your domain |
| gtin | string, up to 20 | yes | GTIN |
| batch | string, up to 20 | no | Batch or lot |
| serial | string, up to 20 | no | Serial |
| expiry | date, YYYY-MM-DD | no | Expiry date |
| production | date, YYYY-MM-DD | no | Production date |
type=review - Review request
| Name | Type | Required | Description |
|---|---|---|---|
| platform | google | trustpilot | yelp | facebook | other | no | Where should the review go? |
| value | string, up to 500 | yes | Your Google review link |
type=location - Location
| Name | Type | Required | Description |
|---|---|---|---|
| query | string, up to 200 | no | Address or place name |
| latitude | string, up to 20 | no | Latitude |
| longitude | string, up to 20 | no | Longitude |
| mapsLink | string, up to 1000 | no | Or paste a maps link |
type=event - Calendar event
| Name | Type | Required | Description |
|---|---|---|---|
| title | string, up to 200 | yes | Event name |
| start | string, up to 16 | yes | Starts |
| end | string, up to 16 | no | Ends |
| timeZone | string, up to 64 | no | Time zone |
| allDay | boolean | no | All day |
| location | string, up to 300 | no | Where |
| description | string, up to 2000 | no | Details |
| url | string, up to 2048 | no | Link |
type=payment - Payment link
| Name | Type | Required | Description |
|---|---|---|---|
| provider | paypal | venmo | cashapp | revolut | wise | bmc | stripe | other | no | How do people pay you? |
| handle | string, up to 2048 | yes | Your PayPal.me name or link |
| payee | string, up to 80 | yes | Name people will see |
| amount | string, up to 12 | no | Amount |
| currency | | USD | EUR | GBP | CAD | AUD | NZD | CHF | NOK | SEK | DKK | PLN | CZK | JPY | INR | BRL | MXN | no | Currency |
| note | string, up to 200 | no | Note |
Request
curl -sS \
'https://app.qrsalt.com/api/qr?type=wifi&ssid=Cafe-Guest&password=espresso2026&format=png' \
-H "Authorization: Bearer $QRSALT_KEY" \
-o qr.pngResponse · 200 OK
The image itself, with Content-Type image/svg+xml, image/png, image/jpeg, image/webp or application/pdf.Errors
| 400 | A parameter is wrong. The body names it: {"error": "\"ecc\" must be one of: L, M, Q, H.", "docs": "…"} |
| 401 | No key, or a key that is not valid. |
| 429 | Too 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
| Name | Type | Required | Description |
|---|---|---|---|
| type | URL | TEXT | WIFI | EMAIL | PHONE | SMS | VCARD | GS1 | REVIEW | LOCATION | EVENT | PAYMENT | APP_STORE | no | Default URL. |
| name | string, up to 120 | no | For your list. Named from the content when left out. |
| kind | DYNAMIC | STATIC | no | Default DYNAMIC. STATIC puts the content in the pattern: it works with no signal, and it cannot change. |
| destination | string, up to 2048 | no | For a website code: where it goes. A shortcut for payload.url. https:// is added if it is missing. |
| payload | object | no | The type's fields, with the same names as in Render. |
| folderId | string | no | Put the code in one of your folders. |
| design | object | no | foreground, background, eyeColor, moduleStyle, eyeStyle, eyeFrameStyle, eyeBallStyle, eyeRotation, errorCorrection, logoScale, quietZone. No frames. |
| rules | array | no | Smart routing for a dynamic code: send each scan somewhere different by time, place, device or language. See Smart routing. |
| output | both | link | no | Only 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. |
| slug | string, 4 - 24 | no | A chosen ending, like spring-menu. With domain, on Starter and above; on our domain, Business. See Shorten a link for the rules. |
| domain | string | null | no | One of your verified custom domains, by hostname, like go.yourbrand.com. The short link and QR code use it. Left out: your default domain. null: ours. Dynamic codes only. |
| tags | array of strings, up to 10 | no | Tag names. Replaces the code's tags; [] removes them. New names make new tags. |
| utm | object | null | no | Campaign tags added to the destination on each scan: source, medium, campaign, term, content, each up to 120 characters. null removes them. |
| status | ACTIVE | PAUSED | no | Create it paused. A paused code shows a neutral page. |
Request
curl -sS \
-X POST \
'https://app.qrsalt.com/api/v1/codes' \
-H "Authorization: Bearer $QRSALT_KEY" \
-H "Content-Type: application/json" \
-d '{"type":"VCARD","name":"Sales card","payload":{"firstName":"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
| 402 | Your plan's dynamic codes are used up. The body names the plan that has room. |
| 409 | That link ending is already taken. |
| 422 | A field is invalid. |
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
| Name | Type | Required | Description |
|---|---|---|---|
| url | string, up to 2048 | yes | Where the link goes: an http or https address, with https:// added if it is missing. Screened like every destination. |
| name | string, up to 120 | no | For your list. Named from the address when left out. |
| slug | string, 4 - 24 | no | A chosen ending. On your domain: Starter and above. On ours: Business. |
| domain | string | null | no | One of your verified custom domains, by hostname. Left out: your default domain. null: ours. |
| folderId | string | no | Put the link in one of your folders. |
| rules | array | no | Smart routing. See Smart routing. |
| tags | array of strings, up to 10 | no | Tag names. Replaces the code's tags; [] removes them. New names make new tags. |
| utm | object | null | no | Campaign tags added to the destination on each scan: source, medium, campaign, term, content, each up to 120 characters. null removes them. |
| status | ACTIVE | PAUSED | no | Create it paused. A paused code shows a neutral page. |
Request
curl -sS \
-X POST \
'https://app.qrsalt.com/api/v1/links' \
-H "Authorization: Bearer $QRSALT_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/autumn-menu","name":"Instagram bio","slug":"autumn-menu"}'Response · 201 Created
{
"data": {
"id": "41b5d347-02de-4cc4-afb3-2e9910ca0efe",
"name": "Instagram bio",
"kind": "DYNAMIC",
"type": "URL",
"status": "ACTIVE",
"slug": "autumn-menu",
"output": "link",
"shortUrl": "https://qrsalt.com/autumn-menu",
"shortCodeUrl": "https://qrsalt.com/autumn-menu",
"customUrl": null,
"domain": null,
"warning": null,
"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
| 402 | Your plan's dynamic codes are used up, or a chosen ending needs a plan that has them. |
| 409 | That ending is already taken. |
| 422 | The address or the ending is not valid. The message says which rule. |
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
| 404 | No 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
| Name | Type | Required | Description |
|---|---|---|---|
| format | svg | png | jpg | webp | pdf | no | Default svg. |
| size | 64 - 2000 | no | Width in pixels. Default 512. |
| mm | 5 - 1000 | no | Printed width of the PDF. Default 40. |
Request
curl -sS \
'https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10/image?format=png&size=1024' \
-H "Authorization: Bearer $QRSALT_KEY" \
-o qr.pngResponse · 200 OK
The image itself, with the Content-Type of the format.Errors
| 404 | No code with that id. |
| 422 | format, size or mm is out of range. |
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
| Name | Type | Required | Description |
|---|---|---|---|
| limit | 1 - 100 | no | Codes per page. Default 25. |
| offset | number | no | Codes to skip. |
| q | string, up to 200 | no | Search names and destinations. |
| folder | string | no | Only codes in this folder, by id. none: codes in no folder. folderId works too. |
| tag | string | no | Only codes with this tag, by name. Case does not matter. |
| domain | string | no | Only codes on this custom domain, by hostname. none: codes on our domain. |
| status | ACTIVE | PAUSED | DISABLED | ARCHIVED | no | Only codes with this status. Without it, deleted (ARCHIVED) codes are left out. |
| output | link | qr | no | link: 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
| Name | Type | Required | Description |
|---|---|---|---|
| name | string, up to 120 | no | A new name. |
| destination | string, up to 2048 | no | Where a dynamic website code should go. |
| payload | object | no | New content for another type, such as a contact card. |
| note | string, up to 200 | no | Why it changed, for the history. |
| status | ACTIVE | PAUSED | no | PAUSED shows a neutral page; ACTIVE resumes. To delete, use DELETE. |
| rules | array | no | Replaces the code's Smart routing rules. [] removes them. |
| domain | string | null | no | Move a dynamic code's short link to one of your verified domains, by hostname, or null for ours. Copies printed with the old domain stop working. Its link on our domain always works. |
| slug | string, 4 - 24 | no | The custom link on the code's domain, 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. |
| folderId | string | null | no | Move the code to one of your folders, or null for none. |
| tags | array of strings, up to 10 | no | Tag names. Replaces the code's tags; [] removes them. New names make new tags. |
| utm | object | null | no | Campaign tags added to the destination on each scan: source, medium, campaign, term, content, each up to 120 characters. null removes them. |
Request
curl -sS \
-X PATCH \
'https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10' \
-H "Authorization: Bearer $QRSALT_KEY" \
-H "Content-Type: application/json" \
-d '{"destination":"https://example.com/winter"}'Response · 200 OK
{
"data": {
"id": "d7a4c2e9-1b3f-4e8a-b6d5-0f2c9e7a1b84",
"name": "Instagram bio - autumn beans",
"kind": "DYNAMIC",
"type": "URL",
"status": "ACTIVE",
"slug": "autumn-beans",
"output": "link",
"shortUrl": "https://qrsalt.com/autumn-beans",
"shortCodeUrl": "https://qrsalt.com/autumn-beans",
"customUrl": null,
"domain": null,
"warning": null,
"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
| 402 | Tags, folders or campaign tags are not in your plan. |
| 404 | No code or folder with that id. |
| 409 | The ending is already taken on that domain. |
| 422 | Nothing to change, an invalid field, an unverified domain, a custom link on a code that is not on your domain, or a static code (its content is in the pattern). |
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
| 404 | No 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
| Name | Type | Required | Description |
|---|---|---|---|
| from | date, YYYY-MM-DD | no | Default 30 days before to. At most 366 days. |
| to | date, YYYY-MM-DD | no | Default today. |
| dimension | country | region | city | device | os | browser | referrer | hour | weekday | no | One breakdown. Hours and weekdays are UTC; weekday runs 1 (Monday) to 7 (Sunday). |
Request
curl -sS \
'https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10/scans?from=2026-08-25&to=2026-08-31&dimension=country' \
-H "Authorization: Bearer $QRSALT_KEY"Response · 200 OK
{
"data": {
"codeId": "8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10",
"summary": {
"scans": 143,
"uniques": 119,
"firstScanAt": "2026-08-25T08:14:00.000Z",
"lastScanAt": "2026-08-31T17:42:00.000Z"
},
"series": [
{
"day": "2026-08-25",
"scans": 27,
"uniques": 22
},
{
"day": "2026-08-26",
"scans": 16,
"uniques": 13
},
{
"day": "2026-08-27",
"scans": 20,
"uniques": 17
},
{
"day": "2026-08-28",
"scans": 26,
"uniques": 22
},
{
"day": "2026-08-29",
"scans": 19,
"uniques": 16
},
{
"day": "2026-08-30",
"scans": 22,
"uniques": 18
},
{
"day": "2026-08-31",
"scans": 13,
"uniques": 11
}
],
"dimension": "country",
"breakdown": [
{
"value": "US",
"count": 56
},
{
"value": "CA",
"count": 32
},
{
"value": "GB",
"count": 23
},
{
"value": "NO",
"count": 18
},
{
"value": "JP",
"count": 14
}
]
},
"meta": {
"from": "2026-08-25",
"to": "2026-08-31",
"clamped": false
}
}Errors
| 402 | That breakdown is not in your plan. |
| 404 | No code with that id. |
| 409 | Analytics are paused while the subscription is inactive. |
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
| Name | Type | Required | Description |
|---|---|---|---|
| from | date, YYYY-MM-DD | no | Default 30 days before to. At most 366 days. |
| to | date, YYYY-MM-DD | no | Default today. |
| dimension | country | region | city | device | os | browser | referrer | hour | weekday | no | One breakdown. Hours and weekdays are UTC; weekday runs 1 (Monday) to 7 (Sunday). |
Request
curl -sS \
'https://app.qrsalt.com/api/v1/analytics?from=2026-08-25&to=2026-08-31&dimension=device' \
-H "Authorization: Bearer $QRSALT_KEY"Response · 200 OK
{
"data": {
"summary": {
"scans": 711,
"uniques": 590,
"firstScanAt": "2026-08-25T08:14:00.000Z",
"lastScanAt": "2026-08-31T17:42:00.000Z"
},
"series": [
{
"day": "2026-08-25",
"scans": 82,
"uniques": 68
},
{
"day": "2026-08-26",
"scans": 112,
"uniques": 93
},
{
"day": "2026-08-27",
"scans": 103,
"uniques": 85
},
{
"day": "2026-08-28",
"scans": 102,
"uniques": 85
},
{
"day": "2026-08-29",
"scans": 101,
"uniques": 84
},
{
"day": "2026-08-30",
"scans": 125,
"uniques": 104
},
{
"day": "2026-08-31",
"scans": 86,
"uniques": 71
}
],
"dimension": "device",
"breakdown": [
{
"value": "mobile",
"count": 356
},
{
"value": "desktop",
"count": 208
},
{
"value": "tablet",
"count": 147
}
]
},
"meta": {
"from": "2026-08-25",
"to": "2026-08-31",
"clamped": false
}
}Errors
| 402 | That breakdown is not in your plan. |
| 409 | Analytics are paused while the subscription is inactive. |
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
| Name | Type | Required | Description |
|---|---|---|---|
| ids | array of code ids, 1 - 500 | yes | The codes to change. |
| action | domain | folder | tags | status | utm | delete | yes | What to do. Each takes the field below. |
Per action
| Name | Type | Required | Description |
|---|---|---|---|
| domain | string | null | no | action: "domain". A verified domain of yours, by hostname, or null for ours. A kept custom link is used again when it is free there; otherwise the code uses its short code. |
| folderId | string | null | no | action: "folder". One of your folders, or null for none. |
| add, remove | arrays of tag names | no | action: "tags". Tags to add and tags to take off. A code carries up to 10. |
| status | active | paused | no | action: "status", for example { "action": "status", "status": "paused" }. Pause or resume; either case works. Resuming more codes than your plan allows is refused as a whole. |
| presetId | string | no | action: "utm". A campaign preset. Only editable website codes take campaign tags. |
| confirm | string | no | action: "delete". 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
| 402 | Folders, tags or presets are not in your plan, or resuming would pass your plan's dynamic codes. |
| 404 | The folder or preset is not in your workspace. |
| 409 | Another code took one of the links just now. Try again. |
| 422 | The body is invalid, more than 500 ids, the wrong confirm, or a domain that is not verified. |
| 429 | Too many bulk requests this hour. |
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
| 402 | Folders are not in your plan. |
| 409 | A folder with that name exists. Names ignore case. |
| 422 | The name is empty or too long. |
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
| Name | Type | Required | Description |
|---|---|---|---|
| hostname | string, up to 253 | yes | Just the address: no https://, no path. A subdomain like go.yourbrand.com is best. |
Request
curl -sS \
-X POST \
'https://app.qrsalt.com/api/v1/domains' \
-H "Authorization: Bearer $QRSALT_KEY" \
-H "Content-Type: application/json" \
-d '{"hostname":"go.yourbrand.com"}'Response · 201 Created
{
"data": {
"id": "6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14",
"hostname": "go.yourbrand.com",
"status": "PENDING",
"default": false,
"codes": 0,
"dns": [
{
"type": "TXT",
"name": "_qr-verify.go.yourbrand.com",
"value": "k3v9x2m7q8w1z5r4tq6y"
},
{
"type": "CNAME",
"name": "go.yourbrand.com",
"value": "your-cname-target.example"
}
],
"lastError": null,
"lastCheckedAt": null,
"nextCheckAt": null,
"verifiedAt": null,
"createdAt": "2026-08-20T09:00:00.000Z"
},
"meta": {}
}Errors
| 402 | Your plan's domains are used up. The body names the plan with more. |
| 403 | Only an owner or admin can add domains, or custom domains are not set up yet. |
| 409 | Someone already added that domain. Contact support if it is yours. |
| 422 | Not a hostname, or one of ours. |
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
| 404 | No 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
| 404 | No domain with that id. |
| 409 | The workspace is paused. |
| 429 | Checked 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
| Name | Type | Required | Description |
|---|---|---|---|
| default | boolean | yes | true to make it the default, false to stop. |
Request
curl -sS \
-X PATCH \
'https://app.qrsalt.com/api/v1/domains/6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14' \
-H "Authorization: Bearer $QRSALT_KEY" \
-H "Content-Type: application/json" \
-d '{"default":true}'Response · 200 OK
{
"data": {
"id": "6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14",
"hostname": "go.yourbrand.com",
"status": "VERIFIED",
"default": true,
"codes": 12,
"dns": [
{
"type": "TXT",
"name": "_qr-verify.go.yourbrand.com",
"value": "k3v9x2m7q8w1z5r4tq6y"
},
{
"type": "CNAME",
"name": "go.yourbrand.com",
"value": "your-cname-target.example"
}
],
"lastError": null,
"lastCheckedAt": "2026-09-01T11:55:00.000Z",
"nextCheckAt": "2026-09-02T11:55:00.000Z",
"verifiedAt": "2026-08-20T09:12:00.000Z",
"createdAt": "2026-08-20T09:00:00.000Z"
},
"meta": {}
}Errors
| 403 | Only an owner or admin can change the default. |
| 404 | No domain with that id. |
| 422 | The domain is not verified yet. |
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
| Name | Type | Required | Description |
|---|---|---|---|
| confirm | string | no | The domain's hostname. Required when codes use it. |
Request
curl -sS \
-X DELETE \
'https://app.qrsalt.com/api/v1/domains/6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14?confirm=go.yourbrand.com' \
-H "Authorization: Bearer $QRSALT_KEY"Response · 200 OK
{
"data": {
"id": "6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14",
"hostname": "go.yourbrand.com",
"deleted": true,
"codesMoved": 12
},
"meta": {
"note": "Removed. Its codes now use their short code on our domain; copies printed with this domain stop working."
}
}Errors
| 403 | Only an owner or admin can remove domains. |
| 404 | No domain with that id. |
| 422 | Codes use the domain and confirm is missing or not its hostname. |
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
| Name | Type | Required | Description |
|---|---|---|---|
| limit | 1 - 100 | no | Responses per page. Default 25. |
| offset | number | no | Responses to skip. |
Request
curl -sS \
'https://app.qrsalt.com/api/v1/forms/a3e9b7c1-2f4d-4a6b-8c0e-5d7f9a1b3c2e/responses?limit=1' \
-H "Authorization: Bearer $QRSALT_KEY"Response · 200 OK
{
"data": [
{
"id": "9e1a3c5e-7a9c-4e1a-8c3e-5a7c9e1a3c5e",
"answers": {
"name": "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
| 404 | No 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 createdcode.updated- A code was renamed or re-pointedcode.disabled- A code was paused, deleted or disabledscan.recorded- A code was scannedform.submitted- A QR Form was answered (the answers are included)
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
| Name | Type | Required | Description |
|---|---|---|---|
| when | object | yes | Its conditions, below. Every one given must hold. {} matches every scan. |
| then | string | yes | Where a matching scan goes. With split, the first link. |
| split | array | no | 2 to 5 of { "percent": 50, "then": "https://…" }. Whole percentages that add up to 100. |
| label | string, up to 60 | no | Your name for it, shown in the code's scan breakdown. |
Conditions (`when`)
| Name | Type | Required | Description |
|---|---|---|---|
| os | ios | android | other | no | iPhones and iPads, Android phones, anything else. |
| device | phone | tablet | desktop | no | The kind of device. |
| country | array of ISO codes | no | For example ["US", "CA"]. Any one matches. |
| region | array of strings | no | Region or state as scans report it, usually a code like CA. Accents and case do not matter. |
| city | array of strings | no | City names, for example ["Oslo", "Bergen"]. |
| language | array of codes | no | The device's language, for example ["es"]. |
| after, before | ISO date-time | no | A date window: from after, until the moment before begins. |
| schedule | object | no | { "days": [1,2,3,4,5], "from": "07:00", "to": "11:00", "tz": "America/New_York" }. Days 0-6 from Sunday. to earlier than from runs overnight. |
| scansBelow | integer | no | While the code has had fewer scans than this: the first N. |
| scansFrom | integer | no | Once the code has had at least this many scans. |
Example
curl -sS \
-X PATCH \
'https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10' \
-H "Authorization: Bearer $QRSALT_KEY" \
-H "Content-Type: application/json" \
-d '{"rules":[{"label":"Spanish menu","when":{"language":["es"]},"then":"https://example.com/es/menu"},{"label":"Breakfast","when":{"schedule":{"days":[0,1,2,3,4,5,6],"from":"07:00","to":"11:00","tz":"America/New_York"}},"then":"https://example.com/menu/breakfast"},{"label":"iPhones","when":{"os":"ios"},"then":"https://apps.apple.com/app/id000000000"}]}'Errors
| 402 | A condition your plan does not include. The body names the plan that has it. |
| 422 | A rule cannot be read, one of its links is refused, or the code is static. |
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
TXTandCNAMErecords fromdnsat your DNS provider. On Cloudflare, set the CNAME to DNS only. - Check it until
statusisVERIFIED, or wait: we check every few minutes. - Create codes and links with
domain, andslugfor the custom link. Or make it the default and leavedomainout. - Move codes you already have with
domainon Update, or many at once with Bulk actions. - A custom link only has to be free on your domain. Taken is a
409, a plan without custom links is a402, and a few words are kept for us, likehelp. - Every code on your domain keeps its short code on ours.
shortUrland the QR image use the custom link while the domain is verified and switch to the short code if it stops, withwarningset. 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
shortUrlandimageback to the row.
Example
Method: POST
URL: https://app.qrsalt.com/api/v1/codes
Header: Authorization = Bearer <your API key>
Header: Content-Type = application/json
Body: {"type":"URL","name":"Row 42","destination":"https://example.com/42"}
Returns: JSONGuides
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