OTVS

OTVS 1.0 — Open Ticket Validation Standard

   
Specification identifier otvs/1.0
Document version 1.0-draft.4
Status DRAFT — for internal review
Date 2026-06-12
Scope ticket format (JSON), QR profile (JWS), validation algorithm, validation events, offline mode, extension mechanism

This document is self-contained: it includes the specification, JSON Schema (draft 2020-12), and examples.


1. Purpose and scope

The standard defines an admission ticket format and a deterministic algorithm for its validation at access control, covering:

Out of scope: sales and payments, seat reservation, ticket distribution to the customer (Apple/Google Wallet, PDF — compatibility guidance: §5.6), venue topology (deployment configuration, §3.3), and the UI of scanning devices.


2. Terminology and conventions

The key words MUST, MUST NOT, SHOULD, SHOULD NOT, MAY are to be interpreted as described in RFC 2119 / RFC 8174.

Term Definition
Ticket An immutable (except for the status field) JSON document issued by the Issuer; a container of entitlements.
Entitlement The right to enter specified zones, subject to rules of time, place, and number of uses.
Zone A logical area of the venue (main grounds, sector, VIP, backstage). Zone identifiers are defined by the event configuration.
Gate A control point with an assigned type and target zone (§3.3).
Use An effective entry: an IN event with an ALLOW decision and countedUse = true (§6, step 9).
Validation event An immutable (append-only) record of a single validation attempt (§8).
Validator Software that makes the admission decision (a server or an offline device).
Issuer The entity that issues and signs tickets.

All timestamps in documents MUST conform to RFC 3339 and include an explicit timezone offset (e.g. 2026-07-03T13:00:00+02:00). In the QR profile, time is encoded as epoch seconds (NumericDate).


3. Conceptual model

3.1. Relationships

Product ──issues──▶ Ticket ──contains──▶ Entitlement (1..n)
                       │                       │
                       │                       └─ rules: zones, gates, time windows, limits, requirements
                       │
                       └──generates──▶ Validation event (0..n, append-only)

Fundamental principles:

  1. A ticket is immutable after issuance — the only mutable field is status (managed by the Issuer and distributed via the revocation registry, §9.2).
  2. All dynamic state derives exclusively from the event log: use counters, daily limits, on-site presence (anti-passback). The ticket MUST NOT store any counter.
  3. Limits (maxUses, maxUsesPerDay) are counted per entitlement, not per ticket.

3.2. Presence state machine

Presence (for anti-passback purposes) is determined by the last ALLOW event at a gate of type PERIMETER:

            IN (ALLOW, PERIMETER)
 OUTSIDE ──────────────────────────▶ INSIDE
    ▲                                  │
    └──────────────────────────────────┘
            OUT (PERIMETER / EXIT)

Initial state: OUTSIDE. In version 1.0, presence is tracked at the venue perimeter level; per-zone presence tracking is a planned extension.

3.3. Gate context (deployment configuration)

Gates are not part of the ticket — they are event configuration that the validator receives at provisioning time. Each gate has:

Field Description
gateId Gate identifier (e.g. G1, G-VIP-2).
eventId The event the gate serves.
type PERIMETER (entry from outside onto the grounds), ZONE (internal control at a zone boundary), EXIT (exit).
targetZone The zone the gate admits into.
direction IN / OUT (bidirectional gates are modeled as two contexts).
exitTrackingEnabled Whether the venue scans exits (a precondition for anti-passback, §6 step 7f).

Consequences of the types: uses (countedUse) are counted exclusively by PERIMETER gates; ZONE gates verify zone entitlements (e.g. a VIP wristband checked repeatedly) but do not consume limits and do not affect anti-passback.


4. Ticket format (JSON)

The full ticket document is the authoritative document stored on the Issuer’s side. The carrier (QR) holds its compressed, signed profile (§5). Formal schema: §12.1.

4.1. Root object

Field Type Req. Description
spec string YES Specification version identifier, pattern otvs/1.MINOR. The validator MUST reject an unknown major version (§4.11).
ticketId uuid YES Unique ticket identifier. SHOULD be a UUIDv7 (time-sortable).
issuer string YES Issuer identifier, e.g. tickets.example.com. Designates the signing key set (§5.4).
issuedAt date-time YES Moment of issuance.
status enum YES ACTIVE | BLOCKED | CANCELLED. The only mutable field; changes are propagated via the revocation registry (§9.2).
statusReason string Human-readable reason for the status change (e.g. REFUND, FRAUD_SUSPECTED).
event object YES Event context (§4.2).
product object Source product (§4.3).
holder object Holder — named tickets only (§4.4).
seat object Assigned seat (§4.5).
entitlements array YES List of entitlements, min. 1 (§4.6). Order matters (§6, step 7).
requirements array Requirements applying to every entry (§4.9).
ext object Custom extensions (§4.10).

4.2. event

