| 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.
The standard defines an admission ticket format and a deterministic algorithm for its validation at access control, covering:
requirements) allowing future credentials — including health credentials (e.g. vaccinations) — to be added without changing the base format (see §10).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.
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).
Product ──issues──▶ Ticket ──contains──▶ Entitlement (1..n)
│ │
│ └─ rules: zones, gates, time windows, limits, requirements
│
└──generates──▶ Validation event (0..n, append-only)
Fundamental principles:
status (managed by the Issuer and distributed via the revocation registry, §9.2).maxUses, maxUsesPerDay) are counted per entitlement, not per ticket.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.
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.
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.
| 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). |
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. |
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. |
holderNamed 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). |
seatInformational 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”. |
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). |
validity vs entryWindowsvalidity constrains when entry is possible at all (hard boundaries: before from → NOT_YET_VALID, after until → EXPIRED).entryWindows additionally narrows the moments of entry to a list of {from, until, gates?} windows. Entry outside every window → OUTSIDE_ENTRY_WINDOW.gates list in a window restricts that window to the listed gates (e.g. early entry only through the VIP gate).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:
direction = IN, decision = ALLOW, countedUse = true (counted exclusively at PERIMETER gates).maxUses: 1; festival with free in/out movement → no maxUses, antiPassback: true; “one entry per day, no re-entry” → maxUsesPerDay: 1.maxUses to match the realities of the venue.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.
ext — custom extensionsAn 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.
spec = otvs/MAJOR.MINOR. MINOR changes are strictly additive (new optional fields, new requirement types, new result codes).otvs/1.x MUST reject a ticket with a different major version (UNSUPPORTED_SPEC_VERSION) and MUST tolerate (ignore) unknown fields within the same major version (tolerant reader). The only exception to ignoring: requirements with critical = true (§4.9).ext.requirements types (with critical control) and ext.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" }
alg: validators MUST support ES256 (ECDSA P-256). The Issuer MAY additionally use EdDSA (Ed25519) if all validators in the deployment support it.kid: key identifier — required (key rotation, §11.1).typ: the constant otvs1+jws (the digit = major version).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.
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.
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).
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.
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:
.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.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.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.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:
uses(e) — the number of events {ticketId, entitlementId = e, direction = IN, decision = ALLOW, countedUse = true}.usesToday(e) — as above, restricted to the current day per dayRollover and event.timezone.presence(ticket) — the state from §3.2 based on the last PERIMETER/EXIT event.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”.
The validator’s decision: decision ∈ {ALLOW, REJECT}; on REJECT exactly one reasonCode is set; the warnings[] field may accompany either decision.
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 | status ≠ ACTIVE (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). |
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.
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. |
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.
status changes are distributed as incremental deltas {ticketId, status, changedAt, seq}. The validator MUST reject tickets marked BLOCKED/CANCELLED in its snapshot (REVOKED).
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).
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.
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:
health.* namespace, distributed as a MINOR change, e.g.:{
"type": "health.covid-cert",
"critical": true,
"params": {
"accepted": ["vaccination", "recovery", "test"],
"minDosesIfVaccination": 2,
"maxTestAgeHours": 48
}
}
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).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.requirementResults, not by the ticket format.The same mechanism will serve other future credentials (identity.* accreditations, memberships, etc.).
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.
| 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). |
holder exists only in the full document and only for named tickets (minimization).ticketId); the ticket↔person link is maintained exclusively by the sales system. Fulfilling the right to erasure = severing the link; the pseudonymized log remains for accounting purposes for a configured retention period (≤ 24 months recommended, at the DPO’s discretion).requirementResults stores boolean values only (§10 item 3).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."
}
}
}
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}$"
}
}
}
{
"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.
{
"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.
{
"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.
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
}
]
}
[
{
"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.
An Issuer MUST:
ext),ES256, kid),status field in the QR,A Validator MUST:
critical = true (fail-closed),PERIMETER gates,| 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. |