> ## Documentation Index
> Fetch the complete documentation index at: https://docs.satyakod.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Offline verification

> Check a batch's serials in your own systems, without internet, using the batch's verification key.

Batches set to [offline, or online and offline](https://docs.satyakod.com/concepts/verification-modes.md) can be checked by
your own systems with no connection to Satyakod. Two ways, which you can combine:

| Way | Confirms | Needs |
| - | - | - |
| **Serial list** | The serial is one you issued, and whether it is revoked | The batch's Serial list (CSV) |
| **Verification key** | The serial's signature is genuine and it is inside the batch | The batch's key and about 20 lines of code |

The key is better for large batches and for codes you didn't download in advance; the list also
tells you about revocations made after the key was loaded.

## Get the verification key

**1. Open the batch**

**Batches**, then the batch. The key exists once the batch is signed.

**2. Download the key**

Under **Offline checking**, admins download **Verification key · JSON**. Each download is
recorded in the audit log.

**3. Load it into your system**

Store it where your system keeps other secrets, never in an app or device that leaves your
control.

![An offline batch: Offline checking and its verification key](https://docs.satyakod.com/images/screenshot-placeholder.svg)

An offline batch: Offline checking and its verification key

```json B-1045-verification-key.json theme={null}
{
  "batch": "B-1045",
  "batchSeq": 1045,
  "quantity": 80000,
  "workspaceId": "ws_example0000000001",
  "keyId": "example-2026-01",
  "algorithm": "HMAC-SHA256, first 4 bytes",
  "key": "Xd0K9tB+7Ni/P3YcbfecPYU8TgLzLdpXIT19ic9+SKo="
}
```

> [!WARNING]
>
> Anyone who has a batch's key can make serials that pass offline checks **for that batch**. It
> can't make serials for any other batch or brand, and Satyakod's own checks still catch copies
> (clone detection) once scans are uploaded. Keep keys in your factory and warehouse systems only:
> never ship one in a consumer app, a web page or a scanner you lend out.

## Field scanners' phones

Your field scanners can check offline batches in the Satyakod app without handling keys:

**1. Enrol the phone**

**Settings → Offline devices**: name the phone, choose the field scanner, then **Enrol device**.
You get a one-time code, valid for 30 minutes.

**2. Turn it on in the app**

Signed in as that field scanner, **Account → Offline checking**, then type the code.

The phone keeps the keys of your offline batches (at the printer or printed) in its secure
storage, for that person only, for 7 days; they renew whenever the screen is opened online.
Results checked offline say so, and the scans are verified and recorded once the phone is back
online. **Revoke** a phone under Offline devices and it loses its keys at its next sync; signing
out of the app deletes them at once.

## Check a serial

A serial is genuine for the batch when its tag equals the first 4 bytes of
HMAC-SHA256(key, message), where the message is `workspaceId|keyId|` followed by the serial's
6-byte batch sequence and index. See [Serial numbers](https://docs.satyakod.com/concepts/serial-numbers.md) for the format.

```python Python theme={null}
import base64
import hashlib
import hmac

ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
DECODE = {c: i for i, c in enumerate(ALPHABET)} | {"O": 0, "I": 1, "L": 1}


def check_serial(serial: str, key: dict) -> bool:
    """True when `serial` is one of this batch's serials. `key` is the verification key JSON."""
    text = serial.strip().upper().replace("-", "").replace(" ", "")
    if len(text) != 16 or any(c not in DECODE for c in text):
        return False
    value = 0
    for c in text:
        value = (value << 5) | DECODE[c]
    tag = (value & 0xFFFFFFFF).to_bytes(4, "big")
    payload = value >> 32
    batch_seq, index = payload >> 24, payload & 0xFFFFFF
    if batch_seq != key["batchSeq"] or index >= key["quantity"]:
        return False
    message = f"{key['workspaceId']}|{key['keyId']}|".encode() + payload.to_bytes(6, "big")
    expected = hmac.new(base64.b64decode(key["key"]), message, hashlib.sha256).digest()[:4]
    return hmac.compare_digest(expected, tag)
```

```javascript Node.js theme={null}
import { createHmac, timingSafeEqual } from "node:crypto"

const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
const DECODE = new Map([...ALPHABET].map((c, i) => [c, BigInt(i)]))
DECODE.set("O", 0n).set("I", 1n).set("L", 1n)

/** True when `serial` is one of this batch's serials. `key` is the verification key JSON. */
export function checkSerial(serial, key) {
  const text = serial.trim().toUpperCase().replace(/[-\s]/g, "")
  if (text.length !== 16 || [...text].some((c) => !DECODE.has(c))) return false
  let value = 0n
  for (const c of text) value = (value << 5n) | DECODE.get(c)
  const tag = Buffer.alloc(4)
  tag.writeUInt32BE(Number(value & 0xffffffffn))
  const payload = value >> 32n
  const batchSeq = Number(payload >> 24n)
  const index = Number(payload & 0xffffffn)
  if (batchSeq !== key.batchSeq || index >= key.quantity) return false
  const payloadBytes = Buffer.alloc(6)
  payloadBytes.writeUIntBE(Number(payload), 0, 6)
  const message = Buffer.concat([Buffer.from(`${key.workspaceId}|${key.keyId}|`), payloadBytes])
  const expected = createHmac("sha256", Buffer.from(key.key, "base64")).update(message).digest().subarray(0, 4)
  return timingSafeEqual(expected, tag)
}
```

```csharp C# (.NET 7+) theme={null}
using System.Security.Cryptography;
using System.Text;

// Read the JSON with new JsonSerializerOptions(JsonSerializerDefaults.Web) (camelCase names).
public record VerificationKey(string Batch, int BatchSeq, int Quantity, string WorkspaceId, string KeyId, string Key);

public static class Serials
{
    const string Alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";

    /// True when `serial` is one of this batch's serials.
    public static bool Check(string serial, VerificationKey key)
    {
        var text = serial.Trim().ToUpperInvariant().Replace("-", "").Replace(" ", "")
            .Replace('O', '0').Replace('I', '1').Replace('L', '1');
        if (text.Length != 16) return false;
        UInt128 value = 0;
        foreach (var c in text)
        {
            var digit = Alphabet.IndexOf(c);
            if (digit < 0) return false;
            value = (value << 5) | (UInt128)(uint)digit;
        }
        var tag = (uint)(value & 0xFFFFFFFF);
        var payload = (ulong)(value >> 32);
        if ((int)(payload >> 24) != key.BatchSeq || (int)(payload & 0xFFFFFF) >= key.Quantity) return false;

        var prefix = Encoding.UTF8.GetBytes($"{key.WorkspaceId}|{key.KeyId}|");
        var message = new byte[prefix.Length + 6];
        prefix.CopyTo(message, 0);
        for (var i = 0; i < 6; i++) message[prefix.Length + i] = (byte)(payload >> (8 * (5 - i)));

        using var hmac = new HMACSHA256(Convert.FromBase64String(key.Key));
        var expected = hmac.ComputeHash(message).AsSpan(0, 4);
        Span<byte> tagBytes = stackalloc byte[] { (byte)(tag >> 24), (byte)(tag >> 16), (byte)(tag >> 8), (byte)tag };
        return CryptographicOperations.FixedTimeEquals(expected, tagBytes);
    }
}
```

### Test your implementation

With the example key above:

| Serial | Expected |
| - | - |
| `0021A00002YSXBWW` | genuine (item 0) |
| `0021-A051-2PXK-20J9` | genuine (item 41,237; hyphens are ignored) |
| `0021A0512PXK20J0` | not genuine (last character changed) |

> [!NOTE]
>
> The example key is made up for these docs. It belongs to no workspace, and its serials won't pass
> Satyakod's online checks.

## What an offline check can't tell you

* **Copies.** A genuine serial printed twice passes offline. Satyakod's clone check compares
  scans across places and times; upload your offline scans to get it.
* **Revocations after you loaded the key.** Download the Serial list again, or check online when
  you can, for batches you have revoked codes from.

## Upload scans made offline

When the device or plant is back online, send its scans to
[`POST /v1/verify/offline`](https://docs.satyakod.com/api-reference/verify-offline), up to 500 at a time. Satyakod checks
each as of when it was scanned and adds it to your scan history, alerts and reports.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.