Field Type Req. Description
eventId string YES Event identifier. The validator compares it against the gate configuration (§6, step 5).
name string Display name.
timezone string YES IANA timezone (e.g. Europe/Warsaw). Determines the boundaries of a “day” for daily limits (§4.8).
venueId string Venue identifier.

4.3. product

Field Type Req. Description
productId string YES Product identifier in the sales system.
name string Display name (e.g. “3-day VIP pass”).
category string Informational classification, e.g. SINGLE, PASS, SEASON, INVITATION. No effect on validation.

4.4. holder

Named tickets only. Holder data MUST NOT be carried in the QR profile (§5.3, §11.3).

Field Type Req. Description
displayName string Full name to be presented at an identity check.
identityCheckRequired boolean Defaults to false. When true, gate staff SHOULD verify an identity document; the result is recorded in the event’s requirementResults (method DOCUMENT).

4.5. seat

Informational field (attendee navigation, printing). Enforcement of sector access is performed by entitlement zones (zones), not by seat.

Field Type Description
sectionId string Section identifier (consistent with zone identifiers if the section is a controlled zone).
row string Row.
number string Seat number.
label string Display label, e.g. “Section A, row 12, seat 7”.

4.6. entitlement

Field Type Req. Description
entitlementId string YES Identifier unique within the ticket (short, e.g. e1 — it goes into the QR).
name string Display name.
zones array<string> YES Zones the entitlement grants entry to (min. 1). A gate matches when its targetZone is in this list.
gates array<string> Restriction to the listed gates. Field absent = all gates serving the allowed zones.
validity object Entry validity window: from, until (both optional, min. one). Field absent = no time constraint from this rule. The Issuer SHOULD always set validity.
entryWindows array Time slots for the moment of entry (§4.7).
usage object Use limits and anti-passback (§4.8). Absent = no limits, antiPassback = true.
requirements array Additional requirements for this entitlement (§4.9), checked together with the ticket’s requirements.
ext object Extensions (§4.10).

4.7. Time semantics: validity vs entryWindows

4.8. usage — limits and re-entry

Field Type Default Description
maxUses integer ≥ 1 unlimited Total number of permitted uses of the entitlement.
maxUsesPerDay integer ≥ 1 unlimited Limit of uses within one event day.
dayRollover HH:MM 00:00 Time (in event.timezone) marking the day boundary. Day = [D dayRollover, D+1 dayRollover). For festivals ending in the early morning, e.g. 06:00 is recommended.
antiPassback boolean true Blocks re-entry without a registered exit (§6, step 7f). Effective only when exitTrackingEnabled = true at the venue.

Definitions:

4.9. requirements — requirements (extension mechanism)

A requirement is an additional admission condition, verified after an entitlement has been positively matched (§6, step 8):

{ "type": "age.min", "critical": true, "params": { "minAge": 18 } }
Field Type Req. Description
type string YES Requirement type in namespaced notation: ^[a-z][a-z0-9]*(\.[a-z][a-z0-9-]*)+$.
critical boolean YES Validator behavior for an unknown type: true → rejection (UNKNOWN_CRITICAL_REQUIREMENT, fail-closed), false → skip + warning.
params object Type-dependent parameters.

Type registry in version 1.0:

Type Parameters Verification
age.min minAge (integer) Visual assessment or document; result in requirementResults (method VISUAL / DOCUMENT).

Namespaces reserved for the future (they must not be used for custom purposes): health.* (health credentials — §10), identity.* (identity verification). Custom extensions MUST use a namespace with the issuer’s domain, e.g. com.example.tickets.member-only.

The critical field is the key to all future extensibility: it allows a new requirement type (e.g. vaccinations) to be added with the guarantee that an old validator will not admit anyone it cannot verify.

4.10. ext — custom extensions

An object of arbitrary content at the ticket and entitlement level. Keys SHOULD be prefixed with a reversed domain (e.g. com.example.tickets.crm-segment). The validator MUST NOT base the admission decision on the contents of ext.

4.11. Versioning and compatibility


5. QR profile (ticket carrier)

5.1. Format

The ticket carrier is JWS Compact Serialization (RFC 7515). The reference symbology is a QR code (byte mode, recommended error correction M); since the payload is an ASCII string, it MAY be carried by any 2D code (Aztec, PDF417) — the requirements of this section apply to the payload, not the symbology:

BASE64URL(header) . BASE64URL(payload) . BASE64URL(signature)

Protected header:

{ "alg": "ES256", "typ": "otvs1+jws", "kid": "2026-06-k1" }

5.2. Field mapping (full document → compact claims)

The payload contains only the data needed for an offline decision:

Full document Claim Type in QR Notes
spec (major version) v integer 1
ticketId jti string UUID; in the future MAY be shortened to 16 B base64url
issuer iss string  
issuedAt iat NumericDate  
event.eventId eid string  
event.timezone tz string needed offline for day boundaries (dayRollover)
seat.label st string optional, display only
entitlements[] ent[] array as below
entitlementId i string  
zones z array  
gates g array optional
validity.from / until nbf / exp NumericDate  
entryWindows[] w[] array of {f, u, g?} objects NumericDate
usage.maxUses mu integer  
usage.maxUsesPerDay md integer  
usage.dayRollover dr string HH:MM  
usage.antiPassback ap 0/1 default 1
requirements[] req[] array of {t, c, p?} objects also applies to entitlement requirements (inside ent[] as rq[])

