GS1 Barcode Scanner Online – Parse GTIN, Lot, Expiry & Serial

A GS1 barcode is not a barcode with a special font. It is an ordinary symbology carrying a string of concatenated data elements, and every one of them starts with an application identifier (AI): two to four digits that say what the value means.

That single design decision is what makes GS1 readable by every scanner on earth — and what makes it impossible to read correctly with a scanner that only returns raw text. 01095060001343521727043010LOT-42 is four data elements or one long serial number, and nothing on the page tells you which.

The business case is unglamorous and enormous: a GTIN identifies the product, a batch/lot number makes a recall possible, an expiry date makes a pharmacy dispense safe, and a serial number makes a counterfeit detectable. All four fit in one symbol smaller than a fingernail, and all four have to survive a scan at a checkout, in a warehouse, or through a phone camera at an angle.

What you’ll build: a browser-based GS1 scanner that reads GS1 DataBar, DataBar Expanded, GS1 DataMatrix, GS1 QR Code, GS1-128 and ITF-14 from a live camera or an uploaded image, splits the element string by the AI table, verifies the GTIN check digit, and renders each data element as a named field — with the human readable interpretation and the GS1 Digital Link beside it.

Online demo

Key Takeaways

  • A GS1 element string is decoded by the AI table, not by punctuation: fixed-length data is consumed by length, variable-length data runs to the next FNC1 byte (0x1D) or to the end of the string.
  • The AI table is prefix-free — no two-digit AI begins a three- or four-digit one — which is the property that makes a length-driven split possible at all.
  • DataBar and ITF-14 encode a bare GTIN with no AI; the parser has to infer AI 01 from the symbology.
  • Several decoders strip FNC1, which silently lets a batch number swallow the serial that follows it. The AI length rules are what let you prove the reading is wrong — and repair it.
  • The GTIN/SSCC check digit is the cheapest integrity check in the whole chain, and it is the one field worth always verifying.

Common Developer Questions

How do I scan and parse GS1 barcodes with application identifiers in JavaScript?

Decode the symbol with a barcode SDK, then split the decoded text with an AI table that knows each element’s length rule. Fixed-length elements are consumed by their own length; variable-length elements run to the next FNC1 byte. Dynamsoft Barcode Reader does the decoding in WebAssembly, and Dynamsoft Code Parser (GS1_AI spec) can name the data elements for you.

How do I extract GTIN, expiry date, and batch/lot from a GS1 DataBar or DataMatrix barcode in a web app?

Parse the element string into AI/value pairs, then read the AIs you care about: 01 is the GTIN, 17 the expiry date, 10 the batch or lot, 21 the serial. The values need interpreting, not just extracting — a GS1 date is six digits (YYMMDD), and day 00 means the last day of that month.

What JavaScript library supports GS1 Composite barcodes and GS1 AI parsing in the browser?

Dynamsoft Barcode Reader’s JavaScript bundle decodes GS1 Composite, DataBar, DataMatrix and QR Code in the browser, and the same bundle ships Dynamsoft Code Parser for the GS1_AI spec. The parser is a separately licensed component, so the code below also carries a local AI table that produces the same structure on its own.

Prerequisites

  • A modern browser (Chrome, Edge, Safari, Firefox)
  • A static file server — python -m http.server is enough
  • A 30-day free trial license for Dynamsoft Barcode Reader

No build step and no npm install: the SDK loads from a CDN, so the whole project is three files.

Step 1: Create the Project

Create a folder with an index.html, an app.js for the SDK wiring, and a gs1.js for the AI parser. Serve it over HTTP — opening the file directly will not work, because the SDK fetches its WebAssembly modules:

python -m http.server 8000

Step 2: Load the SDK and Activate a License

<!-- DBR (decoding) + DCP (GS1 AI parsing) + DCE (camera) -->
<script src="https://cdn.jsdelivr.net/npm/dynamsoft-barcode-reader-bundle@11.6.3200/dist/dbr.bundle.js"></script>
<script src="gs1.js"></script>
<script src="app.js"></script>

The license has to be activated before any component is created, or createInstance() throws:

await Dynamsoft.License.LicenseManager.initLicense("DLS2eyJvcmdhbml6YXRpb25JRCI6IjIwMDAwMSJ9", true);

// Load only what this page needs: the decoder and the code parser.
await Dynamsoft.Core.CoreModule.loadWasm(["DBR", "DCP"]);
await Dynamsoft.DCP.CodeParserModule.loadSpec("GS1_AI");

loadWasm(["DBR", "DCP"]) matters. Which modules you list is what gets downloaded — a GS1 scanner needs the code parser, and nothing else.

Step 3: Configure the Reader from a Preset

The formats worth reading here are the ones that can legally carry a GS1 element string, plus the linear symbologies a supply chain actually prints. There are two ways to configure that, and they are not equivalent:

// Read the SDK's own preset back, change one field, write it back.
async function applyScope() {
  const settings = await cvRouter.getSimplifiedSettings("ReadBarcodes_Balance");
  settings.barcodeSettings.barcodeFormatIds = gs1FormatMask();
  await cvRouter.updateSettings("ReadBarcodes_Balance", settings);
}

Do not hand-write the equivalent template JSON. A template document is accepted by initSettings() and then rejected later by startCapturing() with

[-10038] BarcodeReaderTaskSettingOptions[0].BarcodeFormatIds:
The parameter value is invalid or out of range.

— a message that names the format list while the real problem is the hand-rolled template around it. The same preset streams fine; that preset with one edited field streams fine; only the hand-written document fails. Editing a preset cannot produce a settings document the engine disagrees with.

The mask itself has to come from the runtime enum, and those values are BigInt: BF_ALL alone is 18446744069414584319, past the range a JSON number can hold. OR them together as BigInt and assign the result directly:

function gs1FormatMask() {
  const table = Dynamsoft.DBR.EnumBarcodeFormat;
  return [
    "BF_GS1_DATABAR_OMNIDIRECTIONAL", "BF_GS1_DATABAR_TRUNCATED",
    "BF_GS1_DATABAR_STACKED", "BF_GS1_DATABAR_STACKED_OMNIDIRECTIONAL",
    "BF_GS1_DATABAR_LIMITED", "BF_GS1_DATABAR_EXPANDED",
    "BF_GS1_DATABAR_EXPANDED_STACKED", "BF_GS1_COMPOSITE",
    "BF_DATAMATRIX", "BF_MICRO_QR", "BF_QR_CODE",
    "BF_CODE_128", "BF_CODE_39", "BF_CODE_93", "BF_ITF",
    "BF_EAN_13", "BF_EAN_8", "BF_UPC_A", "BF_UPC_E",
    "BF_PDF417", "BF_MICRO_PDF417", "BF_AZTEC", "BF_MAXICODE", "BF_DOTCODE"
  ].reduce((mask, name) => mask | table[name], 0n);
}

Step 4: Read GS1 Barcodes from an Image

Upload mode should own its own file input. The SDK has a built-in image picker (singleFrameMode = "image"), but it opens the OS file dialog the moment it is activated, which leaves no room for a drop zone or a paste handler.

Build the image the way the SDK’s own image view does — a raw pixel buffer, stride 4 × width, format 10 (IPF_ABGR_8888):

function imageToDsImageData(img) {
  const canvas = document.createElement("canvas");
  canvas.width = img.naturalWidth;
  canvas.height = img.naturalHeight;
  const ctx = canvas.getContext("2d", { willReadFrequently: true });
  ctx.drawImage(img, 0, 0);

  const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height);
  return {
    bytes: new Uint8Array(pixels.data.buffer, pixels.data.byteOffset, pixels.data.length),
    width: canvas.width,
    height: canvas.height,
    stride: 4 * canvas.width,
    format: 10
  };
}

const result = await cvRouter.capture(imageToDsImageData(img), "ReadBarcodes_Balance");

Two things worth knowing: capture() reads the barcodes from result.items, not result.decodedBarcodesResult.barcodeResultItems — that second property only exists on the streaming result a receiver gets. And capture() detaches the byte buffer it is handed, so build a fresh one per call rather than reusing the object.

Step 5: Split the Element String with the AI Table

