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
| Item | What 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. |
2. Install
Python 3.9 or newer. OpenCV, NumPy, SciPy and cryptography install with the package — nothing else to set up.
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.ps1Download the package
From the customer portal → SDK & 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"If your supplier uses a private
certificate, add --cacert cert.pem to that command.
Install it
pip install dotcode_sdk-1.0.0-py3-none-any.whl
Replacing an older copy? Add
--force-reinstall --no-deps.
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")Or a .env file read by python-dotenv, as long
as it is in .gitignore.
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"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
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
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
| Field | Type | Meaning |
|---|---|---|
ok | bool | Was a code read |
text | str / None | The decoded payload |
format | str / None | "DotCode" |
elapsed_ms | float | How long that decode took |
error | str / None | Why 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.
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()
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
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 see | What it means | Do 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
| Where | What you see | What it means |
|---|---|---|
| Startup | LicenseError |
Key invalid, or expired past the 14-day grace. Nothing will scan — stop and renew. |
| Per scan | ok=False, error="no_dotcode_found" |
Normal. That frame had no readable code. |
| Per scan | error="license_expired:renew_required" |
Ran past the year plus grace while offline. Renew. |
| Per scan | error="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:
| Setting | When 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
Scanneris created once at startup, not per frame. LicenseErrorat 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=:
| Value | Behaviour |
|---|---|
auto (default) |
Parse when the payload looks like GS1, otherwise null. |
always |
Always attempt, and report why it failed in errors. |
off | Raw 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']}")
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)
Send your staff the link
https://api.yourdomain.com/scanner
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.
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.
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
| Symptom | Cause | Fix |
|---|---|---|
| No camera prompt | The 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 reads | Too far, or too dark | Fill the guide box with the code; turn on the torch button. |
| Was working, now says expired | The 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. |