Deliberately not carried in the QR: status (the revocation registry is the source of truth, §9.2), holder and any personal data (§11.3), product, ext.

5.3. Size and privacy

A typical ticket (1–3 entitlements) yields a JWS of about 0.5–1.0 kB → QR versions ~15–20 at ECC M. The Issuer MUST keep the payload minimal and MUST NOT place personal data in the QR. Migration to CWT/COSE (CBOR, RFC 8392) is planned as a future size optimization — with no changes to the data model.

5.4. Key distribution

The Issuer’s public keys are published as a JWKS at an address derived from iss:

https://{issuer}/.well-known/otvs/jwks.json

Offline validators receive the JWKS at provisioning and refresh it at every synchronization (§9.1).

5.5. Rotating codes (optional)

For high-risk events (screenshot resale), a deployment MAY use short-lived, rotating tokens generated in the holder’s app (an additional exp claim on the order of seconds + a real-time channel). The mechanism is out of scope for 1.0; the claims format remains compatible with this profile.

5.6. Wallet distribution (Apple Wallet, Google Wallet) — compatibility guidance

Mobile wallets are presentation containers and a distribution channel, so they remain outside the normative scope of the standard (§1); the profile in §5 is, however, compatible with them by design. The JWS payload is an ASCII string placed without any transformation in the pass’s barcode field, and the validator treats a scan from a wallet identically to any other carrier (printout, PDF, the issuer’s app).

Element Apple Wallet (.pkpass, eventTicket style) Google Wallet (EventTicketObject)
OTVS payload (JWS) barcodes[].message, messageEncoding: iso-8859-1 barcode.value
Code symbology PKBarcodeFormatQR (Aztec/PDF417 permitted) QR_CODE (AZTEC/PDF_417 permitted)
Event name, venue visual fields, semantics.eventName / venueName eventName, venue (in the class)
Holder (holder.displayName) visual field (named tickets) ticketHolderName
Seat (seat) semantics.seats[] seatInfo
Validity start relevantDate dateTime

