# QR code API documentation

Base URL: https://app.qrsalt.com. Every request needs `Authorization: Bearer <API key>`.

## 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](#custom-domains) and custom links, folders, tags, campaign tags and [bulk actions](#bulk).

**Read** scans for one code or the whole workspace, your [QR Menus](#pages), your [QR Forms](#forms) and their answers, and hear about each scan and answer as it happens with [webhooks](#webhooks).

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

## Authentication

Send your API key as a bearer token on every request. Keys come with Pro and above; create one in [Dashboard → API](/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`.

**Header**

```text
Authorization: Bearer qr_live_...
```

## Quickstart

1. Create a key in [Dashboard → API](/dashboard/api) and keep it in an environment variable, `QRSALT_KEY`.

2. Render a static QR code as a PNG:

3. Create a dynamic code: the answer carries its `id`, `shortUrl` and an `image` URL.
4. Fetch that code as an image in any format, or change its destination later with `PATCH`.

**Render**

```bash
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
```

**Create dynamic**

```bash
curl -sS \
  -X POST \
  'https://app.qrsalt.com/api/v1/codes' \
  -H "Authorization: Bearer $QRSALT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"type":"URL","name":"Table tents","destination":"https://example.com/menu"}'
```

**Get its image**

```bash
curl -sS \
  'https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10/image?format=png' \
  -H "Authorization: Bearer $QRSALT_KEY" \
  -o qr.png
```

## Errors

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

**Error body**

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

### Errors

| Status | Meaning |
| --- | --- |
| 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`. |

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

## Render a QR code

`GET https://app.qrsalt.com/api/qr` - requires an 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](#create).

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 |
| --- | --- | --- | --- |
| `email` | 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 |
| `email` | string, up to 200 | no | Email |
| `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 |

**cURL**

```bash
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
```

**JavaScript**

```js
import fs from 'node:fs'

const res = await fetch("https://app.qrsalt.com/api/qr?type=wifi&ssid=Cafe-Guest&password=espresso2026&format=png", {
  headers: {
    Authorization: `Bearer ${process.env.QRSALT_KEY}`,
  },
})
await fs.promises.writeFile('qr.png', Buffer.from(await res.arrayBuffer()))
```

**Python**

```python
import json, os, urllib.request

req = urllib.request.Request(
    "https://app.qrsalt.com/api/qr?type=wifi&ssid=Cafe-Guest&password=espresso2026&format=png",
    headers={
        "Authorization": "Bearer " + os.environ["QRSALT_KEY"],
    },
)
with urllib.request.urlopen(req) as res, open("qr.png", "wb") as f:
    f.write(res.read())
```

**No-code**

```text
Method:   GET
URL:      https://app.qrsalt.com/api/qr?type=wifi&ssid=Cafe-Guest&password=espresso2026&format=png
Header:   Authorization = Bearer <your API key>
Returns:  a PNG file
```

### Response: 200 OK

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

### Errors

| Status | Meaning |
| --- | --- |
| 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. |

## Create a dynamic code

`POST https://app.qrsalt.com/api/v1/codes` - requires an 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](#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.

A `PDF` code needs a file uploaded in the dashboard, so it is made there. The API reads, renames, pauses, moves, files and deletes it like any other code.

### Body

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `type` | URL \| TEXT \| WIFI \| EMAIL \| PHONE \| SMS \| VCARD \| GS1 \| REVIEW \| LOCATION \| EVENT \| PAYMENT \| APP_STORE | no | Default URL. |
| `name` | string, up to 120 | no | For your list. Named from the content when left out. |
| `kind` | DYNAMIC \| STATIC | no | Default DYNAMIC. STATIC puts the content in the pattern: it works with no signal, and it cannot change. |
| `destination` | string, up to 2048 | no | For a website code: where it goes. A shortcut for payload.url. https:// is added if it is missing. |
| `payload` | object | no | The type's fields, with the same names as in [Render](#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](#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](#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](#shorten) 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. |

**cURL**

```bash
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":"maya@example.com"}}'
```

**JavaScript**

```js
const res = await fetch("https://app.qrsalt.com/api/v1/codes", {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.QRSALT_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({"type":"VCARD","name":"Sales card","payload":{"firstName":"Maya","lastName":"Okafor","email":"maya@example.com"}}),
})
const { data, meta } = await res.json()
```

**Python**

```python
import json, os, urllib.request

req = urllib.request.Request(
    "https://app.qrsalt.com/api/v1/codes",
    method="POST",
    data=json.dumps({"type":"VCARD","name":"Sales card","payload":{"firstName":"Maya","lastName":"Okafor","email":"maya@example.com"}}).encode(),
    headers={
        "Authorization": "Bearer " + os.environ["QRSALT_KEY"],
        "Content-Type": "application/json",
    },
)
with urllib.request.urlopen(req) as res:
    answer = json.load(res)
```

**No-code**

```text
Method:   POST
URL:      https://app.qrsalt.com/api/v1/codes
Header:   Authorization = Bearer <your API key>
Header:   Content-Type = application/json
Body:     {"type":"VCARD","name":"Sales card","payload":{"firstName":"Maya","lastName":"Okafor","email":"maya@example.com"}}
Returns:  JSON
```

### Response: 201 Created

```json
{
  "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

| Status | Meaning |
| --- | --- |
| 402 | Your plan's dynamic codes are used up. The body names the plan that has room. |
| 409 | That link ending is already taken. |
| 422 | A field is invalid. |

## Shorten a link

`POST https://app.qrsalt.com/api/v1/links` - requires an 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](#update), send each click somewhere different with [Smart routing](#smart-routing), read its clicks with [Code scans](#scans) and remove it with [Delete](#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` - newest first, with `limit` (1 - 100, default 25), `offset` and `q` to search names and addresses - or with `GET /api/v1/codes?output=link` and every [list filter](#list). Every dynamic code is there, since every one has a short link. Want the QR code for a link? [Get a code's image](#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](#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. |

**cURL**

```bash
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"}'
```

**JavaScript**

```js
const res = await fetch("https://app.qrsalt.com/api/v1/links", {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.QRSALT_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({"url":"https://example.com/autumn-menu","name":"Instagram bio","slug":"autumn-menu"}),
})
const { data, meta } = await res.json()
```

**Python**

```python
import json, os, urllib.request

req = urllib.request.Request(
    "https://app.qrsalt.com/api/v1/links",
    method="POST",
    data=json.dumps({"url":"https://example.com/autumn-menu","name":"Instagram bio","slug":"autumn-menu"}).encode(),
    headers={
        "Authorization": "Bearer " + os.environ["QRSALT_KEY"],
        "Content-Type": "application/json",
    },
)
with urllib.request.urlopen(req) as res:
    answer = json.load(res)
```

**No-code**

```text
Method:   POST
URL:      https://app.qrsalt.com/api/v1/links
Header:   Authorization = Bearer <your API key>
Header:   Content-Type = application/json
Body:     {"url":"https://example.com/autumn-menu","name":"Instagram bio","slug":"autumn-menu"}
Returns:  JSON
```

### Response: 201 Created

```json
{
  "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

| Status | Meaning |
| --- | --- |
| 402 | Your plan's dynamic codes are used up, or a chosen ending needs a plan that has them. |
| 409 | That ending is already taken. |
| 422 | The address or the ending is not valid. The message says which rule. |

## Get a code

`GET https://app.qrsalt.com/api/v1/codes/{id}` - requires an API key.

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

**cURL**

```bash
curl -sS \
  'https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10' \
  -H "Authorization: Bearer $QRSALT_KEY"
```

**JavaScript**

```js
const res = await fetch("https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10", {
  headers: {
    Authorization: `Bearer ${process.env.QRSALT_KEY}`,
  },
})
const { data, meta } = await res.json()
```

**Python**

```python
import json, os, urllib.request

req = urllib.request.Request(
    "https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10",
    headers={
        "Authorization": "Bearer " + os.environ["QRSALT_KEY"],
    },
)
with urllib.request.urlopen(req) as res:
    answer = json.load(res)
```

**No-code**

```text
Method:   GET
URL:      https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10
Header:   Authorization = Bearer <your API key>
Returns:  JSON
```

### Response: 200 OK

```json
{
  "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

| Status | Meaning |
| --- | --- |
| 404 | No code with that id in your workspace. |

## Get a code's image

`GET https://app.qrsalt.com/api/v1/codes/{id}/image` - requires an 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. |

**cURL**

```bash
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
```

**JavaScript**

```js
import fs from 'node:fs'

const res = await fetch("https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10/image?format=png&size=1024", {
  headers: {
    Authorization: `Bearer ${process.env.QRSALT_KEY}`,
  },
})
await fs.promises.writeFile('qr.png', Buffer.from(await res.arrayBuffer()))
```

**Python**

```python
import json, os, urllib.request

req = urllib.request.Request(
    "https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10/image?format=png&size=1024",
    headers={
        "Authorization": "Bearer " + os.environ["QRSALT_KEY"],
    },
)
with urllib.request.urlopen(req) as res, open("qr.png", "wb") as f:
    f.write(res.read())
```

**No-code**

```text
Method:   GET
URL:      https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10/image?format=png&size=1024
Header:   Authorization = Bearer <your API key>
Returns:  a PNG file
```

### Response: 200 OK

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

### Errors

| Status | Meaning |
| --- | --- |
| 404 | No code with that id. |
| 422 | format, size or mm is out of range. |

## List codes

`GET https://app.qrsalt.com/api/v1/codes` - requires an 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). |

**cURL**

```bash
curl -sS \
  'https://app.qrsalt.com/api/v1/codes?limit=2' \
  -H "Authorization: Bearer $QRSALT_KEY"
```

**JavaScript**

```js
const res = await fetch("https://app.qrsalt.com/api/v1/codes?limit=2", {
  headers: {
    Authorization: `Bearer ${process.env.QRSALT_KEY}`,
  },
})
const { data, meta } = await res.json()
```

**Python**

```python
import json, os, urllib.request

req = urllib.request.Request(
    "https://app.qrsalt.com/api/v1/codes?limit=2",
    headers={
        "Authorization": "Bearer " + os.environ["QRSALT_KEY"],
    },
)
with urllib.request.urlopen(req) as res:
    answer = json.load(res)
```

**No-code**

```text
Method:   GET
URL:      https://app.qrsalt.com/api/v1/codes?limit=2
Header:   Authorization = Bearer <your API key>
Returns:  JSON
```

### Response: 200 OK

```json
{
  "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
  }
}
```

## Update or re-point a code

`PATCH https://app.qrsalt.com/api/v1/codes/{id}` - requires an 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](#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](#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. |

**cURL**

```bash
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"}'
```

**JavaScript**

```js
const res = await fetch("https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10", {
  method: 'PATCH',
  headers: {
    Authorization: `Bearer ${process.env.QRSALT_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({"destination":"https://example.com/winter"}),
})
const { data, meta } = await res.json()
```

**Python**

```python
import json, os, urllib.request

