Integrate voltionX

You've bought a product and we've sent you a key. This page takes you from that key to working code. Pick what you bought.

How the SDK works

The SDK decodes entirely on your machine. Your images never leave your network, scanning keeps working if your internet drops, and there is no per-scan fee — you pay per machine, on the interval your plan uses.

Your licence key is checked offline using a signature. In the background the SDK also registers the machine and checks the licence is still valid, but that never blocks a scan: if our servers are unreachable, you carry on scanning.

1. What you received

ItemWhat it is
A licence key A long string starting eyJhbGciOi…. It identifies you, carries your machine count, and is checked offline — it is not a password to a website.
A portal link Paste the same key at the customer portal to download the SDK, see your machines and check your expiry date.
Possibly a certificate file (cert.pem) Only when your supplier runs on a private address. Without it the SDK still decodes perfectly, but cannot report usage — see private certificates.
Keep the licence key somewhere you can find it again. If you lose it, your supplier can re-issue — but anyone who has it can use your machine allowance, so treat it like a password.

2. Install

Python 3.9 or newer. OpenCV, NumPy, SciPy and cryptography install with the package — nothing else to set up.

1

A virtual environment (recommended)

python -m venv venv
# if PowerShell refuses to run the activate script:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process
venv\Scripts\Activate.ps1
python3 -m venv venv
source venv/bin/activate
2

Download the package

From the customer portalSDK & key, or straight from the command line — your licence key is the token:

curl.exe -O -J "https://api.yourdomain.com/v1/sdk/download?key=YOUR_KEY"
curl -O -J "https://api.yourdomain.com/v1/sdk/download?key=YOUR_KEY"

If your supplier uses a private certificate, add --cacert cert.pem to that command.

3

Install it

pip install dotcode_sdk-1.0.0-py3-none-any.whl

Replacing an older copy? Add --force-reinstall --no-deps.

4

Put the key in the environment, not in your code

A key in a committed file is a key on someone else's machine.

# this session only
$env:DOTCODE_LICENSE_KEY = "YOUR_KEY"

# or permanently, for this user
[Environment]::SetEnvironmentVariable("DOTCODE_LICENSE_KEY", "YOUR_KEY", "User")
export DOTCODE_LICENSE_KEY="YOUR_KEY"          # this session
echo 'export DOTCODE_LICENSE_KEY="YOUR_KEY"' >> ~/.bashrc  # permanently

Or a .env file read by python-dotenv, as long as it is in .gitignore.

5

If you were sent a certificate file

Point the SDK at it, so it can confirm it is really talking to your supplier:

$env:DOTCODE_CA_BUNDLE = "C:\path\to\cert.pem"
export DOTCODE_CA_BUNDLE="/path/to/cert.pem"

Skip this if your supplier is on an ordinary public domain.

3. Check it works

Run this before writing any of your own code. It answers every question you would otherwise have to ask support.

check_setup.pyimport datetime, os, time
from dotcode_sdk import Scanner, __version__

print("SDK version:", __version__)

scanner = Scanner(license_key=os.environ["DOTCODE_LICENSE_KEY"])
print("Licensed to:", scanner.license.org)
print("Plan       :", scanner.license.plan, "|", scanner.license.seats, "machines")
print("Expires    :", datetime.date.fromtimestamp(scanner.license.exp))

result = scanner.decode("barcode.png")
print("Decode     :", "OK" if result.ok else "FAILED", "-", result.text)

time.sleep(2)                    # registration happens in the background
print("Reporting  :", "OK" if scanner.cloud_ok else "NOT REACHING SERVER")
if not scanner.cloud_ok:
    print("            ", scanner.cloud_error)
scanner.close()

All five lines healthy looks like this:

SDK version: 1.0.0
Licensed to: Acme Tobacco Ltd
Plan       : pro | 10 machines
Expires    : 2027-07-31
Decode     : OK - 01095011015300031725013110ABC123
Reporting  : OK
Reporting NOT REACHING SERVER does not stop you scanning — decoding is entirely local. It means your supplier's dashboard won't show this machine or its scans. The line underneath says why; the usual cause is a private certificate.

4. Your first scan

Three lines. This is the whole integration:

first_scan.pyimport os
from dotenv import load_dotenv       # pip install python-dotenv
from dotcode_sdk import Scanner

load_dotenv()                        # reads DOTCODE_LICENSE_KEY from .env

scanner = Scanner(license_key=os.environ["DOTCODE_LICENSE_KEY"])
result  = scanner.decode("pack.png")