Rules:

  1. Visual fields are display-only. They are filled from the full ticket document, but the validator MUST NOT base decisions on them — only the JWS payload and the state (online / offline snapshot) are authoritative.
  2. Signatures are layered and independent. The pass package signature (PKCS#7 over the .pkpass manifest; the “Save to Google Wallet” JWT signed with a service-account key) authenticates the pass to the wallet ecosystem. The OTVS JWS signature authenticates the ticket to the validator. Neither layer replaces the other.
  3. Pass freshness is not a security mechanism. A pass on a device may be stale; since status is not carried in the QR (§5.2), revocations are enforced by the revocation registry (§9.2). Push updates (webServiceURL/APNs on Apple, object update on Google) SHOULD serve the UX layer only, e.g. visually marking a cancelled ticket.
  4. Rotating codes (§5.5). Google Wallet natively supports the TOTP-based RotatingBarcode type, and the valuePattern template allows composing the code value from a fixed part and a TOTP suffix. Recommended composition: <JWS>~<totp>, with a separator outside the base64url alphabet (e.g. ~) so the suffix can be unambiguously separated from the payload (the JWS contains its own dots). The validator then verifies the TOTP suffix against the secret assigned to the pass, followed by standard JWS verification (§6). Apple Wallet offers no public equivalent — rotation on iOS requires the issuer’s app or an NFC channel.
  5. NFC (Apple VAS, Google Smart Tap) are partner programs requiring the wallet operator’s approval, and their payload is too small for a full JWS. The NFC path therefore requires a short reference token resolved online and remains out of scope for version 1.0.

6. Validation algorithm

Validation MUST proceed in the order below and be deterministic. The first unmet condition stops processing and determines the reasonCode.

INPUT:   scan payload, gate context (eventId, gateId, type, targetZone,
         direction, exitTrackingEnabled), clock `now`,
         state: online → event database; offline → snapshot + local log (§9)

 1. Parsing and syntactic validation of the payload  → REJECT MALFORMED
 2. Version: claim `v` (major) supported             → REJECT UNSUPPORTED_SPEC_VERSION
 3. JWS signature: key for `kid` known, signature
    valid                                            → REJECT UNKNOWN_KEY
                                                     → REJECT INVALID_SIGNATURE
 4. Status: ticket not in the revocation registry    → REJECT REVOKED
            status != ACTIVE (online mode)           → REJECT TICKET_NOT_ACTIVE
 5. Event: eid == gate.eventId                       → REJECT WRONG_EVENT
 6. direction == OUT?
       register the exit and return ALLOW
       (state != INSIDE → warning EXIT_WITHOUT_ENTRY;
        exits MUST NOT ever be blocked)
 7. Entitlement selection — for successive e ∈ ent[] in array order:
    7a. gate.targetZone ∈ e.zones                    → ZONE_NOT_ALLOWED
    7b. e.gates absent OR gateId ∈ e.gates           → GATE_NOT_ALLOWED
    7c. validity: now ≥ from and now ≤ until         → NOT_YET_VALID / EXPIRED
    7d. entryWindows: now inside some window
        (and gateId ∈ window.gates, if given)        → OUTSIDE_ENTRY_WINDOW
    7e. limits (only gate.type == PERIMETER):
        uses(e) < maxUses                            → MAX_USES_EXCEEDED
        usesToday(e) < maxUsesPerDay                 → DAILY_LIMIT_EXCEEDED
    7f. anti-passback (PERIMETER ∧ antiPassback
        ∧ exitTrackingEnabled):
        presence(ticket) != INSIDE                   → ALREADY_INSIDE
    ── the first e passing 7a–7f is selected;
    ── none passes → REJECT with the reason of the entitlement that got
       furthest through 7a→7f (ties broken by the lower array index)
 8. Requirements: ticket requirements ∪ requirements of the selected entitlement:
       type unsupported ∧ critical = true            → UNKNOWN_CRITICAL_REQUIREMENT
       type unsupported ∧ critical = false           → skip + warning
       type supported, condition not met             → REQUIREMENT_NOT_MET
 9. ALLOW; record the event (§8):
       countedUse = (gate.type == PERIMETER)
       entitlementId = identifier of the selected entitlement

Auxiliary definitions:

Debounce: a repeated scan of the same ticketId at the same gate and in the same direction within ≤ 5 s MUST return the previous decision without counting a use (countedUse = false, warning DUPLICATE_SCAN) — this protects against double counting when a code is “tapped again”.


7. Result codes

The validator’s decision: decision ∈ {ALLOW, REJECT}; on REJECT exactly one reasonCode is set; the warnings[] field may accompany either decision.

7.1. Rejection codes (reasonCode)

Code Step Meaning
MALFORMED 1 Payload unparsable or syntactically invalid.
UNSUPPORTED_SPEC_VERSION 2 Unsupported major specification version.
UNKNOWN_KEY 3 Unknown kid (key missing from the validator’s JWKS).
INVALID_SIGNATURE 3 Signature does not verify.
REVOKED 4 Ticket in the revocation registry.
TICKET_NOT_ACTIVE 4 statusACTIVE (online).
WRONG_EVENT 5 Ticket for a different event.
ZONE_NOT_ALLOWED 7a No entitlement covers the gate’s zone.
GATE_NOT_ALLOWED 7b Zone matches, but the gate is outside the gates list.
NOT_YET_VALID 7c Before validity.from.
EXPIRED 7c After validity.until.
OUTSIDE_ENTRY_WINDOW 7d Outside all entry windows.
MAX_USES_EXCEEDED 7e Total limit exhausted.
DAILY_LIMIT_EXCEEDED 7e Daily limit exhausted.
ALREADY_INSIDE 7f Anti-passback: no registered exit.
REQUIREMENT_NOT_MET 8 Requirement verified negatively.
UNKNOWN_CRITICAL_REQUIREMENT 8 Critical requirement not supported by the validator (fail-closed).

7.2. Warnings (warnings[])

Code Meaning
EXIT_WITHOUT_ENTRY Exit without a prior entry in the log (the exit is allowed anyway).
DUPLICATE_SCAN Repeated scan within the debounce window; previous decision returned.
STALE_OFFLINE_DATA Offline snapshot older than the configured maxOfflineAge (§9.3).

Extensions MAY add codes in MINOR versions; validators and reporting systems MUST tolerate unknown codes.


8. Validation events

Every validation attempt (including a rejected one) MUST be recorded as an event in an append-only log. The log is the sole source of truth for counters and presence (§3.1). Formal schema: §12.2.

Field Type Req. Description
validationId uuid YES Event identifier (UUIDv7 recommended).
ticketId uuid YES The ticket the attempt concerns.
entitlementId string | null The selected entitlement; null when rejection occurred before selection or on exit.
eventId string YES Event (partitioning key).
gateId string YES Gate.
gateType enum PERIMETER | ZONE | EXIT.
deviceId string YES Scanning device.
direction enum YES IN | OUT.
occurredAt date-time YES Event time (device clock; offline — see §9.4).
decision enum YES ALLOW | REJECT.
reasonCode string | null Code from §7.1 on REJECT.
warnings array<string> Codes from §7.2.
mode enum YES ONLINE | OFFLINE.
countedUse boolean YES Whether the event consumes a limit (§6, step 9).
requirementResults array Requirement verification results: {type, satisfied, method?}. Boolean values only — no source credential data (§10, §11.3).
prevEventHash string | null Optional log-integrity chaining (hash of the device’s previous event, e.g. sha256:…). Recommended for auditing.
ext object Extensions.

9. Offline mode

9.1. Device state

An offline validator maintains: (a) the Issuer’s JWKS, (b) a revocation registry snapshot, (c) a local event log, (d) gate configuration. Synchronization (log push, revocation and key pull) happens at the interval configured for the event.

9.2. Revocation registry

status changes are distributed as incremental deltas {ticketId, status, changedAt, seq}. The validator MUST reject tickets marked BLOCKED/CANCELLED in its snapshot (REVOKED).

9.3. Data freshness

The deployment parameter maxOfflineAge defines the maximum acceptable snapshot age. Once exceeded, the device MUST attach the STALE_OFFLINE_DATA warning, and the event policy MAY mandate switching to fail-closed mode (rejections until synchronization).

9.4. Limitations and reconciliation

Offline enforcement of maxUses/maxUsesPerDay is best-effort: the device knows only its own events plus the state from the last synchronization, so a coordinated attack on multiple disconnected gates may exceed the limit. After synchronization, the server MUST detect abuse (count of countedUse > limit) and SHOULD automatically set status = BLOCKED (further entries → REVOKED); already-admitted events remain in the log with an abuse flag in ext. For tickets with a small maxUses, online mode or a short synchronization interval is recommended. Device clocks MUST be synchronized (NTP) at every contact with the server.


10. Planned extension: credentials (including health)

Version 1.0 does not define health requirements but reserves a complete deployment path for them — without changing the base format and without bumping the major version:

  1. A new requirement type in the reserved health.* namespace, distributed as a MINOR change, e.g.:
{
  "type": "health.covid-cert",
  "critical": true,
  "params": {
    "accepted": ["vaccination", "recovery", "test"],
    "minDosesIfVaccination": 2,
    "maxTestAgeHours": 48
  }
}
  1. Verification outside the ticket: the credential is presented as a separate, signed document (pattern: EU Digital COVID Certificate / W3C Verifiable Credentials) and scanned at the gate independently of the ticket QR. The ticket carries only the requirement, never the holder’s status.
  2. Result recording: the validator records in requirementResults only { "type": "health.covid-cert", "satisfied": true, "method": "DCC" }. The certificate payload and any health data MUST NOT be persisted — not in the ticket, not in the user account, not in the log (special-category data, GDPR Art. 9; the legal basis for processing must be established before deployment).
  3. Fail-closed: thanks to critical: true, validators not updated to a version supporting health.* will reject the ticket with UNKNOWN_CRITICAL_REQUIREMENT instead of admitting a person without verification.
  4. Credential-dependent limits (e.g. “vaccinated attendees exempt from the capacity limit”): venue capacity is a deployment parameter outside the ticket; any such rules are implemented by the server based on aggregated requirementResults, not by the ticket format.

The same mechanism will serve other future credentials (identity.* accreditations, memberships, etc.).


11. Security and privacy

11.1. Keys

Signing keys MUST be stored in an HSM/KMS; rotation happens by publishing a new kid in the JWKS while keeping the old key until all tickets signed with it have expired. Key compromise → immediate removal from the JWKS (tickets signed with it will return UNKNOWN_KEY) and ticket reissuance.

11.2. Abuse vectors

Vector Mitigation
QR copy/screenshot First use consumes the limit; anti-passback; optionally rotating codes (§5.5); named tickets with identityCheckRequired.
Content forgery JWS signature; validators verify the signature without exception (online too).
Replay after revocation Revocation registry + maxOfflineAge (§9.3).
Double counting Debounce (§6); idempotency by validationId on log retransmission.
Device log tampering prevEventHash (chain), TLS transport, signed synchronization batches (recommended).

11.3. Personal data (GDPR)


12. JSON Schema

12.1. Ticket (ticket.json)

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://otvs.org/schemas/1.0/ticket.json",
  "title": "OTVS 1.0 Ticket",
  "type": "object",
  "additionalProperties": false,
  "required": ["spec", "ticketId", "issuer", "issuedAt", "status", "event", "entitlements"],
  "properties": {
    "spec": {
      "type": "string",
      "pattern": "^otvs/1\\.[0-9]+$",
      "description": "Specification identifier and version."
    },
    "ticketId": { "$ref": "#/$defs/uuid" },
    "issuer": { "type": "string", "minLength": 1 },
    "issuedAt": { "$ref": "#/$defs/dateTime" },
    "status": { "enum": ["ACTIVE", "BLOCKED", "CANCELLED"] },
    "statusReason": { "type": "string" },
    "event": { "$ref": "#/$defs/event" },
    "product": { "$ref": "#/$defs/product" },
    "holder": { "$ref": "#/$defs/holder" },
    "seat": { "$ref": "#/$defs/seat" },
    "entitlements": {
      "type": "array",
      "minItems": 1,
      "items": { "$ref": "#/$defs/entitlement" }
    },
    "requirements": {
      "type": "array",
      "items": { "$ref": "#/$defs/requirement" }
    },
    "ext": { "$ref": "#/$defs/ext" }
  },
  "$defs": {
    "uuid": {
      "type": "string",
      "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
    },
    "dateTime": {
      "type": "string",
      "format": "date-time",
      "description": "RFC 3339 with an explicit timezone offset."
    },
    "timeHHMM": {
      "type": "string",
      "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$"
    },
    "id": { "type": "string", "minLength": 1, "maxLength": 64 },
    "event": {
      "type": "object",
      "additionalProperties": false,
      "required": ["eventId", "timezone"],
      "properties": {
        "eventId": { "$ref": "#/$defs/id" },
        "name": { "type": "string" },
        "timezone": {
          "type": "string",
          "minLength": 1,
          "description": "IANA timezone identifier, e.g. Europe/Warsaw."
        },
        "venueId": { "$ref": "#/$defs/id" }
      }
    },
    "product": {
      "type": "object",
      "additionalProperties": false,
      "required": ["productId"],
      "properties": {
        "productId": { "$ref": "#/$defs/id" },
        "name": { "type": "string" },
        "category": {
          "type": "string",
          "description": "Informational classification, e.g. SINGLE, PASS, SEASON, INVITATION."
        }
      }
    },
    "holder": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "displayName": { "type": "string" },
        "identityCheckRequired": { "type": "boolean", "default": false }
      }
    },
    "seat": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "sectionId": { "$ref": "#/$defs/id" },
        "row": { "type": "string" },
        "number": { "type": "string" },
        "label": { "type": "string" }
      }
    },
    "entitlement": {
      "type": "object",
      "additionalProperties": false,
      "required": ["entitlementId", "zones"],
      "properties": {
        "entitlementId": { "$ref": "#/$defs/id" },
        "name": { "type": "string" },
        "zones": {
          "type": "array",
          "minItems": 1,
          "items": { "$ref": "#/$defs/id" }
        },
        "gates": {
          "type": "array",
          "minItems": 1,
          "items": { "$ref": "#/$defs/id" }
        },
        "validity": { "$ref": "#/$defs/validity" },
        "entryWindows": {
          "type": "array",
          "minItems": 1,
          "items": { "$ref": "#/$defs/entryWindow" }
        },
        "usage": { "$ref": "#/$defs/usage" },
        "requirements": {
          "type": "array",
          "items": { "$ref": "#/$defs/requirement" }
        },
        "ext": { "$ref": "#/$defs/ext" }
      }
    },
    "validity": {
      "type": "object",
      "additionalProperties": false,
      "minProperties": 1,
      "properties": {
        "from": { "$ref": "#/$defs/dateTime" },
        "until": { "$ref": "#/$defs/dateTime" }
      }
    },
    "entryWindow": {
      "type": "object",
      "additionalProperties": false,
      "required": ["from", "until"],
      "properties": {
        "from": { "$ref": "#/$defs/dateTime" },
        "until": { "$ref": "#/$defs/dateTime" },
        "gates": {
          "type": "array",
          "minItems": 1,
          "items": { "$ref": "#/$defs/id" }
        }
      }
    },
    "usage": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "maxUses": { "type": "integer", "minimum": 1 },
        "maxUsesPerDay": { "type": "integer", "minimum": 1 },
        "dayRollover": { "$ref": "#/$defs/timeHHMM", "default": "00:00" },
        "antiPassback": { "type": "boolean", "default": true }
      }
    },
    "requirement": {
      "type": "object",
      "additionalProperties": false,
      "required": ["type", "critical"],
      "properties": {
        "type": {
          "type": "string",
          "pattern": "^[a-z][a-z0-9]*(\\.[a-z][a-z0-9-]*)+$"
        },
        "critical": { "type": "boolean" },
        "params": { "type": "object" }
      }
    },
    "ext": {
      "type": "object",
      "description": "Custom extensions; keys SHOULD be prefixed with a reversed domain, e.g. com.example.tickets.x."
    }
  }
}

