Tickets

Guide to implementing event ticketing — mint, transfer, and verify tickets using Ticket intents.

When to Use Ticket Intents

Ticket intents are for any scenario where proof of entitlement needs to be created, transferred, and verified — concerts, conferences, gated access, NFT ticket drops. Tickets use a developer-owned free-form schema with a developer backend as the exit target.

Data Payload

pub struct FreeFormPayload {
    pub kind: String,   // e.g. "concert_vip_redeem", "event_ga_entry"
    pub data: Vec<u8>,  // Serialized application-specific fields
}

The SDK does not parse or validate the contents of data. Your application defines the schema and validation logic.

Dual-Signature Model

Tickets use a unique dual-signature envelope with three participants:

  1. Issuer — signs at creation (proves legitimacy)
  2. Device — signs the intent envelope (replay protection)
  3. Holder — signs at redemption (proves execution)

Signing Order

zypp_create_intent("ticket", payload)
IntentPayload::Ticket { signatures: DualSignatureEnvelope {
        issuer_signature: vec![],     // empty
        holder_signature: None,       // empty
        nonce: 0,
    }}

zypp_attach_issuer_signature(id, issuer_sig_hex, issuer_nonce)
  → writes to signatures.issuer_signature + signatures.nonce
  → guard: must be Ticket, issuer_signature must be empty

zypp_attach_signature(id, sig_hex, pk_hex, nonce)
  → writes to Intent.envelope (CryptographicEnvelope)
  → sets trust_tier = Signed

zypp_attach_holder_signature(id, holder_sig_hex)
  → writes to signatures.holder_signature
  → guard: must be Ticket, issuer_signature must be non-empty

Enforced guard: issuer signature must be non-empty before the holder can sign.

Creating a Ticket Intent

Expo SDK

import { createIntent, attachIssuerSignature, attachHolderSignature } from '@zypp-labs/expo-sdk';

// 1. Create ticket intent
const { intentId } = createIntent('concert_vip_redeem', JSON.stringify({
  eventId: 'concert-2026-07-15',
  seat: 'A12',
  tier: 'VIP',
}));

// 2. Attach issuer signature (from your backend)
attachIssuerSignature(intentId, issuerSigHex, 1);

// 3. Attach device signature
attachSignature(intentId, signatureHex, publicKeyHex, nonce);

// 4. At redemption, attach holder signature
attachHolderSignature(intentId, holderSigHex);

Unity SDK

var intent = ZyppSdk.CreateIntent("concert_vip_redeem", "{\"seat\":\"A12\"}", ZyppTrustTier.Signed);
ZyppSdk.AttachIssuerSignature(intent.intentId, issuerSigHex, 1);
ZyppSdk.AttachSignature(intent.intentId, sigHex, pubKeyHex, nonce);
ZyppSdk.AttachHolderSignature(intent.intentId, holderSigHex);

Verification Flow

At the venue, the verifier checks:

  1. intent.status == SyncedFinalized — the ticket has been accepted
  2. Issuer signature is present — the ticket was legitimately issued
  3. Holder signature is present — the device performed the redemption

Verification works offline — cached intents can be checked without internet access.

Template: Atomic Redemption

The zypp-ticket starter template provides an atomic check-and-set redemption pattern to prevent double-spend (TOCTOU):

// Template-level code (not in SDK core)
pub async fn validate_ticket_redemption(db_pool: &DbPool, payload: FreeFormPayload)
    -> Result<(), RejectionReason>
{
    let result = sqlx::query!(
        "UPDATE tickets SET redeemed = true, redeemed_at = NOW()
         WHERE id = $1 AND redeemed = false
         RETURNING winning_txn",
        ticket_id
    ).fetch_optional(db_pool).await;

    match result {
        Ok(Some(_)) => Ok(()),
        Ok(None) => Err(RejectionReason::ValidationError {
            code: 409,
            message: "Ticket already redeemed".to_string(),
            details: None,
        }),
        Err(e) => Err(RejectionReason::ValidationError {
            code: 500, message: e.to_string(), details: None,
        }),
    }
}

Offline Transfer

Tickets can be transferred between devices without network connectivity:

// Sender creates transfer packet
const { packetB64 } = createTransferPacket(intentId);

// Render as QR code, send via BLE, or use NFC
// ...

// Receiver decodes
const decoded = decodeTransferPacket(packetB64);

Error Handling

ErrorCauseAction
IssuerSignatureRequiredHolder signed before issuerComplete issuer signing first
ValidationErrorBackend rejected the ticketCheck backend validation rules
DuplicateInLocalQueueSame ticket already queuedAvoid duplicate creation

Next Steps