print(result.ok, result.text, result.format, result.elapsed_ms)
# True  01095011015300031725013110ABC123  DotCode  14.2
Create the scanner once, not per image. Building it verifies the licence and warms the decoder, so the first scan through a fresh Scanner costs a few hundred milliseconds and every scan after it runs in 10-25 ms. A script that builds a new one per file pays that warm-up every time.

decode() accepts three kinds of input, so it drops into whatever you already have:

scanner.decode("pack.png")              # a file path
scanner.decode(open("pack.png","rb").read())  # raw bytes (e.g. from a queue)
scanner.decode(frame)                     # a numpy array, straight from OpenCV

What you get back

FieldTypeMeaning
okboolWas a code read
textstr / NoneThe decoded payload
formatstr / None"DotCode"
elapsed_msfloatHow long that decode took
errorstr / NoneWhy it failed, when ok is false

5. Raw text or GS1 fields

Most DotCodes on tobacco and pharmaceutical packaging don't carry free text — they carry a GS1 element string: a run of (identifier, value) pairs with no visible separators. Reading a GTIN or an expiry date out of that by eye is a good way to make a mistake, so you get both views.

result.text is always the raw payload, exactly as encoded:

01095011015300031725013110ABC123<GS>21SN9

result.gs1 is the same data, parsed:

structured.pyresult = scanner.decode("pack.png")

print(result.text)              # raw, always available

if result.gs1:                  # None when the code is plain text
    for element in result.gs1["elements"]:
        print(element["ai"], element["label"], element["value"])
    # 01   GTIN          09501101530003
    # 17   Expiry date   250131
    # 10   Batch/Lot     ABC123
    # 21   Serial number SN9

# or reach for one field directly
gtin   = result.field("01")     # "09501101530003"
batch  = result.field("10")     # "ABC123"
serial = result.field("21")     # "SN9"
print(result.fields)            # the whole dict at once

Dates come back both ways

GS1 dates are YYMMDD. The raw value is preserved and an ISO date is added, with the century resolved by the GS1 rule and a day of 00 read as "end of that month":

{"ai": "17", "label": "Expiry date",
 "value": "250131",          # as encoded
 "formatted": "2025-01-31"}  # ready to compare or store

GTIN check digits are verified too — a misread shows up as "09501101530004 (check digit mismatch)" rather than passing silently into your database.

Only want raw text? Scanner(license_key=KEY, parse_gs1=False) skips parsing entirely. It costs microseconds either way, so leave it on unless you have a reason.

6. A real production line

Create the scanner once when your program starts and reuse it — not per frame. Building it verifies the licence and starts background threads, so doing that in a loop is wasteful.

line_worker.pyimport os, cv2, logging
from dotcode_sdk import Scanner, LicenseError

log = logging.getLogger("line")

try:
    scanner = Scanner(license_key=os.environ["DOTCODE_LICENSE_KEY"])
except LicenseError as exc:
    # Expired, revoked or mistyped. Fail loudly at startup rather than
    # silently reading nothing all shift.
    log.critical("Cannot start: %s", exc)
    raise SystemExit(1)

camera = cv2.VideoCapture(0)

try:
    while True:
        ok, frame = camera.read()
        if not ok:
            continue

        result = scanner.decode(frame)          # no network, ~10-40 ms

        if result.ok:
            handle_pack(result.text)            # your business logic
        elif result.error.startswith("license_expired"):
            # Only after the year AND the 14-day grace have passed.
            log.error("Licence needs renewing -- contact voltionX")
            break
        # "no_dotcode_found" is normal: most frames have no code in them.
finally:
    camera.release()
    scanner.close()
Threading: one Scanner is safe to call from several worker threads. For multiple processes, give each process its own instance — they share the machine's activation, so it still counts as one machine.

7. Processing a folder of images

The other common shape: a folder of photographs from a line camera or a QA station, out to a CSV your systems already read.

batch.pyimport csv, os, pathlib
from dotcode_sdk import Scanner

scanner = Scanner(license_key=os.environ["DOTCODE_LICENSE_KEY"])
folder  = pathlib.Path("incoming")

with open("results.csv", "w", newline="") as fh:
    out = csv.DictWriter(fh, fieldnames=["file", "ok", "text",
                                         "gtin", "batch", "expiry", "ms"])
    out.writeheader()

    for image in sorted(folder.glob("*.png")):
        r = scanner.decode(str(image))
        out.writerow({
            "file":   image.name,
            "ok":     r.ok,
            "text":   r.text or "",
            # these are empty for a plain-text code, which is fine
            "gtin":   r.field("01") or "",
            "batch":  r.field("10") or "",
            "expiry": (r.gs1 or {}).get("fields", {}).get("17", ""),
            "ms":     round(r.elapsed_ms, 1),
        })