12.2. Validation event (validation-event.json)

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://otvs.org/schemas/1.0/validation-event.json",
  "title": "OTVS 1.0 Validation Event",
  "type": "object",
  "additionalProperties": false,
  "required": ["validationId", "ticketId", "eventId", "gateId", "deviceId", "direction", "occurredAt", "decision", "mode", "countedUse"],
  "properties": {
    "validationId": { "$ref": "#/$defs/uuid" },
    "ticketId": { "$ref": "#/$defs/uuid" },
    "entitlementId": { "type": ["string", "null"] },
    "eventId": { "type": "string", "minLength": 1 },
    "gateId": { "type": "string", "minLength": 1 },
    "gateType": { "enum": ["PERIMETER", "ZONE", "EXIT"] },
    "deviceId": { "type": "string", "minLength": 1 },
    "direction": { "enum": ["IN", "OUT"] },
    "occurredAt": { "type": "string", "format": "date-time" },
    "decision": { "enum": ["ALLOW", "REJECT"] },
    "reasonCode": { "type": ["string", "null"] },
    "warnings": {
      "type": "array",
      "items": { "type": "string" }
    },
    "mode": { "enum": ["ONLINE", "OFFLINE"] },
    "countedUse": { "type": "boolean" },
    "requirementResults": {
      "type": "array",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["type", "satisfied"],
        "properties": {
          "type": { "type": "string" },
          "satisfied": { "type": "boolean" },
          "method": { "type": "string" }
        }
      }
    },
    "prevEventHash": { "type": ["string", "null"] },
    "ext": { "type": "object" }
  },
  "$defs": {
    "uuid": {
      "type": "string",
      "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
    }
  }
}