This is the part a generic barcode reader gets wrong. Punctuation is not the structure; the AI table is.

Anatomy of a GS1 element string

Each entry in the table declares either a fixed length or a maximum:

const AI = {
  "01": { title: "GTIN", len: 14, check: "gtin" },
  "17": { title: "EXPIRATION DATE", len: 6, kind: "date" },
  "10": { title: "BATCH/LOT", max: 20 },
  "21": { title: "SERIAL NUMBER", max: 20 },
  "310": { title: "NET WEIGHT (kg)", len: 6, decimals: 3 }   // AI 310n
};

Walking the string is then a matter of two rules:

while (pos < text.length) {
  const match = matchAI(text, pos);      // longest match first: 4, then 3, then 2
  pos += match.code.length;

  if (match.entry.len) {
    value = text.substr(pos, match.entry.len);   // fixed: take exactly this many
    pos += value.length;
  } else {
    const end = text.indexOf(SEP, pos);          // variable: run to FNC1...
    value = end === -1 ? text.substr(pos) : text.substr(pos, end - pos);
    pos = end === -1 ? text.length : end;        // ...or to the end of the string
  }
}

Longest-match-first is safe here for a specific reason: the GS1 AI table is prefix-free. No two-digit AI is the beginning of a three- or four-digit one, so 10 can never be the start of 100, and the scan order cannot pick the wrong code.

Normalise FNC1 before you parse

Decoders disagree about how to report the separator, so fold all of them into one form first:

function normalize(text) {
  return String(text)
    .replace(/\{GS\}/g, SEP)                       // placeholder form
    .replace(/\]C1/g, SEP)                         // AIM symbology identifier form
    .replace(new RegExp(String.fromCharCode(29), "g"), SEP)   // the real byte
    .replace(/^\|+/, "").replace(/\|+$/, "");      // a leading FNC1 just says "GS1"
}

A leading FNC1 is not a separator. It is how a decoder announces “this is a GS1 symbol”, the same way ]C1 does. Trim it, or the element string starts with an empty segment.

Verify the check digit

The last digit of a GTIN, SSCC or GLN is a mod-10 check over the preceding ones, weighting alternate digits by 3 and 1:

function checkDigit(data) {
  let sum = 0, weight = 3;
  for (let i = data.length - 1; i >= 0; i--) {
    sum += Number(data.charAt(i)) * weight;
    weight = weight === 3 ? 1 : 3;
  }
  return String((10 - (sum % 10)) % 10);
}

A mismatch means the symbol was misread — not that the product is fake. It is the cheapest way to catch a bad scan before it reaches a database.

Interpret the values

Two interpretations matter more than the rest:

  • Dates are YYMMDD, and day 00 means “the last day of that month” — that is how GS1 encodes a month-precision expiry. Year window: 00–49 is 20xx, 50–99 is 19xx.
  • Measurements in the 3Ndd families carry their own decimal position in the AI itself. AI 3103 is net weight in kg with 3 decimals, so 002500 is 2.500 kg — and AI 3102 with the same data would be 25.00 kg.

Step 6: Add Live Camera Scanning

For a camera, let the SDK own the frame loop. setInput() plus startCapturing() pushes frames through the pipeline and hands results to a receiver, with a cross-filter dropping the duplicates that consecutive frames inevitably produce:

const filter = new Dynamsoft.Utility.MultiFrameResultCrossFilter();
filter.enableResultDeduplication("barcode", true);
await cvRouter.addResultFilter(filter);

receiver = {
  onDecodedBarcodesReceived(result) {
    if (resultsOpen) return;
    const items = result.barcodeResults || result.barcodeResultItems || [];
    if (items.length) showResults(items, "camera");
  }
};

cvRouter.setInput(cameraEnhancer);
await cvRouter.startCapturing("ReadBarcodes_Balance");
cvRouter.addResultReceiver(receiver);

Wrapping the capture for the camera is a deliberate choice over fetchImage() in an interval. fetchImage() throws getImageData: Value is not of type 'long' on a viewfinder that has not produced a frame yet, so the manual loop needs its own readiness guard; startCapturing() does not have that failure mode at all.