req = urllib.request.Request(
    "https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10",
    method="PATCH",
    data=json.dumps({"destination":"https://example.com/winter"}).encode(),
    headers={
        "Authorization": "Bearer " + os.environ["QRSALT_KEY"],
        "Content-Type": "application/json",
    },
)
with urllib.request.urlopen(req) as res:
    answer = json.load(res)
```

**No-code**

```text
Method:   PATCH
URL:      https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10
Header:   Authorization = Bearer <your API key>
Header:   Content-Type = application/json
Body:     {"destination":"https://example.com/winter"}
Returns:  JSON
```

### Response: 200 OK

```json
{
  "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

| Status | Meaning |
| --- | --- |
| 402 | Tags, folders or campaign tags are not in your plan. |
| 404 | No code or folder with that id. |
| 409 | The ending is already taken on that domain. |
| 422 | Nothing to change, an invalid field, an unverified domain, a custom link on a code that is not on your domain, or a static code (its content is in the pattern). |

## Delete a code

`DELETE https://app.qrsalt.com/api/v1/codes/{id}` - requires an 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.

**cURL**

```bash
curl -sS \
  -X DELETE \
  'https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10' \
  -H "Authorization: Bearer $QRSALT_KEY"
```

**JavaScript**

```js
const res = await fetch("https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10", {
  method: 'DELETE',
  headers: {
    Authorization: `Bearer ${process.env.QRSALT_KEY}`,
  },
})
const { data, meta } = await res.json()
```

**Python**

```python
import json, os, urllib.request

req = urllib.request.Request(
    "https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10",
    method="DELETE",
    headers={
        "Authorization": "Bearer " + os.environ["QRSALT_KEY"],
    },
)
with urllib.request.urlopen(req) as res:
    answer = json.load(res)
```

**No-code**

```text
Method:   DELETE
URL:      https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10
Header:   Authorization = Bearer <your API key>
Returns:  JSON
```

### Response: 200 OK

```json
{
  "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

| Status | Meaning |
| --- | --- |
| 404 | No code with that id. |

## Code scans

`GET https://app.qrsalt.com/api/v1/codes/{id}/scans` - requires an 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). |

**cURL**

```bash
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"
```

**JavaScript**

```js
const res = await fetch("https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10/scans?from=2026-08-25&to=2026-08-31&dimension=country", {
  headers: {
    Authorization: `Bearer ${process.env.QRSALT_KEY}`,
  },
})
const { data, meta } = await res.json()
```

**Python**

```python
import json, os, urllib.request

req = urllib.request.Request(
    "https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10/scans?from=2026-08-25&to=2026-08-31&dimension=country",
    headers={
        "Authorization": "Bearer " + os.environ["QRSALT_KEY"],
    },
)
with urllib.request.urlopen(req) as res:
    answer = json.load(res)
```

**No-code**

```text
Method:   GET
URL:      https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10/scans?from=2026-08-25&to=2026-08-31&dimension=country
Header:   Authorization = Bearer <your API key>
Returns:  JSON
```

### Response: 200 OK

```json
{
  "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

| Status | Meaning |
| --- | --- |
| 402 | That breakdown is not in your plan. |
| 404 | No code with that id. |
| 409 | Analytics are paused while the subscription is inactive. |

## Workspace analytics

`GET https://app.qrsalt.com/api/v1/analytics` - requires an 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). |

**cURL**

```bash
curl -sS \
  'https://app.qrsalt.com/api/v1/analytics?from=2026-08-25&to=2026-08-31&dimension=device' \
  -H "Authorization: Bearer $QRSALT_KEY"
```

**JavaScript**

```js
const res = await fetch("https://app.qrsalt.com/api/v1/analytics?from=2026-08-25&to=2026-08-31&dimension=device", {
  headers: {
    Authorization: `Bearer ${process.env.QRSALT_KEY}`,
  },
})
const { data, meta } = await res.json()
```

**Python**

```python
import json, os, urllib.request

req = urllib.request.Request(
    "https://app.qrsalt.com/api/v1/analytics?from=2026-08-25&to=2026-08-31&dimension=device",
    headers={
        "Authorization": "Bearer " + os.environ["QRSALT_KEY"],
    },
)
with urllib.request.urlopen(req) as res:
    answer = json.load(res)
```

**No-code**

```text
Method:   GET
URL:      https://app.qrsalt.com/api/v1/analytics?from=2026-08-25&to=2026-08-31&dimension=device
Header:   Authorization = Bearer <your API key>
Returns:  JSON
```

### Response: 200 OK

```json
{
  "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

| Status | Meaning |
| --- | --- |
| 402 | That breakdown is not in your plan. |
| 409 | Analytics are paused while the subscription is inactive. |

## Bulk actions

`POST https://app.qrsalt.com/api/v1/codes/bulk` - requires an 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](#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](#utm-presets). 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. |

**cURL**

```bash
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"]}'
```

**JavaScript**

```js
const res = await fetch("https://app.qrsalt.com/api/v1/codes/bulk", {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.QRSALT_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({"ids":["0b6f3c2a-9d41-4e7b-a8c5-1f2e3d4c5b6a","7c8d9e0f-1a2b-4c3d-9e4f-5a6b7c8d9e0f","c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f"],"action":"tags","add":["Spring"]}),
})
const { data, meta } = await res.json()
```

**Python**

```python
import json, os, urllib.request

req = urllib.request.Request(
    "https://app.qrsalt.com/api/v1/codes/bulk",
    method="POST",
    data=json.dumps({"ids":["0b6f3c2a-9d41-4e7b-a8c5-1f2e3d4c5b6a","7c8d9e0f-1a2b-4c3d-9e4f-5a6b7c8d9e0f","c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f"],"action":"tags","add":["Spring"]}).encode(),
    headers={
        "Authorization": "Bearer " + os.environ["QRSALT_KEY"],
        "Content-Type": "application/json",
    },
)
with urllib.request.urlopen(req) as res:
    answer = json.load(res)
```

**No-code**

```text
Method:   POST
URL:      https://app.qrsalt.com/api/v1/codes/bulk
Header:   Authorization = Bearer <your API key>
Header:   Content-Type = application/json
Body:     {"ids":["0b6f3c2a-9d41-4e7b-a8c5-1f2e3d4c5b6a","7c8d9e0f-1a2b-4c3d-9e4f-5a6b7c8d9e0f","c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f"],"action":"tags","add":["Spring"]}
Returns:  JSON
```

### Response: 200 OK

```json
{
  "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

| Status | Meaning |
| --- | --- |
| 402 | Folders, tags or presets are not in your plan, or resuming would pass your plan's dynamic codes. |
| 404 | The folder or preset is not in your workspace. |
| 409 | Another code took one of the links just now. Try again. |
| 422 | The body is invalid, more than 500 ids, the wrong `confirm`, or a domain that is not verified. |
| 429 | Too many bulk requests this hour. |

## Folders

`GET https://app.qrsalt.com/api/v1/folders` - requires an 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](#create) and [Update](#update), or many at once with [Bulk actions](#bulk).

**cURL**

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

**JavaScript**

```js
const res = await fetch("https://app.qrsalt.com/api/v1/folders", {
  headers: {
    Authorization: `Bearer ${process.env.QRSALT_KEY}`,
  },
})
const { data, meta } = await res.json()
```

**Python**

```python
import json, os, urllib.request

req = urllib.request.Request(
    "https://app.qrsalt.com/api/v1/folders",
    headers={
        "Authorization": "Bearer " + os.environ["QRSALT_KEY"],
    },
)
with urllib.request.urlopen(req) as res:
    answer = json.load(res)
```

**Create**

```bash
curl -sS \
  -X POST \
  'https://app.qrsalt.com/api/v1/folders' \
  -H "Authorization: Bearer $QRSALT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Spring menus"}'
```

### Response: 200 OK

```json
{
  "data": [
    {
      "id": "2e4c6a8b-0d1f-4a3c-9e5b-7d9f1b3d5f7a",
      "name": "Spring menus",
      "codes": 14,
      "createdAt": "2026-08-12T10:00:00.000Z"
    }
  ],
  "meta": {
    "total": 1
  }
}
```

### Errors

| Status | Meaning |
| --- | --- |
| 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. |

## Tags

`GET https://app.qrsalt.com/api/v1/tags` - requires an 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](#bulk). Filter the list of codes with `?tag=`.

**cURL**

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

**JavaScript**

```js
const res = await fetch("https://app.qrsalt.com/api/v1/tags", {
  headers: {
    Authorization: `Bearer ${process.env.QRSALT_KEY}`,
  },
})
const { data, meta } = await res.json()
```

**Python**

```python
import json, os, urllib.request

req = urllib.request.Request(
    "https://app.qrsalt.com/api/v1/tags",
    headers={
        "Authorization": "Bearer " + os.environ["QRSALT_KEY"],
    },
)
with urllib.request.urlopen(req) as res:
    answer = json.load(res)
```

**No-code**

```text
Method:   GET
URL:      https://app.qrsalt.com/api/v1/tags
Header:   Authorization = Bearer <your API key>
Returns:  JSON
```

### Response: 200 OK

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

## Campaign presets

`GET https://app.qrsalt.com/api/v1/utm-presets` - requires an API key.

The campaign-tag presets saved in the dashboard, for `action: "utm"` in [Bulk actions](#bulk). One code takes its values directly in `utm`.

**cURL**

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

**JavaScript**

```js
const res = await fetch("https://app.qrsalt.com/api/v1/utm-presets", {
  headers: {
    Authorization: `Bearer ${process.env.QRSALT_KEY}`,
  },
})
const { data, meta } = await res.json()
```

**Python**

```python
import json, os, urllib.request

req = urllib.request.Request(
    "https://app.qrsalt.com/api/v1/utm-presets",
    headers={
        "Authorization": "Bearer " + os.environ["QRSALT_KEY"],
    },
)
with urllib.request.urlopen(req) as res:
    answer = json.load(res)
```

**No-code**

```text
Method:   GET
URL:      https://app.qrsalt.com/api/v1/utm-presets
Header:   Authorization = Bearer <your API key>
Returns:  JSON
```

### Response: 200 OK

```json
{
  "data": [
    {
      "id": "8a0c2e4f-6b8d-4f1a-a3c5-e7a9c1e3a5c7",
      "name": "Spring posters",
      "utm": {
        "source": "poster",
        "medium": "print",
        "campaign": "spring"
      }
    }
  ],
  "meta": {
    "total": 1
  }
}
```

## List custom domains

`GET https://app.qrsalt.com/api/v1/domains` - requires an 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](#custom-domains) for the whole flow.

**cURL**

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

**JavaScript**

```js
const res = await fetch("https://app.qrsalt.com/api/v1/domains", {
  headers: {
    Authorization: `Bearer ${process.env.QRSALT_KEY}`,
  },
})
const { data, meta } = await res.json()
```

**Python**

```python
import json, os, urllib.request

req = urllib.request.Request(
    "https://app.qrsalt.com/api/v1/domains",
    headers={
        "Authorization": "Bearer " + os.environ["QRSALT_KEY"],
    },
)
with urllib.request.urlopen(req) as res:
    answer = json.load(res)
```

**No-code**

```text
Method:   GET
URL:      https://app.qrsalt.com/api/v1/domains
Header:   Authorization = Bearer <your API key>
Returns:  JSON
```

### Response: 200 OK

```json
{
  "data": [
    {
      "id": "6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14",
      "hostname": "go.yourbrand.com",
      "status": "VERIFIED",
      "default": true,
      "codes": 12,
      "dns": [
        {
          "type": "TXT",
          "name": "_qr-verify.go.yourbrand.com",
          "value": "k3v9x2m7q8w1z5r4tq6y"
        },
        {
          "type": "CNAME",
          "name": "go.yourbrand.com",
          "value": "your-cname-target.example"
        }
      ],
      "lastError": null,
      "lastCheckedAt": "2026-09-01T11:55:00.000Z",
      "nextCheckAt": "2026-09-02T11:55:00.000Z",
      "verifiedAt": "2026-08-20T09:12:00.000Z",
      "createdAt": "2026-08-20T09:00:00.000Z"
    },
    {
      "id": "1d3f5b7d-9f1b-4d3f-8b5d-7f9b1d3f5b7d",
      "hostname": "links.yourbrand.com",
      "status": "PENDING",
      "default": false,
      "codes": 0,
      "dns": [
        {
          "type": "TXT",
          "name": "_qr-verify.links.yourbrand.com",
          "value": "k3v9x2m7q8w1z5r4tq6y"
        },
        {
          "type": "CNAME",
          "name": "links.yourbrand.com",
          "value": "your-cname-target.example"
        }
      ],
      "lastError": null,
      "lastCheckedAt": null,
      "nextCheckAt": null,
      "verifiedAt": null,
      "createdAt": "2026-08-20T09:00:00.000Z"
    }
  ],
  "meta": {
    "total": 2,
    "limit": 10,
    "defaultDomain": "go.yourbrand.com"
  }
}
```

## Add a domain

`POST https://app.qrsalt.com/api/v1/domains` - requires an 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](#domain-check). 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. |

**cURL**

```bash
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"}'
```

**JavaScript**

```js
const res = await fetch("https://app.qrsalt.com/api/v1/domains", {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.QRSALT_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({"hostname":"go.yourbrand.com"}),
})
const { data, meta } = await res.json()
```

**Python**

```python
import json, os, urllib.request

req = urllib.request.Request(
    "https://app.qrsalt.com/api/v1/domains",
    method="POST",
    data=json.dumps({"hostname":"go.yourbrand.com"}).encode(),
    headers={
        "Authorization": "Bearer " + os.environ["QRSALT_KEY"],
        "Content-Type": "application/json",
    },
)
with urllib.request.urlopen(req) as res:
    answer = json.load(res)
```

**No-code**

```text
Method:   POST
URL:      https://app.qrsalt.com/api/v1/domains
Header:   Authorization = Bearer <your API key>
Header:   Content-Type = application/json
Body:     {"hostname":"go.yourbrand.com"}
Returns:  JSON
```

### Response: 201 Created

```json
{
  "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

| Status | Meaning |
| --- | --- |
| 402 | Your plan's domains are used up. The body names the plan with more. |
| 403 | Only an owner or admin can add domains, or custom domains are not set up yet. |
| 409 | Someone already added that domain. Contact support if it is yours. |
| 422 | Not a hostname, or one of ours. |

## Get a domain

`GET https://app.qrsalt.com/api/v1/domains/{id}` - requires an API key.

One domain, with its status, records and the number of codes on it. `lastError` says why the last check failed.

**cURL**

```bash
curl -sS \
  'https://app.qrsalt.com/api/v1/domains/6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14' \
  -H "Authorization: Bearer $QRSALT_KEY"
```

**JavaScript**

```js
const res = await fetch("https://app.qrsalt.com/api/v1/domains/6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14", {
  headers: {
    Authorization: `Bearer ${process.env.QRSALT_KEY}`,
  },
})
const { data, meta } = await res.json()
```

**Python**

```python
import json, os, urllib.request

req = urllib.request.Request(
    "https://app.qrsalt.com/api/v1/domains/6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14",
    headers={
        "Authorization": "Bearer " + os.environ["QRSALT_KEY"],
    },
)
with urllib.request.urlopen(req) as res:
    answer = json.load(res)
```

**No-code**

```text
Method:   GET
URL:      https://app.qrsalt.com/api/v1/domains/6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14
Header:   Authorization = Bearer <your API key>
Returns:  JSON
```

### Response: 200 OK

```json
{
  "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

| Status | Meaning |
| --- | --- |
| 404 | No domain with that id in your workspace. |

## Check DNS now

`POST https://app.qrsalt.com/api/v1/domains/{id}/check` - requires an 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.

**cURL**

```bash
curl -sS \
  -X POST \
  'https://app.qrsalt.com/api/v1/domains/6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14/check' \
  -H "Authorization: Bearer $QRSALT_KEY"
```

**JavaScript**

```js
const res = await fetch("https://app.qrsalt.com/api/v1/domains/6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14/check", {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.QRSALT_KEY}`,
  },
})
const { data, meta } = await res.json()
```

**Python**

```python
import json, os, urllib.request