13. Examples

13.1. Concert — single-use ticket, assigned seat, 18+ event

{
  "spec": "otvs/1.0",
  "ticketId": "0197a3b2-5c1d-7e2a-9f10-3b6c8d2e4f01",
  "issuer": "tickets.example.com",
  "issuedAt": "2026-06-12T09:41:00+02:00",
  "status": "ACTIVE",
  "event": {
    "eventId": "evt-2026-arena-091",
    "name": "XYZ Concert — Warsaw",
    "timezone": "Europe/Warsaw",
    "venueId": "ven-arena"
  },
  "product": {
    "productId": "prd-xyz-cat1",
    "name": "Standard ticket — category I",
    "category": "SINGLE"
  },
  "seat": {
    "sectionId": "A",
    "row": "12",
    "number": "7",
    "label": "Section A, row 12, seat 7"
  },
  "entitlements": [
    {
      "entitlementId": "e1",
      "name": "Main admission + section A",
      "zones": ["MAIN", "SECTOR-A"],
      "validity": {
        "from": "2026-09-12T17:00:00+02:00",
        "until": "2026-09-12T23:30:00+02:00"
      },
      "usage": { "maxUses": 1 }
    }
  ],
  "requirements": [
    { "type": "age.min", "critical": true, "params": { "minAge": 18 } }
  ]
}

