# Hovercode API

> Create and manage QR codes, PDF codes, short links, landing pages, forms, and GS1 Digital Link codes programmatically.

Base URL: `https://hovercode.com`

Human-readable docs: https://hovercode.com/api/

**Prefer working from an AI assistant?** Hovercode also has an [MCP server](https://hovercode.com/mcp/docs) so tools like Claude and Cursor can create and manage all of this conversationally.

---

## Introduction

Hovercode's API lets you create and update [dynamic QR codes](https://hovercode.com/blog/static-vs-dynamic-qr-codes/), PDF codes, short links, hosted landing pages, forms, and GS1 Digital Link codes programmatically. It's ideal for creating codes in bulk or adding QR/link features to your own product: see [Build QR codes into your product](#build-qr-codes-into-your-product).

You need the Business plan to use the API, and you can try it on a free trial — see [pricing](https://hovercode.com/pricing/).

**Note:** You can't call this API from the browser — that would expose your API key. Call it from a back-end (PHP, Node.js, Python, Ruby, etc.).

---

## Authentication

Every request must include an `Authorization` header with your API token:

```
Authorization: Token YOUR-TOKEN
```

Find your token in your account settings while logged in. Keep it private. Many endpoints also take a workspace ID, as a field named `workspace`: it's in your account settings, or get it from the token alone with [List workspaces](#workspaces).


## Rate limits

Limits are per API token, counted in one-minute windows:

| Requests | Limit |
|---|---|
| Reads (`GET`) | 120 per minute |
| Writes (`POST`, `PUT`, `PATCH`, `DELETE`) | 60 per minute |
| No token, or a rejected token | 20 per minute per IP address |
| MCP server tool calls | 60 per minute |

Every API response says where you stand:

```
RateLimit-Limit: 60
RateLimit-Remaining: 42
RateLimit-Reset: 18
RateLimit-Policy: 60;w=60
```

`RateLimit-Reset` is the number of seconds until the window resets. Over the limit, the API answers `429 Too Many Requests` with a `Retry-After` header (seconds) and a JSON body:

```json
{"error": {"code": "rate_limited", "message": "Rate limit exceeded: 60 requests per 60 seconds. Retry after 18 seconds.", "retry_after": 18}}
```

Wait for `Retry-After` before retrying rather than retrying straight away. For bulk jobs, pace requests using `RateLimit-Remaining`. If you need higher limits, [contact us](https://hovercode.com/contact/).

---

## Workspaces

### List workspaces

`GET https://hovercode.com/api/v2/workspaces/`

The workspaces your token can use, oldest first. Use it to find a workspace ID from the token alone. Each result: `id`, `name`, `current` (the account's most recently used workspace), `plan`, `paid`, `dynamic_qr_used` and `dynamic_qr_limit`. `current_workspace` is that workspace's `id`. Paginated 50 per page; `?page_size=` raises it to 200.

```json
{
  "current_workspace": "6b0c1f8e-1c2a-4a7f-9a0e-5d2b3c4e5f60",
  "count": 1,
  "next": null,
  "previous": null,
  "results": [
    {"id": "6b0c1f8e-1c2a-4a7f-9a0e-5d2b3c4e5f60", "name": "Acme", "current": true, "plan": "Business", "paid": true, "dynamic_qr_used": 41, "dynamic_qr_limit": 500, "pages_used": 2}
  ]
}
```

### Create a workspace

`POST https://hovercode.com/api/v2/workspace/create/`

Creates a workspace you own, for example one per customer of your product (see [Build QR codes into your product](#build-qr-codes-into-your-product)). Body: `{"name": "Acme Corp"}` (up to 30 characters). Returns `201` with the same shape as a listing row. Your account's current workspace doesn't change. Multiple workspaces are on the **Business Plus** plan, up to 10 ([contact us](https://hovercode.com/contact/) for more); other plans include one. You get `403` at your plan's limit.

### Rename a workspace

`PATCH https://hovercode.com/api/v2/workspace/{WORKSPACE-ID}/update/`

Body: `{"name": "New name"}`. Owners and admins only.

Workspaces can't be deleted through the API, because deleting one breaks every QR code in it, including printed ones. Delete them from the dashboard.

---

## QR codes

### Create a QR code

`POST https://hovercode.com/api/v2/hovercode/create/`

Generates a QR code. Returns the QR code as an SVG string by default; set `generate_png: true` to also get `.png` and `.svg` file URLs (slower). `qr_type` defaults to `Link`; use `Text` for plain text, or see the vCard and GS1 sections for those types.

**Parameters**

| Name | Required | Description |
|---|---|---|
| `workspace` | required | Your workspace ID |
| `qr_data` | required (Link/Text) | For `qr_type=Link`: a valid URL. For `qr_type=Text`: any plain text |
| `qr_type` | optional | `Link` (default), `Text`, `vCard`, or `GS1` |
| `dynamic` | optional | `false` by default. Set `true` for a dynamic QR code |
| `display_name` | optional | Internal name for organising codes |
| `domain` | optional | Custom domain for dynamic codes |
| `generate_png` | optional | `true` also returns `.png` and `.svg` file URLs |
| `gps_tracking` | optional | Enables GPS tracking for dynamic codes |
| `error_correction` | optional | `L`, `M`, `Q`, or `H` |
| `size` | optional | Width in pixels. Defaults to `220` |
| `logo_url` | optional | URL to an image to embed as a logo |
| `folder` | optional | ID or slug of a folder to file the code into (see [Folders](#folders)) |
| `template` | optional | ID of a design template saved in your workspace. Applies its full design (colors, pattern, eye style, frame, text, logo); fields passed explicitly override the template. Get IDs from List templates |
| `logo_round` | optional | Force logo into a circle shape |
| `primary_color` | optional | Hex color (with `#`). Defaults to `#111111`. Eyes and frame use this color too unless `eye_color`/`frame_color` are also given |
| `eye_color` | optional | Hex color for the corner eyes, if different from `primary_color` |
| `background_color` | optional | Hex color (with `#`). Transparent by default |
| `pattern` | optional | `Original` (default), `Circles`, `Squares`, `Diamonds`, `Triangles` |
| `eye_style` | optional | `Square` (default), `Rounded`, `Drop`, `Leaf` |
| `frame` | optional | `border`, `border-small`, `border-large`, `square`, `speech-bubble`, `speech-bubble-above`, `card`, `card-above`, `text-frame`, `round-frame`, `circle-viewfinder`, `solid-spin`, `burst`, `scattered-lines`, `polkadot`, `swirl`. If `text` is set and this is omitted (or the frame given doesn't render text), a frame that does (`card`) is used automatically |
| `frame_color` | optional | Hex color for the frame, if different from `primary_color` |
| `has_border` | optional | For frames with a border option |
| `text` | optional | For frames with a text option — see the automatic frame selection under `frame` above |

```python
import requests

data = {
    "workspace": "YOUR-WORKSPACE-ID",
    "qr_data": "https://twitter.com/hovercodeHQ",
    "primary_color": "#1DA1F2"
}

response = requests.post(
    "https://hovercode.com/api/v2/hovercode/create/",
    headers={"Authorization": "Token YOUR-TOKEN"},
    json=data,
    timeout=10,
)
```

### Create a vCard QR code

`POST https://hovercode.com/api/v2/hovercode/create/`

Set `qr_type` to `vCard` and pass a nested `vcard` object (instead of `qr_data`). A dynamic vCard (`dynamic: true`) is editable later; a static one (default) encodes the contact directly. `first_name` is required; all other vCard fields (`last_name`, `company_name`, `position`, `email`, `mobile_number`, `phone_number`, `website`, `street`, `city`, `state`, `country`, `post_code`, `description`) are optional.

```python
import requests

data = {
    "workspace": "YOUR-WORKSPACE-ID",
    "qr_type": "vCard",
    "dynamic": True,
    "vcard": {
        "first_name": "Ada",
        "last_name": "Lovelace",
        "email": "ada@example.com"
    }
}

response = requests.post(
    "https://hovercode.com/api/v2/hovercode/create/",
    headers={"Authorization": "Token YOUR-TOKEN"},
    json=data,
    timeout=10,
)
```

### List QR codes

`GET https://hovercode.com/api/v2/workspace/{WORKSPACE-ID}/hovercodes/`

Each result: `id`, `display_name`, `qr_data`, `qr_type`, `shortlink_url`, `dynamic`, `png`, `created`, and `folder` (an object with `id`, `name`, `slug` and `path` — the full breadcrumb — or `null` when the code isn't filed).

Paginated 50 per page; `?page=` walks the pages and `?page_size=` raises it to 200. Add `?q=` to search links, display names, shortlink URLs, and tags.

**Query parameters**

| Name | Description |
|---|---|
| `q` | Search links, display names, shortlink URLs, and tags |
| `folder` | Only codes in this folder — its `id` or its `slug`. Use `none` for codes that aren't in a folder |
| `subfolders` | With `folder`, also include codes in its subfolders. `true`/`false` |
| `include` | Comma-separated extras: `scans` adds `total_scans` and `unique_scans` to every code, `tags` adds each code's tags |
| `days` / `start` / `end` | With `include=scans`, count a window instead of all time (see below) |
| `page`, `page_size` | Pagination — 50 per page, up to 200 |

`include=scans` counts the whole page in one query, so you don't need a request per code. The counts are **lifetime** by default (the same numbers as the dashboard and the single-code endpoints); add `?days=30`, or `?start=2026-01-01&end=2026-03-31`, to count a window instead. Codes that can't be tracked at all (static codes have no short link) report `null` rather than `0`.

```
GET /api/v2/workspace/{WORKSPACE-ID}/hovercodes/?include=scans&page_size=200
```

```json
{
  "count": 128,
  "next": "https://hovercode.com/api/v2/workspace/.../hovercodes/?page=2&page_size=200",
  "results": [
    {
      "id": "2fbb014a-4b5a-4ecd-95a3-p914d4aa167b",
      "display_name": "Spring menu",
      "qr_data": "https://example.com/menu",
      "qr_type": "Link",
      "shortlink_url": "https://qrr.to/spring-menu",
      "dynamic": true,
      "created": "2026-03-04T09:12:44.221Z",
      "folder": {
        "id": "6b0c1f8e-1c2a-4a7f-9a0e-5d2b3c4e5f60",
        "name": "Spring 2026",
        "slug": "spring-2026",
        "path": "Campaigns / Spring 2026"
      },
      "total_scans": 1841,
      "unique_scans": 1502
    }
  ]
}
```

### Reporting: building a table of codes and scans

This is the shape most BI tools (Power BI, Looker Studio, a warehouse sync) want — every code with its folder and scan counts, without a request per code:

```
GET https://hovercode.com/api/v2/workspace/{WORKSPACE-ID}/hovercodes/?include=scans&page_size=200
```

Walk `next` until it's `null`. Each row already carries `folder name`, `id`, `created`, `display_name`, `qr_data`, `shortlink_url`, `qr_type`, `total_scans` and `unique_scans`.

In Power BI, `Web.Contents` with the `Authorization` header, then expand the `folder` column to get `folder.name` or `folder.path`:

```
let
    Source = Json.Document(Web.Contents(
        "https://hovercode.com/api/v2/workspace/YOUR-WORKSPACE-ID/hovercodes/?include=scans&page_size=200",
        [Headers=[Authorization="Token YOUR-TOKEN"]]
    )),
    Codes = Table.FromRecords(Source[results]),
    Expanded = Table.ExpandRecordColumn(Codes, "folder", {"name", "path"}, {"folder_name", "folder_path"})
in
    Expanded
```

Note that `/workspace/{WORKSPACE-ID}/analytics/` is a *different* thing: one aggregate row for the whole workspace, not a row per code. Use it for headline totals, not for a code-level table.

### Folders

`GET https://hovercode.com/api/v2/workspace/{WORKSPACE-ID}/folders/`

The workspace's folders, so a report can resolve or filter by them. Each result: `id`, `name`, `slug`, `path` (full breadcrumb), `parent` (a folder id, or `null` at the top level), `code_count`, and `created`.

`POST https://hovercode.com/api/v2/folder/create/`

Creates a folder. Fields: `workspace` (required), `name` (required), and `parent` (optional — a folder `id` or `slug` to nest under). Creating a folder that already exists with the same name under the same parent returns the existing one instead of a duplicate, so a sync job can call it repeatedly.

`PATCH https://hovercode.com/api/v2/folder/{FOLDER-ID}/update/`

Renames and/or moves a folder. Fields: `name` (new name; the slug is regenerated) and `parent` (a folder `id` or `slug` to nest under, or `null` for the top level). A folder can't be moved into itself or one of its subfolders.

`DELETE https://hovercode.com/api/v2/folder/{FOLDER-ID}/delete/`

Deletes a folder. Codes in it (and in its subfolders) become unfiled — they are never deleted — and its subfolders move up to its parent. Returns `204`.

To file a code into a folder, pass `folder` (an `id` or `slug`) when creating it, or send `folder` to the update endpoint to move it — `"folder": null` takes it out of its folder.

### List templates

`GET https://hovercode.com/api/v2/workspace/{WORKSPACE-ID}/templates/`

Returns the QR design templates saved in your workspace (created in the dashboard), paginated 50 per page. Each result includes `id`, `title`, `primary_color`, `eye_color`, `background_color`, `pattern`, `eye_style`, `frame_color`, `has_border`, `text`, `frame`, `has_logo`, and a `preview` image URL. Pass a template's `id` as the `template` field on the create endpoint to apply its design.

### Get a single QR code

`GET https://hovercode.com/api/v2/hovercode/{QR-CODE-ID}/`

### Get QR code tracking activity

`GET https://hovercode.com/api/v2/hovercode/{QR-CODE-ID}/activity/`

Paginated (max 200 via `page_size`). Each result: `qr_code_id`, `time_utc`, `time_timezone_aware`, `location`, `device`, `scanner_id`, `id`.

### Get scan analytics

`GET https://hovercode.com/api/v2/hovercode/{QR-CODE-ID}/analytics/`

Aggregated stats for one code over a period: `total_scans`, `unique_scans`, `scans_by_day`, and top `top_countries`, `top_cities`, `top_os`, `top_browsers`.

The period is the last 30 days by default. Add `?days=` (up to 365), or set an explicit range with `?start=2026-01-01&end=2026-03-31` (ISO dates or datetimes; `start` alone means "since then", which is how you get all-time figures).

For the whole workspace, use `GET https://hovercode.com/api/v2/workspace/{WORKSPACE-ID}/analytics/` — same shape, plus `top_codes` (each with `id`, `display_name`, `destination`, `scans` and `unique_scans`; `?top=` returns more than the default 10, up to 100). It's one aggregate for the workspace — for per-code numbers, use `?include=scans` on the [code listing](#list-qr-codes) or the single-code endpoint above.

### Download images

`GET https://hovercode.com/api/v2/hovercode/{QR-CODE-ID}/download/`

Returns print-ready image URLs: `png_url` (high-resolution PNG) and `svg_url` (vector SVG — best for large sizes/print). Images are generated on first request.

### Update a QR code

`POST https://hovercode.com/api/v2/hovercode/{QR-CODE-ID}/update/` (`PATCH` also works)

Change `display_name`, `qr_data` (Link type only), `gps_tracking`, or `folder` (a folder `id` or `slug` to move the code; `null` takes it out of its folder). Returns the same shape as GET.

### Update a QR code's design

`PATCH https://hovercode.com/api/v2/hovercode/{QR-CODE-ID}/design/`

Restyle an existing code **in place** — the code, its destination, and its scan history are unchanged, so an already-printed code keeps working. The QR is re-rendered and the new `svg` is returned. Accepts any of: `primary_color`, `eye_color`, `background_color`, `pattern`, `eye_style`, `frame`, `frame_color`, `has_border`, `text`, `text_secondary`, `error_correction`, `logo_url`, `logo_base64` (a JPEG/PNG/GIF/WebP under 2 MB, base64-encoded, `data:` prefix allowed), `logo_round`, and `remove_logo` (set `true` to clear the logo). Same values as the create endpoint.

Sending `primary_color` alone recolors the eyes and frame to match too, unless `eye_color`/`frame_color` are also given — restyling a code made outside the API (dashboard, browser extension, Figma, Canva) now behaves the same way. Sending `text` without a frame that can show it picks one (`card`) automatically rather than silently dropping the caption; an explicitly chosen frame is never overridden.

```python
import requests

response = requests.patch(
    "https://hovercode.com/api/v2/hovercode/{QR-CODE-ID}/design/",
    headers={"Authorization": "Token YOUR-TOKEN"},
    json={"primary_color": "#1DA1F2", "pattern": "Circles", "eye_style": "Rounded"},
    timeout=10,
)
```

### Tags

`GET https://hovercode.com/api/v2/workspace/{WORKSPACE-ID}/tags/`

The workspace's tags. Each result: `id`, `title`, `slug`.

`POST https://hovercode.com/api/v2/hovercode/{QR-CODE-ID}/tags/add/`

Adds tags to a code. Send a JSON list of `{"title": "..."}` objects (a tag is created in the workspace if it doesn't exist) or `{"id": "..."}` for existing tags. Returns the code with its `tags`.

`DELETE https://hovercode.com/api/v2/hovercode/{QR-CODE-ID}/tag/{TAG-ID-OR-SLUG}/remove/`

Removes one tag from a code (the tag itself is kept). Returns the code with its remaining `tags`.

### Delete a QR code

`DELETE https://hovercode.com/api/v2/hovercode/{QR-CODE-ID}/delete/`

Returns `204` on success.

---

## PDF codes

Host a PDF and get a dynamic QR code that points at it — for menus, flyers, tickets, and similar. Because it's dynamic you can swap the PDF later without reprinting the code, and scans are tracked. PDF codes are dynamic, so they count toward your plan's dynamic-code limit.

### Create a PDF code

`POST https://hovercode.com/api/v2/pdf/create/`

Provide the PDF as **either** `pdf_url` (Hovercode fetches it) **or** `pdf_base64` (the file's base64-encoded bytes). Returns the QR code plus its shareable short link and the hosted PDF URL.

**Parameters**

| Name | Required | Description |
|---|---|---|
| `workspace` | required | Your workspace ID |
| `pdf_url` | required* | A URL Hovercode fetches the PDF from. *Provide this or `pdf_base64` |
| `pdf_base64` | required* | Base64-encoded PDF bytes (a `data:` URI prefix is allowed), max 20 MB. *Provide this or `pdf_url` |
| `filename` | optional | Name for the stored file, e.g. `menu.pdf` |
| `display_name` | optional | Internal name for organising codes |
| `domain` | optional | A custom short-link domain available to your workspace |

```python
import requests

data = {
    "workspace": "YOUR-WORKSPACE-ID",
    "pdf_url": "https://example.com/menu.pdf",
    "filename": "menu.pdf"
}

response = requests.post(
    "https://hovercode.com/api/v2/pdf/create/",
    headers={"Authorization": "Token YOUR-TOKEN"},
    json=data,
    timeout=30,
)
```

The response is the standard QR code shape (`id`, `qr_data`, `shortlink_url`, `dynamic`, `svg`, …) plus `pdf_file_url` for the raw hosted file.

---

## Short links

A short link is a dynamic redirect with an associated QR code (the SVG is generated immediately; the `.png` is generated on first request).

### Create a short link

`POST https://hovercode.com/api/v2/link/create/`

**Parameters**

| Name | Required | Description |
|---|---|---|
| `workspace` | required | Your workspace ID |
| `link` | required | The destination URL |
| `slug` | optional | Custom slug (letters, numbers, hyphens). Unique per domain; not a reserved word. Auto-generated if omitted |
| `domain` | optional | One of the short link domains available to your workspace |
| `display_name` | optional | Internal organising name |
| `gps` | optional | Enable GPS location tracking |

```python
import requests

data = {
    "workspace": "YOUR-WORKSPACE-ID",
    "link": "https://twitter.com/hovercodeHQ",
    "slug": "hovercode-twitter"
}

response = requests.post(
    "https://hovercode.com/api/v2/link/create/",
    headers={"Authorization": "Token YOUR-TOKEN"},
    json=data,
    timeout=10,
)
```

### Get short links

`GET https://hovercode.com/api/v2/workspace/{WORKSPACE-ID}/links/`

Paginated. Add `?q=` to search destinations, display names, and slugs.

### Get a short link

`GET https://hovercode.com/api/v2/link/{LINK-ID}/`

### Update a short link

`PATCH https://hovercode.com/api/v2/link/{LINK-ID}/update/`

Update `link`, `display_name`, or `gps`.

### Delete a short link

`DELETE https://hovercode.com/api/v2/link/{LINK-ID}/delete/`

Returns `204`.

### Short link QR image

`GET https://hovercode.com/api/v2/link/{LINK-ID}/qr/?format=png`

`?format=svg` returns the SVG inline; `?format=png` (default) redirects to a generated `.png`.

### Short link activity

`GET https://hovercode.com/api/v2/link/{LINK-ID}/activity/`

Same paginated format as QR code activity.

---

## Landing pages

A landing page (micro "link-in-bio" page) is a hosted page with a title and a list of link buttons, plus its own short URL and QR code. Pages count toward your plan's page limit.

### Create a landing page

`POST https://hovercode.com/api/v2/page/create/`

Creates and publishes a page in one call, and returns its live URL and QR code.

**Parameters**

| Name | Required | Description |
|---|---|---|
| `workspace` | required | Your workspace ID |
| `title` | required | The page heading |
| `links` | optional | An ordered array of link buttons. Each: `url` (required), `title`, `type` (e.g. `Website` (default), `Instagram`, `YouTube`) |
| `description` | optional | Text shown under the title |
| `bg_color` | optional | Hex background color |
| `text_color` | optional | Hex text color |
| `domain` | optional | A custom page domain available to your workspace |
| `slug` | optional | Custom URL slug; auto-generated if omitted |

```python
import requests

data = {
    "workspace": "YOUR-WORKSPACE-ID",
    "title": "My Links",
    "links": [
        {"url": "https://example.com", "title": "Website"},
        {"url": "https://instagram.com/example", "title": "Instagram", "type": "Instagram"}
    ]
}

response = requests.post(
    "https://hovercode.com/api/v2/page/create/",
    headers={"Authorization": "Token YOUR-TOKEN"},
    json=data,
    timeout=10,
)
```

Response: `id`, `title`, `description`, `status`, `share_url` (the live page URL), `slug`, `qr_id`, `svg` (the QR), `links`, `created`.

### Get a landing page

`GET https://hovercode.com/api/v2/page/{PAGE-ID}/`

### List landing pages

`GET https://hovercode.com/api/v2/workspace/{WORKSPACE-ID}/pages/`

Returns the workspace's pages, newest first.

---

## Forms

Forms collect submissions through a shareable link or QR code. Building a form is a three-step flow: **create** the form, **save** its fields, then **publish** it — publishing mints the short link and QR code and makes the form live. You can then read submissions. Publishing creates a dynamic code, so it counts toward your dynamic-code limit.

### Create a form

`POST https://hovercode.com/api/v2/form/create/`

**Parameters**

| Name | Required | Description |
|---|---|---|
| `workspace` | required | Your workspace ID |
| `title` | required | The form title |
| `description` | optional | Shown above the fields |

Returns the form, including its `id`.

### Save fields

`POST https://hovercode.com/api/v2/form/{FORM-ID}/save/`

Saves the form's fields (and optional form-level settings) in one atomic call. Pass a `fields` array in display order. **New fields must be given an `id` that starts with `temp-`** (existing fields keep their real `id`); any field left out of the array is removed.

Each field: `field_type` (`text`, `email`, `phone`, `number`, `textarea`, `select`, `radio`, `checkbox`, `date`, `time`, `url`, `rating`, `scale`, `yes_no`, `image`), `label` (required), and optionally `required`, `placeholder`, and `options` (for `select`/`radio`/`checkbox`, as `[{"value": …, "label": …}]`). `image` (respondent photo/image upload) is available on paid plans — publishing a form containing one requires an active subscription.

The same call also accepts form-level settings: `title`, `description`, `redirect_url`, `thank_you_message`, and **branding** — `bg_color`, `text_color`, `button_color`, `button_text_color` (hex). To **edit** an existing form later, send the fields again including each field's real `id` to keep it (omit a field to remove it, leave `id` off a new one), then re-publish.

### Set a form's logo

`POST https://hovercode.com/api/v2/form/{FORM-ID}/logo-url/`

Set the form's logo from an image URL — `{"logo_url": "https://…/logo.png"}` (JPEG/PNG/GIF/WebP, max 2 MB). The JSON alternative to the multipart logo upload.

```python
import requests

fields = {
    "fields": [
        {"id": "temp-0", "field_type": "text", "label": "Name", "required": True},
        {"id": "temp-1", "field_type": "email", "label": "Email", "required": True},
        {"id": "temp-2", "field_type": "select", "label": "Shirt size",
         "options": [{"value": "S", "label": "S"}, {"value": "M", "label": "M"}]}
    ]
}

response = requests.post(
    "https://hovercode.com/api/v2/form/{FORM-ID}/save/",
    headers={"Authorization": "Token YOUR-TOKEN"},
    json=fields,
    timeout=10,
)
```

### Publish a form

`POST https://hovercode.com/api/v2/form/{FORM-ID}/publish/`

Snapshots the current fields as the live form, creates the short link and QR code, and sets the form to `Published`. Optional body: `domain` and `slug` (as with short links). Returns the form including `shortlink_url`, `qr_code_svg`, and (on first publish) `share_url`.

### Close or reopen a form

`POST https://hovercode.com/api/v2/form/{FORM-ID}/close/` stops accepting responses; `POST .../reopen/` resumes.

### List forms

`GET https://hovercode.com/api/v2/forms/?workspace={WORKSPACE-ID}`

The workspace's forms (newest first). `?workspace=` is required. Add `?q=` to search by title. Paginated (`?page=`, `?page_size=` up to 100).

### List responses

`GET https://hovercode.com/api/v2/forms/{FORM-ID}/responses/`

A form's submissions, newest first. Each response includes its per-field answers.

---

## GS1 Digital Link

A GS1 Digital Link encodes one identifier (e.g. a GTIN) that resolves to a destination. The quickest way to make one is a single call to [Create a GS1 QR code](#create-a-gs1-qr-code) — pass an identifier and a destination.

The product/link endpoints below are for **advanced** use: managing an identifier's *multiple* link types (product info, instructions, recalls…) over time, and reusing one identifier across several codes. You don't need them for a basic GS1 QR. Resolution is handled by Hovercode's resolver.

### Create a GS1 product

`POST https://hovercode.com/api/v2/gs1/products/`

**Parameters**

| Name | Required | Description |
|---|---|---|
| `workspace` | required | Your workspace ID |
| `identifier_value` | required | The identifier value, e.g. a GTIN. Validated incl. GS1 check digit |
| `identifier_type` | optional | GS1 Application Identifier. One of: `01` (GTIN), `00` (SSCC), `414` (GLN), `417` (Party GLN), `253` (GDTI), `255` (GCN), `401` (GINC), `402` (GSIN), `8003` (GRAI), `8004` (GIAI), `8006` (ITIP), `8013` (GMN), `8017` (GSRN – Provider), `8018` (GSRN – Recipient). Defaults to `01` |
| `batch_lot`, `serial_number` | optional | Qualifiers added to the Digital Link path |
| `expiry_date` | optional | `YYMMDD` format (e.g. `261231`) |
| `destination_url` | optional | Seeds a default product information page (`gs1:pip`) link |

```python
import requests

data = {
    "workspace": "YOUR-WORKSPACE-ID",
    "identifier_type": "01",
    "identifier_value": "09506000134369",
    "destination_url": "https://example.com/product"
}

response = requests.post(
    "https://hovercode.com/api/v2/gs1/products/",
    headers={"Authorization": "Token YOUR-TOKEN"},
    json=data,
    timeout=10,
)
```

### List GS1 products

`GET https://hovercode.com/api/v2/workspace/{WORKSPACE-ID}/gs1/products/`

### Get, update or delete a GS1 product

`GET | PATCH | DELETE https://hovercode.com/api/v2/gs1/products/{PRODUCT-ID}/`

### Manage link types

`POST https://hovercode.com/api/v2/gs1/products/{PRODUCT-ID}/links/`

Each product resolves to one destination per link type. Exactly one is the default — setting a new default unsets the others, and deleting the default promotes another.

**Parameters**

| Name | Required | Description |
|---|---|---|
| `link_type` | required | One of: `gs1:pip`, `gs1:quickStartGuide`, `gs1:instructions`, `gs1:safetyInfo`, `gs1:recipeInfo`, `gs1:traceability`, `gs1:hasRetailers`, `gs1:recallStatus`, `gs1:review`, `gs1:ePIL`, `gs1:productSustainabilityInfo`, `gs1:promotion`, `gs1:masterData`, `gs1:smpc`, `gs1:certificationInfo`, `gs1:registerProduct` |
| `title` | required | Human-readable label |
| `destination_url` | required | Where this link type resolves to |
| `is_default`, `language`, `media_type` | optional | Default flag, language (e.g. `en`), media type (defaults to `text/html`) |

Update or delete a link with `PATCH | DELETE https://hovercode.com/api/v2/gs1/products/{PRODUCT-ID}/links/{LINK-ID}/`.

### Get the linkset

`GET https://hovercode.com/api/v2/gs1/products/{PRODUCT-ID}/linkset/`

Returns an [RFC 9264](https://www.rfc-editor.org/rfc/rfc9264.html) linkset (`application/linkset+json`) — a preview of what the resolver serves. Optional `?domain=` sets the anchor domain.

### Create a GS1 QR code

`POST https://hovercode.com/api/v2/hovercode/create/`

The quickest way to make a GS1 QR. Set `qr_type` to `GS1` and pass a `gs1_product` **object** with an `identifier_value` and a `destination_url` — this creates the GS1 product and the QR in one call. GS1 codes are always dynamic — the QR encodes the Digital Link URI, which the resolver redirects by link type.

```python
import requests

data = {
    "workspace": "YOUR-WORKSPACE-ID",
    "qr_type": "GS1",
    "gs1_product": {
        "identifier_value": "09506000134369",
        "destination_url": "https://example.com/product"
    }
}

response = requests.post(
    "https://hovercode.com/api/v2/hovercode/create/",
    headers={"Authorization": "Token YOUR-TOKEN"},
    json=data,
    timeout=10,
)
```

To reuse a product you already created (see the advanced endpoints above), pass its id as a string instead: `"gs1_product": "{PRODUCT-ID}"`.

---

## Webhooks

Enable webhooks from your workspace API settings (Business Plus plan). Every scan of a dynamic QR code or short link triggers a `POST` to your URL with `Content-Type: application/json` and an `x-signature` header. Verify `x-signature` against your webhook secret before processing.

```json
{
  "qr_code_id": "2fbb014a-4b5a-4ecd-95a3-p914d4aa167b",
  "time_utc": "2026-06-01 17:44:48.920050+00:00",
  "time_timezone_aware": "Jun. 1, 2026, 05:44 p.m.",
  "location": "London, England, United Kingdom",
  "device": "iPhone, iOS, Mobile Safari",
  "scanner_id": "5dd831a872687315f54a11fa62d089e66887647c67d5ad2cd89ebd3a38084bd3",
  "id": "0acb2379-c9e3-4245-a1e3-6e542cc02637"
}
```

---

## MCP server

Hovercode has a [Model Context Protocol](https://modelcontextprotocol.io/) server, so AI assistants like Claude and Cursor can create and manage QR codes, PDF codes, short links, pages, and forms conversationally — no API calls to write. Anything the assistant creates lands in your dashboard like any other code.

Endpoint: `https://hovercode.com/mcp/`

Connect it two ways:

- **Claude connector** — add Hovercode from Claude's connector directory: https://claude.ai/directory/hovercode
- **OAuth** — in a client that supports remote MCP connectors, add `https://hovercode.com/mcp/` and approve access (you'll go through a normal Hovercode login). Nothing to copy.
- **API token** — for CLI/config clients, pass your token as a bearer token. In Claude Code:

```
claude mcp add --transport http hovercode https://hovercode.com/mcp/ \
  --header "Authorization: Bearer YOUR-TOKEN"
```

- **npm bridge** — for clients that only support local (stdio) servers, `npx -y hovercode-mcp` ([hovercode-mcp](https://www.npmjs.com/package/hovercode-mcp)) bridges to the hosted server. Set `HOVERCODE_API_TOKEN` to skip the browser login.

**Things you can ask for once it's connected:**

- "Point my printed *menu* QR code at the new PDF at this link" — retarget a dynamic code without reprinting it.
- "Make a form to collect name, email, and a rating, and give me a QR for the table."
- "Turn this PDF into a QR code for the flyer."
- "How did my pricing-page QR do this month, and where were people scanning it?"
- "Make a link-in-bio page with my Instagram, shop, and booking links, and a QR for the window."
- "Summarize this week's responses to my feedback form."

Building your own agent? This same guide is served as markdown at `https://hovercode.com/mcp/docs`.

---

## Build QR codes into your product

Use this guide when you're adding QR codes to your own software: a CRM, client portal, property or listings platform, ordering system, event app, or a QR code generator of your own. It covers the setup, what to store, the calls to make, and how to keep each of your customers' codes separate.

### Why not just use a QR code library?

A QR library draws a static code: the data is baked into the pattern, so it can never change and you can't see who scanned it. That's fine for a one-off, but products usually need more:

- **Editable after printing.** A dynamic code encodes a Hovercode short link. When the destination changes (a new menu, a sold listing, a typo), you update it with one API call and every printed copy follows.
- **Scan tracking.** Each scan is recorded with time, location and device. Show it in your UI with the analytics endpoints, or receive it in real time with webhooks.
- **On-brand, print-ready output.** Colors, logos, patterns, eye styles and frames, with high-resolution PNG and SVG files for print.
- **Nothing to run.** Redirects, analytics, storage and custom short-link domains are handled for you.

### What you need

- **An API token** from your account settings. API access is on the **Business** and **Business Plus** plans, and you can try it on a free trial. See [pricing](https://hovercode.com/pricing/).
- **Business Plus** if each of your customers gets their own workspace (see [Choose a setup](#choose-a-setup)). It includes up to 10 workspaces; other plans include one. [Contact us](https://hovercode.com/contact/) if you need more, which is likely once you have more than a handful of customers. Scan webhooks are also on Business Plus.
- **A back-end.** The API can't be called from the browser, because that would expose your token. Any language works; for JavaScript and TypeScript there's an optional client, [`hovercode` on npm](https://www.npmjs.com/package/hovercode).

### Choose a setup

| Setup | Use it when | Plan |
|---|---|---|
| **One workspace** | The codes belong to your product: one per listing, product, table or ticket, managed by your app. | Business |
| **One workspace per customer** | Your customers create and manage their own codes, and must only ever see their own codes and scans. | Business Plus (up to 10 workspaces; more on request) |

Workspaces are Hovercode's isolation boundary. Codes, folders, templates, analytics, scan webhooks and custom domains all belong to one workspace, and every endpoint is scoped to one. With a workspace per customer, a bug in a listing query can't show one customer another customer's codes.

### What to store in your database

| On | Store | Why |
|---|---|---|
| Each customer (workspace-per-customer setup) | `hovercode_workspace_id` | Every call for that customer passes it. |
| Each record that has a QR code | `hovercode_qr_id` | To update, restyle, delete or read analytics later. |
| | `shortlink_url` | What the QR encodes. Show it as a link, or render the QR from it. |
| | `svg` (optional) | Cache the image so pages don't need an API call. |

Create a dynamic code **once** per record and reuse it. Every create makes a new code that counts toward your plan's dynamic-code limit.

### Create a workspace for each customer

Only needed for the workspace-per-customer setup. When a customer signs up in your product, create a workspace and store its `id`:

```bash
curl -X POST https://hovercode.com/api/v2/workspace/create/ \
  -H "Authorization: Token YOUR-TOKEN" -H "Content-Type: application/json" \
  -d '{"name": "Acme Corp"}'
```

It returns `201` with the new workspace's `id` and `name`, plus its `plan`, `dynamic_qr_used` and `dynamic_qr_limit`. Names are up to 30 characters, and your customers never see them. You get `403` when your plan's workspace limit is reached. [List workspaces](https://hovercode.com/api/#workspace-list) shows them all, with each one's usage.

### Create a QR code

Pass the workspace, the destination and `"dynamic": true`. Style fields are optional (see [Create a QR code](https://hovercode.com/api/#qr-create) for all of them):

```python
import requests

response = requests.post(
    "https://hovercode.com/api/v2/hovercode/create/",
    headers={"Authorization": "Token YOUR-TOKEN"},
    json={
        "workspace": customer.hovercode_workspace_id,
        "qr_data": "https://example.com/listings/42",
        "dynamic": True,
        "display_name": "Listing 42",
        "primary_color": "#1E3A8A",
        "pattern": "Circles",
        "eye_style": "Rounded",
    },
    timeout=10,
)
code = response.json()
listing.hovercode_qr_id = code["id"]
listing.shortlink_url = code["shortlink_url"]
listing.qr_svg = code["svg"]
```

### Show the code in your app

- **Inline SVG:** the `svg` field from the create or get response. Store it to avoid an API call per page view.
- **Render it yourself:** encode `shortlink_url` with any QR library. The API token never needs to reach the browser.
- **Print files:** `GET /api/v2/hovercode/{QR-CODE-ID}/download/` returns a high-resolution `png_url` and a vector `svg_url`. Offer these as "Download for print".

### Let customers change where it points

```bash
curl -X PATCH https://hovercode.com/api/v2/hovercode/{QR-CODE-ID}/update/ \
  -H "Authorization: Token YOUR-TOKEN" -H "Content-Type: application/json" \
  -d '{"qr_data": "https://example.com/listings/42-sold"}'
```

The short link and the printed code stay the same, and scans now go to the new destination. To restyle a code in place, use [Update a QR code's design](https://hovercode.com/api/#qr-design): the code keeps working, so a reprint is optional.

### Show scan analytics in your app

| You want | Call |
|---|---|
| Stats for one code (totals, by day, top countries, cities, devices) | `GET /api/v2/hovercode/{QR-CODE-ID}/analytics/?days=30` |
| A table of a customer's codes with scan counts | `GET /api/v2/workspace/{WORKSPACE-ID}/hovercodes/?include=scans&page_size=200` |
| A dashboard for a whole customer | `GET /api/v2/workspace/{WORKSPACE-ID}/analytics/?days=30` |
| Individual scans (time, location, device) | `GET /api/v2/hovercode/{QR-CODE-ID}/activity/` |

`?start=2026-01-01&end=2026-03-31` sets an exact range; `start` on its own gives all-time figures. For busy screens, cache the results for a few minutes rather than calling on every page view.

### React to scans with webhooks

Webhooks send a `POST` to your URL on every scan of a dynamic code. Set the URL and secret in each workspace's API settings in the dashboard (Business Plus). The payload carries `qr_code_id`, so look that up against the `hovercode_qr_id` you stored to find the record and customer.

Verify the `X-Signature` header before trusting a webhook: it's the hex HMAC-SHA256 of the **raw** request body, keyed with the workspace's webhook secret.

```python
import hashlib
import hmac

def verify_hovercode_webhook(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature or "")
```

### Keep each customer's codes private

- **Keep the token on your server.** Your front-end calls your back-end, never Hovercode directly.
- **Resolve IDs from your own database.** Don't accept a Hovercode code or workspace ID from the browser. Look up the signed-in customer's record, then use the IDs stored on it. That way a customer can't reach another customer's codes by editing an ID in a request.
- **Pass the workspace explicitly** on every create and listing call for that customer.

### Custom domains and branding

Short links use Hovercode's domains by default. A custom short-link domain (for example `qr.yourproduct.com`) is added and verified per workspace in the dashboard. Pass it as `domain` when creating codes.

Paid plans remove the "Powered by Hovercode" branding from hosted pages, forms and PDFs, so your customers see your product, not Hovercode.

### Removing a customer

Deleting a code or a workspace stops its printed codes from working. When a customer leaves your product, consider pointing their codes at a page of your choice with the update endpoint instead of deleting them. Workspaces can only be deleted from the dashboard.

### Limits

- **Rate limits** are per token: 120 reads and 60 writes per minute. For bulk jobs, pace with the `RateLimit-Remaining` header, and on a `429` wait for `Retry-After`.
- **Dynamic-code allowances** come from your plan: Business and Business Plus include a monthly allowance (see [pricing](https://hovercode.com/pricing/)). [List workspaces](https://hovercode.com/api/#workspace-list) returns `dynamic_qr_used` and `dynamic_qr_limit` for each workspace.
- **Building a platform** with many customers or high volumes? [Contact us](https://hovercode.com/contact/) about limits.

### Checklist

- [ ] Token only on the server, read from an environment variable
- [ ] One workspace per customer (Business Plus, up to 10 or more by arrangement), with the ID stored on the customer
- [ ] One dynamic code per record, created once, with `id` and `shortlink_url` stored
- [ ] Code and workspace IDs always come from your database, never from the request
- [ ] Destination changes use the update endpoint; codes are never re-created
- [ ] `429` (wait for `Retry-After`) and `403` (plan limit) handled
- [ ] Webhooks verified against the raw body before use

---

## Support

This API is in active development — [send feedback or questions](https://hovercode.com/contact/) any time.