req = urllib.request.Request(
    "https://app.qrsalt.com/api/v1/domains/6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14/check",
    method="POST",
    headers={
        "Authorization": "Bearer " + os.environ["QRSALT_KEY"],
    },
)
with urllib.request.urlopen(req) as res:
    answer = json.load(res)
```

**No-code**

```text
Method:   POST
URL:      https://app.qrsalt.com/api/v1/domains/6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14/check
Header:   Authorization = Bearer <your API key>
Returns:  JSON
```

### Response: 200 OK

```json
{
  "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

| Status | Meaning |
| --- | --- |
| 404 | No domain with that id. |
| 409 | The workspace is paused. |
| 429 | Checked less than 30 seconds ago. Try again after `resetAt`. |

## Set the default domain

`PATCH https://app.qrsalt.com/api/v1/domains/{id}` - requires an 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. |

**cURL**

```bash
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}'
```

**JavaScript**

```js
const res = await fetch("https://app.qrsalt.com/api/v1/domains/6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14", {
  method: 'PATCH',
  headers: {
    Authorization: `Bearer ${process.env.QRSALT_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({"default":true}),
})
const { data, meta } = await res.json()
```

**Python**

```python
import json, os, urllib.request

req = urllib.request.Request(
    "https://app.qrsalt.com/api/v1/domains/6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14",
    method="PATCH",
    data=json.dumps({"default":true}).encode(),
    headers={
        "Authorization": "Bearer " + os.environ["QRSALT_KEY"],
        "Content-Type": "application/json",
    },
)
with urllib.request.urlopen(req) as res:
    answer = json.load(res)