A PERIMETER gate with zone MAIN admits once; the internal ZONE gate of sector SECTOR-A verifies the entitlement repeatedly without consuming the limit.

13.2. Festival — 3-day VIP pass, named, free in/out movement

{
  "spec": "otvs/1.0",
  "ticketId": "0197a3b2-9d4e-7b31-8a25-6c1f0e9d2b77",
  "issuer": "tickets.example.com",
  "issuedAt": "2026-05-02T14:03:00+02:00",
  "status": "ACTIVE",
  "event": {
    "eventId": "evt-festival-2026",
    "name": "Vistula Riverside Festival 2026",
    "timezone": "Europe/Warsaw"
  },
  "product": {
    "productId": "prd-fest-pass3-vip",
    "name": "3-day VIP pass",
    "category": "PASS"
  },
  "holder": {
    "displayName": "John Doe",
    "identityCheckRequired": true
  },
  "entitlements": [
    {
      "entitlementId": "e1",
      "name": "Festival grounds admission",
      "zones": ["GA"],
      "validity": {
        "from": "2026-07-03T13:00:00+02:00",
        "until": "2026-07-06T03:00:00+02:00"
      },
      "entryWindows": [
        { "from": "2026-07-03T13:00:00+02:00", "until": "2026-07-04T02:00:00+02:00" },
        { "from": "2026-07-04T13:00:00+02:00", "until": "2026-07-05T02:00:00+02:00" },
        { "from": "2026-07-05T13:00:00+02:00", "until": "2026-07-06T01:00:00+02:00" }
      ],
      "usage": {
        "dayRollover": "06:00",
        "antiPassback": true
      }
    },
    {
      "entitlementId": "e2",
      "name": "VIP zone",
      "zones": ["VIP"],
      "validity": {
        "from": "2026-07-03T13:00:00+02:00",
        "until": "2026-07-06T03:00:00+02:00"
      }
    }
  ]
}

No maxUses = unlimited entries within the windows; anti-passback enforces an exit before the next entry; the festival day ends at 06:00.

13.3. Museum — timed entry

{
  "spec": "otvs/1.0",
  "ticketId": "0197a3b3-1a2b-7c4d-9e5f-0a1b2c3d4e5f",
  "issuer": "tickets.example.com",
  "issuedAt": "2026-06-10T08:12:00+02:00",
  "status": "ACTIVE",
  "event": {
    "eventId": "evt-museum-2026-06-15",
    "name": "Museum of Technology — day admission",
    "timezone": "Europe/Warsaw"
  },
  "product": {
    "productId": "prd-museum-slot",
    "name": "Timed-entry ticket",
    "category": "SINGLE"
  },
  "entitlements": [
    {
      "entitlementId": "e1",
      "zones": ["EXPO"],
      "gates": ["G-MAIN"],
      "validity": {
        "from": "2026-06-15T12:00:00+02:00",
        "until": "2026-06-15T18:00:00+02:00"
      },
      "entryWindows": [
        { "from": "2026-06-15T12:00:00+02:00", "until": "2026-06-15T12:30:00+02:00" }
      ],
      "usage": { "maxUses": 1, "antiPassback": false }
    }
  ]
}