scanner.close()
print("done -> results.csv")
file,ok,text,gtin,batch,expiry,ms
pack_0001.png,True,010950110153000317250131...,09501101530003,ABC123,250131,14.2
pack_0002.png,False,,,,,8.1
One Scanner for the whole run, not one per image. Building it verifies the licence and warms the decoder, so a fresh one per file pays that cost every time.

When something fails

Every message you can see, what it means, and what to do.

What you seeWhat it meansDo this
ModuleNotFoundError: dotcode_sdk Installed into a different Python than the one running Activate the virtual environment first, then pip install again. python -m pip installs into the interpreter you are actually using.
LicenseError: Invalid license key The key is wrong, truncated, or from a different supplier Copy it again — it is one long line with no spaces or newlines. Quotes around it in the shell.
LicenseError: license expired The term and its 14-day grace have both run out Renew. Your expiry date is in the portal and in check_setup.py.
result.ok is False,
error="no_dotcode_found"
Nothing wrong — that image had no readable code Normal on most frames of a video. If a picture you can read by eye fails, see image quality.
error="feature_not_licensed" Your licence covers the Live Scanner, not the SDK They are separate products. Ask your supplier to add the SDK.
error="license_expired:renew_required" Ran past the term while offline Renew. Scanning stops only after the grace period.
cloud_ok is False Decoding is fine; usage is not being reported Read cloud_error — it names the cause. Usually a private certificate.
"seat limit reached" in your log More machines are running than your licence covers Scanning continues on machines already registered. Ask your supplier to add machines, or retire an old one.
The first scan takes about a second One-off warm-up on a new Scanner Expected. Create the Scanner once at startup and reuse it — see the production line example. Every scan after is 10–25 ms.

Handling them in code

WhereWhat you seeWhat it means
StartupLicenseError Key invalid, or expired past the 14-day grace. Nothing will scan — stop and renew.
Per scanok=False, error="no_dotcode_found" Normal. That frame had no readable code.
Per scanerror="license_expired:renew_required" Ran past the year plus grace while offline. Renew.
Per scanerror="decode_error:…" The input wasn't a readable image — check what you passed in.

If your server uses a private certificate

The SDK reports two things back: which machine is running it, and how many scans it did. Neither affects decoding — but if it can't reach us, your dashboard shows no machines and no scans while the SDK works perfectly. The usual cause is a licence server on a private address with its own certificate.

You'll see this in your log the first time:

WARNING This machine could not register with the licence server --
        scanning works, but it will not appear in your dashboard.

Two ways to fix it:

SettingWhen to use it
DOTCODE_CA_BUNDLE=/path/to/server.crt Preferred. The certificate must carry a subjectAltName for the address you connect to — a Common Name alone is ignored by modern TLS, so a cert made with only -subj "/CN=10.0.0.5" will still be refused. Regenerate it with -addext "subjectAltName=IP:10.0.0.5".
DOTCODE_INSECURE_TLS=1 Skips certificate checking. Reasonable on a private network you control; never on the public internet.

To check from your own code:

scanner = Scanner(license_key=KEY)
import time; time.sleep(2)          # it registers in the background
print(scanner.cloud_ok)          # True, or False with the reason below
print(scanner.cloud_error)

Air-gapped sites

If the machine has no internet at all, turn the background calls off:

scanner = Scanner(license_key=KEY, telemetry=False)

Decoding is unaffected — it never used the network. You lose the usage charts in your portal, and we can't tell you a machine has gone quiet. The licence still expires on its own date.

Go-live checklist

  • Key comes from an environment variable, not source control.
  • The Scanner is created once at startup, not per frame.
  • LicenseError at startup stops the service loudly.
  • You have a calendar reminder ~30 days before the licence expires.
  • You've checked the machine count in your portal matches how many you actually run.

How the Cloud API works

You POST an image, we decode it and return the text. Nothing to install, works from any language. You pay for a monthly quota of scans.

Use this when images are already reaching a server of yours, or when the volume doesn't justify a per-machine licence. If you're scanning continuously on a production line, the SDK is cheaper and faster.

1. Your first call

The key goes in a header. The image goes in the body:

curl -X POST "https://api.yourdomain.com/v1/decode" \
  -H "X-API-Key: YOUR_KEY" \
  -F "file=@pack.png"
{
  "ok": true,
  "text": "01095011015300031725013110ABC123",   // raw, always
  "format": "DotCode",
  "elapsed_ms": 14.2,
  "error": null,
  "gs1": {                                     // null for plain text
    "is_gs1": true,
    "fields": { "01": "09501101530003", "17": "250131", "10": "ABC123" },
    "elements": [
      { "ai": "01", "label": "GTIN",        "value": "09501101530003" },
      { "ai": "17", "label": "Expiry date", "value": "250131",
        "formatted": "2025-01-31" },
      { "ai": "10", "label": "Batch/Lot",   "value": "ABC123" }
    ],
    "errors": []
  }
}

Raw text or structured fields

text is always the raw payload and never changes. The gs1 block is the same data parsed into GS1 Application Identifiers, and is null when the code carries plain text. Control it with ?gs1=:

ValueBehaviour
auto (default) Parse when the payload looks like GS1, otherwise null.
always Always attempt, and report why it failed in errors.
offRaw text only.
curl -X POST "https://api.yourdomain.com/v1/decode?gs1=auto" \
  -H "X-API-Key: YOUR_KEY" -F "file=@pack.png"

Reading one field in your own code:

data = r.json()
gtin = (data.get("gs1") or {}).get("fields", {}).get("01")
if gtin:
    lookup_product(gtin)
else:
    handle_plain_text(data["text"])

2. Wire it into your system

Pick your language. Each of these is a complete, working function — not a fragment.

voltionx.pyimport os, requests

API  = "https://api.yourdomain.com"
KEY  = os.environ["VOLTIONX_API_KEY"]      # never hard-code it

def decode_barcode(image_path, symbology="DotCode"):
    """Returns the decoded text, or None if nothing was found."""
    with open(image_path, "rb") as fh:
        r = requests.post(
            f"{API}/v1/decode",
            headers={"X-API-Key": KEY},
            files={"file": fh},
            params={"format": symbology} if symbology else None,
            timeout=30,
        )

    if r.status_code == 402:
        raise RuntimeError("voltionX quota exhausted -- upgrade the plan")
    r.raise_for_status()

    data = r.json()
    return data["text"] if data["ok"] else None


# in your own code:
code = decode_barcode("incoming/pack_8891.png")
if code:
    save_to_database(code)
else:
    flag_for_manual_review()
voltionx.js// Node 18+ (fetch and FormData are built in)
import { openAsBlob } from "node:fs";

const API = "https://api.yourdomain.com";
const KEY = process.env.VOLTIONX_API_KEY;

export async function decodeBarcode(path, symbology = "DotCode") {
  const form = new FormData();
  form.append("file", await openAsBlob(path));

  const url = `${API}/v1/decode` + (symbology ? `?format=${symbology}` : "");
  const res = await fetch(url, {
    method:  "POST",
    headers: { "X-API-Key": KEY },
    body:    form,
  });

  if (res.status === 402) throw new Error("voltionX quota exhausted");
  if (!res.ok) throw new Error(`voltionX ${res.status}`);

  const data = await res.json();
  return data.ok ? data.text : null;
}

// in your own code:
const code = await decodeBarcode("incoming/pack_8891.png");
code ? saveToDatabase(code) : flagForManualReview();
VoltionX.php<?php
function decodeBarcode(string $path, string $symbology = "DotCode"): ?string
{
    $api = "https://api.yourdomain.com";
    $key = getenv("VOLTIONX_API_KEY");

    $ch = curl_init("$api/v1/decode?format=$symbology");
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 30,
        CURLOPT_HTTPHEADER     => ["X-API-Key: $key"],
        CURLOPT_POSTFIELDS     => ["file" => new CURLFile($path)],
    ]);

    $body   = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($status === 402) throw new RuntimeException("voltionX quota exhausted");
    if ($status !== 200) throw new RuntimeException("voltionX $status");

    $data = json_decode($body, true);
    return $data["ok"] ? $data["text"] : null;
}
VoltionX.cspublic class VoltionX
{
    private readonly HttpClient _http = new();
    private const string Api = "https://api.yourdomain.com";

    public VoltionX(string apiKey) =>
        _http.DefaultRequestHeaders.Add("X-API-Key", apiKey);

    public async Task<string?> DecodeAsync(string path,
                                          string symbology = "DotCode")
    {
        using var form = new MultipartFormDataContent();
        form.Add(new ByteArrayContent(await File.ReadAllBytesAsync(path)),
                 "file", Path.GetFileName(path));

        var res = await _http.PostAsync(
            $"{Api}/v1/decode?format={symbology}", form);

        if ((int)res.StatusCode == 402)
            throw new InvalidOperationException("voltionX quota exhausted");
        res.EnsureSuccessStatusCode();

        using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
        var root = doc.RootElement;
        return root.GetProperty("ok").GetBoolean()
             ? root.GetProperty("text").GetString()
             : null;
    }
}