```

**No-code**

```text
Method:   PATCH
URL:      https://app.qrsalt.com/api/v1/domains/6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14
Header:   Authorization = Bearer <your API key>
Header:   Content-Type = application/json
Body:     {"default":true}
Returns:  JSON
```

### Response: 200 OK

```json
{
  "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

| Status | Meaning |
| --- | --- |
| 403 | Only an owner or admin can change the default. |
| 404 | No domain with that id. |
| 422 | The domain is not verified yet. |

## Remove a domain

`DELETE https://app.qrsalt.com/api/v1/domains/{id}` - requires an 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. |

**cURL**

```bash
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"
```

**JavaScript**

```js
const res = await fetch("https://app.qrsalt.com/api/v1/domains/6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14?confirm=go.yourbrand.com", {
  method: 'DELETE',
  headers: {
    Authorization: `Bearer ${process.env.QRSALT_KEY}`,
  },
})
const { data, meta } = await res.json()
```

**Python**

```python
import json, os, urllib.request

req = urllib.request.Request(
    "https://app.qrsalt.com/api/v1/domains/6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14?confirm=go.yourbrand.com",
    method="DELETE",
    headers={
        "Authorization": "Bearer " + os.environ["QRSALT_KEY"],
    },
)
with urllib.request.urlopen(req) as res:
    answer = json.load(res)
```

**No-code**

```text
Method:   DELETE
URL:      https://app.qrsalt.com/api/v1/domains/6f1c2b9e-4d3a-4e8f-9b21-7a5c0d3e8f14?confirm=go.yourbrand.com
Header:   Authorization = Bearer <your API key>
Returns:  JSON
```

### Response: 200 OK

```json
{
  "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

| Status | Meaning |
| --- | --- |
| 403 | Only an owner or admin can remove domains. |
| 404 | No domain with that id. |
| 422 | Codes use the domain and `confirm` is missing or not its hostname. |

## QR Menus

`GET https://app.qrsalt.com/api/v1/pages` - requires an API key.

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

**cURL**

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

**JavaScript**

```js
const res = await fetch("https://app.qrsalt.com/api/v1/pages", {
  headers: {
    Authorization: `Bearer ${process.env.QRSALT_KEY}`,
  },
})
const { data, meta } = await res.json()
```

**Python**

```python
import json, os, urllib.request

req = urllib.request.Request(
    "https://app.qrsalt.com/api/v1/pages",
    headers={
        "Authorization": "Bearer " + os.environ["QRSALT_KEY"],
    },
)
with urllib.request.urlopen(req) as res:
    answer = json.load(res)
```

**No-code**

```text
Method:   GET
URL:      https://app.qrsalt.com/api/v1/pages
Header:   Authorization = Bearer <your API key>
Returns:  JSON
```

### Response: 200 OK

```json
{
  "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
  }
}
```

## QR Forms

`GET https://app.qrsalt.com/api/v1/forms` - requires an API key.

Your QR Forms, read-only, with their response counts. Read the answers with [Form responses](#form-responses), or get each one as it arrives with the `form.submitted` [webhook](#events).

**cURL**

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

**JavaScript**

```js
const res = await fetch("https://app.qrsalt.com/api/v1/forms", {
  headers: {
    Authorization: `Bearer ${process.env.QRSALT_KEY}`,
  },
})
const { data, meta } = await res.json()
```

**Python**

```python
import json, os, urllib.request

req = urllib.request.Request(
    "https://app.qrsalt.com/api/v1/forms",
    headers={
        "Authorization": "Bearer " + os.environ["QRSALT_KEY"],
    },
)
with urllib.request.urlopen(req) as res:
    answer = json.load(res)
```

**No-code**

```text
Method:   GET
URL:      https://app.qrsalt.com/api/v1/forms
Header:   Authorization = Bearer <your API key>
Returns:  JSON
```

### Response: 200 OK

```json
{
  "data": [
    {
      "id": "a3e9b7c1-2f4d-4a6b-8c0e-5d7f9a1b3c2e",
      "title": "Event RSVP",
      "slug": "event-rsvp",
      "url": "https://app.qrsalt.com/f/event-rsvp",
      "published": true,
      "responses": 42,
      "views": 310,
      "createdAt": "2026-08-01T09:00:00.000Z",
      "updatedAt": "2026-08-28T17:45:00.000Z"
    }
  ],
  "meta": {
    "total": 1
  }
}
```

## Form responses

`GET https://app.qrsalt.com/api/v1/forms/{id}/responses` - requires an 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. |

**cURL**

```bash
curl -sS \
  'https://app.qrsalt.com/api/v1/forms/a3e9b7c1-2f4d-4a6b-8c0e-5d7f9a1b3c2e/responses?limit=1' \
  -H "Authorization: Bearer $QRSALT_KEY"
```

**JavaScript**

```js
const res = await fetch("https://app.qrsalt.com/api/v1/forms/a3e9b7c1-2f4d-4a6b-8c0e-5d7f9a1b3c2e/responses?limit=1", {
  headers: {
    Authorization: `Bearer ${process.env.QRSALT_KEY}`,
  },
})
const { data, meta } = await res.json()
```

**Python**

```python
import json, os, urllib.request

req = urllib.request.Request(
    "https://app.qrsalt.com/api/v1/forms/a3e9b7c1-2f4d-4a6b-8c0e-5d7f9a1b3c2e/responses?limit=1",
    headers={
        "Authorization": "Bearer " + os.environ["QRSALT_KEY"],
    },
)
with urllib.request.urlopen(req) as res:
    answer = json.load(res)
```

**No-code**

```text
Method:   GET
URL:      https://app.qrsalt.com/api/v1/forms/a3e9b7c1-2f4d-4a6b-8c0e-5d7f9a1b3c2e/responses?limit=1
Header:   Authorization = Bearer <your API key>
Returns:  JSON
```

### Response: 200 OK

```json
{
  "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

| Status | Meaning |
| --- | --- |
| 404 | No form with that id in your workspace. |

## Webhooks

A webhook is a web address on your server. We send it a message the moment something happens, like a scan or a form answer, so your other tools find out right away.

Webhooks come with Business. Add an endpoint in [Dashboard → Webhooks](/dashboard/webhooks), pick the events, and copy its signing secret.

## Events

Subscribe an endpoint to any of these:

- `code.created` - A code was created
- `code.updated` - A code was renamed or re-pointed
- `code.disabled` - A code was paused, deleted or disabled
- `scan.recorded` - A code was scanned
- `form.submitted` - A QR Form was answered (the answers are included)

## Payload

Each event is a POST of JSON to your URL, with `x-qr-signature`, `x-qr-event-id` (the same on every retry) and `x-qr-event` headers. Location is country, region and city only; no IP address leaves us.

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

**scan.recorded**

```json
{
  "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
  }
}
```

**form.submitted**

```json
{
  "id": "evt_2a9c41e07b3d5f68",
  "event": "form.submitted",
  "createdAt": "2026-09-10T12:15:40.000Z",
  "data": {
    "formId": "f0f0f0f0-0000-4000-8000-000000000000",
    "slug": "Tq8Lm2",
    "title": "How was your visit?",
    "responseId": "a11ce000-0000-4000-8000-000000000000",
    "version": 3,
    "submittedAt": "2026-09-10T12:15:40.000Z",
    "country": "NO",
    "score": null,
    "answers": [
      {
        "questionId": "f_k2x9q1ab",
        "question": "How was the food?",
        "kind": "rating",
        "answer": 4
      },
      {
        "questionId": "f_p7c3m0de",
        "question": "Anything we should fix?",
        "kind": "textarea",
        "answer": "The soup was cold."
      },
      {
        "questionId": "f_z1v8r4gh",
        "question": "Email, if you want a reply",
        "kind": "email",
        "answer": null
      }
    ],
    "truncated": false
  }
}
```

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

**Node.js**

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

**Python**

```python
import hashlib, hmac, time

# raw_body: the request body exactly as received, as bytes, before json.loads.
# Flask: request.get_data()   Django: request.body
def verify(raw_body: bytes, header: str, secret: str) -> bool:
    t, offered = None, []
    for part in str(header).split(","):
        key, _, value = part.strip().partition("=")
        if key == "t" and value.isascii() and value.isdigit():
            t = int(value)
        elif key == "v1" and value:
            offered.append(value.encode())
    # Reject anything older than five minutes: that is what stops a replay.
    if t is None or abs(time.time() - t) > 300:
        return False
    signed = str(t).encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest().encode()
    return any(hmac.compare_digest(v1, expected) for v1 in offered)
```

## Retries

Answer with any 2xx within 10 seconds. Anything else is a failure.

- We send at once, then retry after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 2 hours and 6 hours — 7 attempts across about 9 hours.
- A 4xx other than 408 or 429 is not retried. Redirects are not followed.
- After the last attempt, or on 410 Gone, the endpoint is switched off and the workspace owner is emailed.
- Delivery is at least once, so deduplicate on `x-qr-event-id`.

## Smart routing

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](#create) or [Update](#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. |

**cURL**

```bash
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"}]}'
```

**JavaScript**

```js
const res = await fetch("https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10", {
  method: 'PATCH',
  headers: {
    Authorization: `Bearer ${process.env.QRSALT_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({"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"}]}),
})
const { data, meta } = await res.json()
```

**Python**

```python
import json, os, urllib.request

req = urllib.request.Request(
    "https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10",
    method="PATCH",
    data=json.dumps({"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"}]}).encode(),
    headers={
        "Authorization": "Bearer " + os.environ["QRSALT_KEY"],
        "Content-Type": "application/json",
    },
)
with urllib.request.urlopen(req) as res:
    answer = json.load(res)
```

**No-code**

```text
Method:   PATCH
URL:      https://app.qrsalt.com/api/v1/codes/8c1f4e2a-5b7d-4c3e-9a1f-2d6b8e4c7a10
Header:   Authorization = Bearer <your API key>
Header:   Content-Type = application/json
Body:     {"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"}]}
Returns:  JSON
```

### Errors

| Status | Meaning |
| --- | --- |
| 402 | A condition your plan does not include. The body names the plan that has it. |
| 422 | A rule cannot be read, one of its links is refused, or the code is static. |

## Custom domains and custom links

Put your own domain on codes and links, like `go.yourbrand.com/menu`. Custom links on your domain come with Starter and above; a chosen ending on our domain needs Business.

1. [Add the domain](#domain-add).
2. Add the `TXT` and `CNAME` records from `dns` at your DNS provider. On Cloudflare, set the CNAME to DNS only.
3. [Check it](#domain-check) until `status` is `VERIFIED`, or wait: we check every few minutes.
4. Create codes and links with `domain`, and `slug` for the custom link. Or [make it the default](#domain-default) and leave `domain` out.
5. Move codes you already have with `domain` on [Update](#update), or many at once with [Bulk actions](#bulk).
- 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.

**cURL**

```bash
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"}'
```

**JavaScript**

```js
const res = await fetch("https://app.qrsalt.com/api/v1/codes", {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.QRSALT_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({"destination":"https://example.com/menu","domain":"go.yourbrand.com","slug":"menu"}),
})
const { data, meta } = await res.json()
```

**Python**

```python
import json, os, urllib.request

req = urllib.request.Request(
    "https://app.qrsalt.com/api/v1/codes",
    method="POST",
    data=json.dumps({"destination":"https://example.com/menu","domain":"go.yourbrand.com","slug":"menu"}).encode(),
    headers={
        "Authorization": "Bearer " + os.environ["QRSALT_KEY"],
        "Content-Type": "application/json",
    },
)
with urllib.request.urlopen(req) as res:
    answer = json.load(res)
```

**No-code**

```text
Method:   POST
URL:      https://app.qrsalt.com/api/v1/codes
Header:   Authorization = Bearer <your API key>
Header:   Content-Type = application/json
Body:     {"destination":"https://example.com/menu","domain":"go.yourbrand.com","slug":"menu"}
Returns:  JSON
```

### Response: 201 Created

```json
{
  "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": {}
}
```

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

**Settings**

```text
Method:   POST
URL:      https://app.qrsalt.com/api/v1/codes
Header:   Authorization = Bearer <your API key>
Header:   Content-Type = application/json
Body:     {"type":"URL","name":"Row 42","destination":"https://example.com/42"}
Returns:  JSON
```

## Formats and print

Use **SVG** for websites and design tools, **PNG** for documents and email, **JPG** when a tool asks for it, **WebP** for small web images, and **PDF** for print. SVG and PDF are vector, so they stay sharp at any size.

For print, send `format=pdf` and the printed width in `mm`; 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.

## Examples in every language

The same request - a Wi-Fi QR code as a PNG - in each language. Keep the key in `QRSALT_KEY`.

**cURL**

```bash
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
```

**JavaScript**

```js
import fs from 'node:fs'

const res = await fetch("https://app.qrsalt.com/api/qr?type=wifi&ssid=Cafe-Guest&password=espresso2026&format=png", {
  headers: {
    Authorization: `Bearer ${process.env.QRSALT_KEY}`,
  },
})
await fs.promises.writeFile('qr.png', Buffer.from(await res.arrayBuffer()))
```

**Python**

```python
import json, os, urllib.request

req = urllib.request.Request(
    "https://app.qrsalt.com/api/qr?type=wifi&ssid=Cafe-Guest&password=espresso2026&format=png",
    headers={
        "Authorization": "Bearer " + os.environ["QRSALT_KEY"],
    },
)
with urllib.request.urlopen(req) as res, open("qr.png", "wb") as f:
    f.write(res.read())
```

**PHP**

```php
<?php
$ch = curl_init('https://app.qrsalt.com/api/qr?type=wifi&ssid=Cafe-Guest&password=espresso2026&format=png');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('QRSALT_KEY')],
    CURLOPT_RETURNTRANSFER => true,
]);
file_put_contents('qr.png', curl_exec($ch));
```