Note the result property: the streaming receiver hands barcodes over in barcodeResultItems, while capture() puts them in items. Accept both.

Step 7: Render the Result

GS1 scan result with the parsed application identifiers

Per barcode, show the four things that make a scan verifiable:

  1. the human readable interpretation — (01)09506000134352(17)270430(10)LOT-42, the notation GS1 prints under a symbol;
  2. the AI table — code, data element name, value, and the check-digit verdict;
  3. the raw element string, with FNC1 drawn as | so it is obvious where a separator has to be;
  4. the GS1 Digital Link — https://id.gs1.org/01/…/10/…?17=… — which is what a consumer-facing QR code would contain.

Common Issues and Edge Cases

These are the ones that actually bite, in the order they bite.

A decoder that strips FNC1 lets a batch number eat the serial. This is the big one. Given …10LOT-4221SN0001 with the separator removed, a length-driven parser reads 10 (variable, max 20) to the end of the string and returns LOT-4221SN0001. The value looks fine. It is wrong.

There is a way to prove it is wrong: AI 10 allows 20 characters, and here it read 26. Once a value exceeds its own maximum, the reading is impossible, so you can search for the split that repairs it — and prefer the longest valid head:

// "1215270827" also splits as AI 30 = "1" + AI 21 = "5270827", because a
// variable-length AI will happily swallow whatever is left. Preferring the
// longest head keeps the data in the element the encoder declared.
for (let length = Math.min(entry.max, value.length - 1); length >= 1; length--) {
  const head = value.slice(0, length);
  const tail = value.slice(length);
  if (parsesCleanly(tail)) return { head, tail };
}

Repairs must be reported, never applied silently — a re-split value is a value worth checking against the label.

DataBar, ITF-14 and EAN-13 carry a bare GTIN. A GS1 DataBar encodes a GTIN-14 with no AI at all. An ITF-14 or EAN-13 printed by a GS1 system is the same: the symbology is the announcement. Infer AI 01 when the direct parse fails and the payload is nothing but a 12- to 14-digit number, and say that you did.

GS1 DataBar Limited refuses most GTINs. It encodes only GTINs beginning with 0 or 1. Everything else has to go in another DataBar variant — the reader will decode the symbol happily and the payload will be a different product.

Some AIs cannot travel alone. GS1 constrains pairings, and the encoders enforce them: AI 21 (serial) requires 01, 03 or 8006; AI 393x (a price in an ISO currency) requires the quantity it applies to (30, 31nn, 32nn, 35nn, 36nn). If you are generating test data, expect One of more requisite AIs ... are missing.

GS1 Code Parser is licensed separately. If the deployment’s licence does not cover it, parser.parse() rejects with [Code Parser] No license found. That is not a bug in your code — it is a reason to keep a local AI table as the fallback rather than treating the parser as the only path.

Camera autofocus delays on mobile. Dense DataBar symbols and small DataMatrix symbols need the camera to settle. Hold at 10–15 cm, and note that a value read from a blurred frame is exactly what the check digit is for.

Verification

The scanner was tested end to end against images produced by the companion GS1 generator: eleven scenarios rendered to label PNGs, each uploaded through the scanner’s own file input, and every reported application identifier compared with the payload that was encoded.

Scenario Symbology Elements Result
Retail GTIN GS1 DataBar Omnidirectional 1 match
Fresh food (weight + price) GS1 DataBar Expanded Stacked 3 match
Healthcare unit GS1 DataMatrix 4 match
Carton (SSCC) GS1-128 1 match
ITF-14 carton ITF-14 1 match
Order + delivery GS1-128 5 match
Ratio pack GS1 DataBar Expanded 3 match
Returnable asset GS1 DataMatrix 1 match
Price-marked pack GS1 DataBar Expanded 3 match
Consumer QR GS1 QR Code 3 match
Custom payload GS1 DataMatrix 3 match

Eleven out of eleven, twice in a row with freshly randomised payloads. Three of those cases are the direct result of the edge cases above — the ITF-14 GTIN inference, the bare DataBar GTIN, and the FNC1 re-split all came out of this round trip rather than out of the specification.

Source Code

Get the complete sample project source code on GitHub