3. Before you go live

Retry, but only the right failures

Retrying a 401 or 402 is pointless — the answer won't change. Retry network errors and 5xx only, and back off:

import time, requests

def decode_with_retry(path, attempts=3):
    for attempt in range(attempts):
        try:
            return decode_barcode(path)
        except requests.HTTPError as exc:
            # 4xx is our request being wrong -- retrying won't help.
            if exc.response.status_code < 500:
                raise
        except requests.RequestException:
            pass                      # network blip
        time.sleep(2 ** attempt)      # 1s, 2s, 4s
    raise RuntimeError("voltionX unreachable after retries")

Watch your quota before it bites

usage = requests.get(f"{API}/v1/usage",
                     headers={"X-API-Key": KEY}).json()

if usage["remaining"] < usage["quota"] * 0.1:
    alert_ops(f"voltionX: {usage['remaining']} scans left, "
              f"resets {usage['resets_on']}")
Keep the key server-side. Never put it in a mobile app, or in JavaScript a browser can read — anyone could take it and spend your quota. Calls should go from your own backend.

Full reference

Every endpoint, error code and a live tester are on the API reference page. There's also a ready-made Postman collection there if you'd rather click than type.

How the Live Scanner works

It's a web page. Your staff open a URL on a phone, tablet or laptop, and scan with the camera. Nothing to install, nothing to develop, no app store.

You bought a machine count — that's how many devices can scan at once. Each browser registers itself the first time it scans and stays the same machine after that, so a tablet closing and reopening doesn't use up a second slot. Once the machines are all in use, the next device is told so rather than silently failing.

1. Set it up (2 minutes)

1

Send your staff the link

https://api.yourdomain.com/scanner
2

They paste the licence key once

The scanner asks for it the first time and remembers it for that tab. Same key on every device — it's per company, not per person.

3

Press Start and scan

Allow the camera when the browser asks. Codes read continuously — there's a beep and a buzz on each one, so nobody has to watch the screen.

When a code carries GS1 data, a Fields button appears on the result. Tap it to switch between the raw payload and the parsed fields — GTIN, batch, expiry, serial — and Copy takes whichever view is on screen.

Add it to a phone's home screen so it opens like an app: iPhone — Share → Add to Home Screen; Android — menu → Add to Home screen.

2. Getting the scans into your system

Most teams copy the code from the scanner and paste it where it's needed. If you want the scans to land in your own system automatically, you have two options.

Option A — put the scanner beside your app

Open the scanner in one browser tab and your system in another. Staff scan, tap Copy, paste. No development at all. This covers most warehouse and QA work.

Option B — build your own screen on the Cloud API

If you need the scan to write straight into your database, use the Cloud API from your own page. This is a complete working scanner in about 30 lines:

scan.html — your own page, your own branding<video id="cam" playsinline></video>
<script>
// The key must NOT be here in production -- proxy through your backend.
const stream = await navigator.mediaDevices.getUserMedia({
  video: { facingMode: "environment" }
});
cam.srcObject = stream;
await cam.play();

const canvas = document.createElement("canvas");

setInterval(async () => {
  canvas.width  = 640;
  canvas.height = 640 * cam.videoHeight / cam.videoWidth;
  canvas.getContext("2d").drawImage(cam, 0, 0, canvas.width, canvas.height);

  const blob = await new Promise(r => canvas.toBlob(r, "image/jpeg", 0.7));
  const form = new FormData();
  form.append("file", blob, "frame.jpg");

  // your backend adds the API key and forwards to voltionX
  const res  = await fetch("/your-backend/decode", { method:"POST", body: form });
  const data = await res.json();

  if (data.ok) onScan(data.text);      // write it to your database
}, 400);                              // ~2.5 frames a second is plenty
</script>

That page needs a Cloud API key, which is a separate product from the Live Scanner licence. If you only want the hosted scanner, stay with option A.

Troubleshooting

SymptomCauseFix
No camera promptThe page isn't on HTTPS Browsers only allow cameras on https:// (or localhost).
"This licence doesn't include the Live Scanner" An SDK licence was pasted The Live Scanner is sold separately — contact us to add it.
Camera opens, nothing readsToo far, or too dark Fill the guide box with the code; turn on the torch button.
Was working, now says expiredThe year plus 14-day grace ran out Renew and we'll issue a new key.
"All the machines on this licence are already scanning" More devices than the licence covers Close the scanner on a device you're not using, or contact us to add machines. Your count is in the portal.