Entry only through gate G-MAIN in the 12:00–12:30 slot; staying is allowed until 18:00 (the window constrains only the moment of entry). antiPassback: false because the venue does not scan exits — maxUses provides the protection.

13.4. QR profile for the ticket from §13.3

JWS header:

{ "alg": "ES256", "typ": "otvs1+jws", "kid": "2026-06-k1" }

Payload (signature omitted):

{
  "v": 1,
  "jti": "0197a3b3-1a2b-7c4d-9e5f-0a1b2c3d4e5f",
  "iss": "tickets.example.com",
  "iat": 1781071920,
  "eid": "evt-museum-2026-06-15",
  "tz": "Europe/Warsaw",
  "ent": [
    {
      "i": "e1",
      "z": ["EXPO"],
      "g": ["G-MAIN"],
      "nbf": 1781517600,
      "exp": 1781539200,
      "w": [ { "f": 1781517600, "u": 1781519400 } ],
      "mu": 1,
      "ap": 0
    }
  ]
}

13.5. Event log excerpt (pass from §13.2, day 1)

[
  {
    "validationId": "0197b101-2233-7abc-8def-112233445566",
    "ticketId": "0197a3b2-9d4e-7b31-8a25-6c1f0e9d2b77",
    "entitlementId": "e1",
    "eventId": "evt-festival-2026",
    "gateId": "G1",
    "gateType": "PERIMETER",
    "deviceId": "dev-017",
    "direction": "IN",
    "occurredAt": "2026-07-03T14:21:05+02:00",
    "decision": "ALLOW",
    "reasonCode": null,
    "mode": "ONLINE",
    "countedUse": true,
    "requirementResults": [
      { "type": "identity.match", "satisfied": true, "method": "DOCUMENT" }
    ]
  },
  {
    "validationId": "0197b101-3344-7abc-8def-223344556677",
    "ticketId": "0197a3b2-9d4e-7b31-8a25-6c1f0e9d2b77",
    "entitlementId": null,
    "eventId": "evt-festival-2026",
    "gateId": "G1",
    "gateType": "EXIT",
    "deviceId": "dev-018",
    "direction": "OUT",
    "occurredAt": "2026-07-03T18:02:41+02:00",
    "decision": "ALLOW",
    "reasonCode": null,
    "mode": "ONLINE",
    "countedUse": false
  },
  {
    "validationId": "0197b101-4455-7abc-8def-334455667788",
    "ticketId": "0197a3b2-9d4e-7b31-8a25-6c1f0e9d2b77",
    "entitlementId": "e1",
    "eventId": "evt-festival-2026",
    "gateId": "G2",
    "gateType": "PERIMETER",
    "deviceId": "dev-021",
    "direction": "IN",
    "occurredAt": "2026-07-03T19:15:12+02:00",
    "decision": "ALLOW",
    "reasonCode": null,
    "warnings": ["STALE_OFFLINE_DATA"],
    "mode": "OFFLINE",
    "countedUse": true
  }
]

Note on the first event’s record: identity verification of a named ticket (identityCheckRequired) is reported as the result of the identity.match requirement — the identity.* namespace is reserved and will be formalized in a future MINOR version.


14. Conformance requirements

An Issuer MUST:

  1. emit documents strictly conforming to the schema in §12.1 (custom content exclusively in ext),
  2. sign the QR profile per §5 (ES256, kid),
  3. not place personal data or the status field in the QR,
  4. distribute status changes via the revocation registry (§9.2),
  5. publish the JWKS at the address in §5.4 and maintain key rotation (§11.1),
  6. use RFC 3339 with an explicit offset in all timestamps.

A Validator MUST:

  1. verify the signature of every scan — including in online mode,
  2. implement the algorithm in §6 in the given order, with deterministic entitlement selection,
  3. reject an unknown major version and tolerate unknown fields within the major version (§4.11),
  4. reject unsupported requirements with critical = true (fail-closed),
  5. count uses exclusively at PERIMETER gates,
  6. never block exits,
  7. record every validation attempt in the append-only log (§8),
  8. respect the revocation registry and signal a stale snapshot (§9.3).

15. Change history

Version Date Changes
1.0-draft.1 2026-06-12 First working draft.
1.0-draft.2 2026-06-12 Standard renamed: BTVS → OTVS (Open Ticket Validation Standard). Identifier migration: spec = otvs/1.x, JWS header typ = otvs1+jws, schemas under https://otvs.org/schemas/1.0/, JWKS under /.well-known/otvs/jwks.json. No semantic changes.
1.0-draft.3 2026-06-12 Added §5.6: wallet compatibility guidance (Apple Wallet / Google Wallet) — field mapping, layered signatures, rotating codes (TOTP), NFC, pass freshness. Generalized §5.1: the payload may be carried by any 2D code (QR remains the reference). Updated the cross-reference in §1. No changes to the data format.
1.0-draft.4 2026-06-12 Document translated to English; English is the project language from this version on. Example data genericized (issuer tickets.example.com, reverse-domain namespaces com.example.tickets.*). No normative changes.