Build

Build with Atmosphere Tickets.

Use app service-auth, buyer assertions, availability, holds, checkout, free claims, QR passes, and ticket integrations.

Read as Markdown · Agent build guide

Developer beta SDK beta 0.0.0-beta.5 30 ticket lexicons Multi-app shared config roadmap

Auth model

App service-auth proves the caller and exact method; buyer/organizer assertions prove user intent. Use the target environment's configured broker service audience. Assertions are private, short-lived and replay-protected; they grant no OAuth repo-write permission.

Paid buyerBoth buyerDid and buyerAssertionJwt, or neither. Buyer assertion uses money.atmosphere.payment.assertPayer; app JWT uses the ticket method.
Payer bindingAsserted buyers are fixed by default. Explicit switchable requires reconciliation of CheckoutPayerChanged. Store private statusToken before redirect; browser URL/token may rotate.
Free buyerclaimFreeTicket/releaseFreeTicket require the buyer's own assertion scoped to that exact method. Fresh claims require customerEmail.
CreationOrganizer assertion is required unless the app is itself the organizer. A pilot is not a general consent exemption.
MutationsRequire current primary-organizer or supported accepted co-organizer authority. Commercial edits, capacity, archival and cancellation remain primary-organizer-only.
Selling approvalOrganizer recipient approval is separate from config consent and remains app/environment scoped.
Public writesPayer receipt/RSVP needs a separate exact OAuth grant and applicable consent, not a service assertion.

App dashboard config

The ATM app dashboard is the control plane for ticketing apps. Organizers should not see service-auth, receiver signatures, fee routing, or redrive logs unless they are also the app operator.

Developer

App configuration

Enable Tickets per environment, set app fee behavior, configure QR/wallet options, and choose public-record defaults.

Organizer approval

Sell into an event

Request ticket recipient approval before availability, holds, free claims, or paid checkout can run for an organizer.

Developer

Webhook settings

Choose events, rotate signing secrets, send test events, inspect attempts, configure optional XRPC receivers, and redrive failed deliveries.

Developer

Service-auth guidance

Copy audience, lxm values, example requests, environment-specific endpoints, and ticket method scopes.

Tickets

Operational bridge

View ticket purchase payments, delivery attempts, scanner activity, and safe support ids in ATM without exposing raw ticket secrets.

SDK examples

Keep ticket calls on the app server. Tickets uses ATM's published beta @atmosphere-money/app-node package rather than a separate ticket SDK because holds, checkout, webhooks, and payment status all share ATM app auth.

import { createAtmAppClient } from "@atmosphere-money/app-node";

const atm = createAtmAppClient({
  getServiceAuthToken: async ({ lxm, aud }) => {
    return mintMyAppServiceAuthJwt({ lxm, aud });
  }
});
// Node/TypeScript: create a paid ticket hold
const hold = await atm.createTicketHold({
  environment: "test",
  eventUri: "at://did:plc:organizer/community.lexicon.calendar.event/abc",
  buyerDid: "did:plc:buyer",
  buyerAssertionJwt,
  items: [{ ticketTypeId: "ttyp_...", quantity: 2 }],
  returnUrl: "https://events.example/orders/123",
  cancelUrl: "https://events.example/e/abc",
  idempotencyKey: "hold:event:buyer:type:2"
});
# curl: read availability
curl "https://checkout.atmosphere.money/xrpc/tickets.atmosphere.getTicketAvailability?eventUri=at://...&environment=test" \
  -H "Authorization: Bearer $APP_SERVICE_AUTH"

SDK beta and compatibility

Tickets uses the same beta app SDK and testing helpers as ATM. Keep SDK updates paired with ATM API version notes, because ticket holds, app callbacks, checkout, and payment status share the same app authentication and event envelope.

PackageVersionUseNotes
@atmosphere-money/app-node0.0.0-beta.5Server-side ATM/Tickets app calls.Dependency-light Node package for service-auth, holds, webhook verification, and XRPC receiver helpers.
@atmosphere-money/testingbetaFixtures and callback tests.Use for sample tickets.issued, ticket.checked-in, signatures, and replay/idempotency checks.
@atmosphere-money/mcpbetaAgent-friendly developer tools.Local/test-mode wrapper for config inspection, fixture generation, webhook validation, and delivery log debugging.

Beta.5 types/sends paid catalog refs as productRef/priceRef; ATM expects product and derives defaultPrice. Use authenticated raw XRPC for paid type creation/updates until the coordinated SDK release.

Compatibility note

Public ticket lexicons are still in community review. Treat the beta SDK as the app-developer surface for pilots, and pin exact versions before running live traffic.

Read the ATM SDK reference

MCP and testing tools

The MCP server is not required to integrate with Tickets. It is a convenience layer for coding agents and local developer workflows that need to inspect configuration, create fixtures, send test events, and debug receiver delivery without browsing the dashboard.

Good MCP actionsInspect app config, generate ticket fixtures, build webhook test payloads, list delivery logs, redrive test events, and verify receiver health.
Keep out of MCP for betaLive charges, refunds, payout settings, organizer KYC, buyer PII export, and arbitrary public repo writes.
Receiver choiceUse HTTP webhooks by default. Choose XRPC receiver callbacks only when the app already hosts an XRPC surface.
Source of truthFulfillment still depends on signed ATM/Tickets events and service reads, not on MCP output.

Framework recipes

Tickets follows the same model as ATM docs: the contract is framework-neutral, and framework examples all implement the same app-server responsibilities. Keep service-auth minting, hold creation, webhook/XRPC receiver verification, and fulfillment mutations on a trusted backend.

RuntimeUseNotes
Generic NodeLong-lived app services, agents, workers.Use the public Node starter and its readable source bundle as the reference.
Next.js route handlersDashboard-adjacent event apps and Vercel-style deployments.Create checkout/hold routes server-side and use the App Node SDK's Web Request webhook helpers.
Express/FastifyTraditional API servers.Preserve raw request bodies for signed webhook verification before JSON parsing.
HonoSmall AT Protocol services and edge-like Node runtimes.Use the Hono webhook helper and keep service-auth signing in server context.
Cloudflare WorkersLightweight receiver and scanner surfaces.Use the Workers helper for webhook verification; keep durable idempotency in KV, Durable Objects, or Postgres, not memory.
SvelteKitAtmosphere event apps with server routes or actions.Call Tickets from server routes or actions. Never call ticket hold or check-in mutation methods directly from browser code.
Shared

Every framework implements the same five pieces

  1. Mint a fresh app service-auth JWT for the exact tickets.atmosphere.* method.
  2. Optionally attach buyer or organizer assertions when acting for a signed-in user.
  3. Create holds or free claims on the server and return only safe UI data to the browser.
  4. Verify signed webhooks or optional XRPC receiver callbacks before fulfillment.
  5. Store delivery ids, hold ids, ticket ids, and scanner state idempotently in app storage.

Integration recipes

These are the minimum app-server recipes most event apps need: paid checkout, free scarce claims, scanner check-in, and event receiver handling. Keep app service-auth and receiver secrets on your server.

01

Paid ticket checkout

Read availability, create a hold, then redirect to the ATM checkout URL returned by the hold.

const availability = await tickets.getTicketAvailability({
  environment: "test",
  eventUri
});

assertCanBuy(availability, ticketTypeId, quantity);

const hold = await tickets.createTicketHold({
  environment: "test",
  eventUri,
  buyerDid,
  buyerAssertionJwt,
  items: [{ ticketTypeId, quantity }],
  returnUrl: "https://events.example/orders/123",
  cancelUrl: "https://events.example/e/abc",
  idempotencyKey: operationKey // Persist once per intended purchase.
});

return Response.redirect(hold.url);
02

Free limited ticket claim

Use the same scarce inventory transaction without a payment redirect.

const claim = await tickets.claimFreeTicket({
  environment: "test",
  eventUri,
  items: [{ ticketTypeId, quantity: 1 }],
  customerEmail,
  buyerDid,
  buyerAssertionJwt,
  idempotencyKey: operationKey // Reuse only for this claim's retries.
});

return Response.json({ tickets: claim.tickets });
03

Scanner check-in

Scan an opaque token, never a raw ticket id or buyer DID. Repeat scans return existing state.

const result = await tickets.checkInTicket({
  ticketToken: scannedQrToken,
  environment: "test",
  checkInListId
});

if (result.status === "already-checked-in") {
  showAlreadyCheckedIn(result.checkedInAt);
} else if (result.status === "checked-in") {
  showEntryAllowed(result.ticket);
} else {
  showEntryDenied();
}
04

Event receiver

Use either HTTP webhooks or the optional XRPC receiver. Both are signed by ATM and are at-least-once.

async function handleAtmTicketEvent(event, deliveryId) {
  // verifiedEvent only; deliveryId comes from the verified delivery header.
  // In one durable transaction: deduplicate, apply effects, mark complete.
  return processDeliveryAtomically(deliveryId, async () => {

  switch (event.type) {
    case "tickets.issued":
      await showTicketsToBuyer(event.data);
      break;
    case "ticket.checked-in":
      await updateScannerDashboard(event.data);
      break;
    case "ticket.voided":
      for (const ticket of event.data.tickets) await revokeVisiblePass(ticket.id);
      break;
  }
  });
}

Sequence diagrams

01

Paid ticket purchase

Buyer selects tickets in the event app, the app creates a hold, ATM checkout collects payment, then Tickets issues ticket records and sends events.

02

Free limited claim

Buyer clicks Get ticket, the app sends app service-auth plus buyer assertion, Tickets locks capacity and issues immediately without checkout.

03

Check-in

Scanner reads an opaque token, calls verify for display state, then calls checkInTicket for the mutating entry action.

04

Refund or void

ATM refund/dispute state triggers Tickets to void or review issued tickets and revoke pass tokens.

Paid ticket
buyer -> app: select type
app -> Tickets: createTicketHold(app auth + buyer assertion)
Tickets -> app: hold + ATM checkout URL
buyer -> ATM: pay
ATM -> Tickets: payment confirmed
Tickets -> app: tickets.issued

Availability

Read availability from your trusted backend with app service-auth and exactly one of eventId or eventUri. The response uses ticketTypes, including current sale/visibility state and optional inventory counts.

availableQuantity is the minimum remaining capacity across type and event pools; it is omitted when unlimited. quantityTotal describes only the type's own cap. Never interpret omission as zero or use public catalog counts as reservation authority. Hidden types require a valid accessCode or supported event code.

GET /xrpc/tickets.atmosphere.getTicketAvailability?environment=test&eventUri=at://...
Authorization: Bearer <app service-auth jwt>

Helper snippets

These snippets belong on the event app server. Replace token minting stubs with your AT Protocol auth stack and keep app service-auth, buyer assertions, webhook secrets, and scan tokens out of browser code.

01

Read availability

async function getAvailability(eventUri) {
  const params = new URLSearchParams({ environment: "test", eventUri });
  const response = await fetch(
    "https://checkout.atmosphere.money/xrpc/tickets.atmosphere.getTicketAvailability?" + params,
    { headers: { authorization: "Bearer " + appServiceAuthJwt } }
  );
  if (!response.ok) throw new Error(await response.text());
  return response.json();
}
02

Create paid hold

async function createHold({ eventUri, ticketTypeId, buyerDid, buyerAssertionJwt, operationKey }) {
  const response = await fetch(
    "https://checkout.atmosphere.money/xrpc/tickets.atmosphere.createTicketHold",
    {
      method: "POST",
      headers: {
        authorization: "Bearer " + appServiceAuthJwt,
        "content-type": "application/json"
      },
      body: JSON.stringify({
        environment: "test",
        eventUri,
        buyerDid,
        buyerAssertionJwt,
        items: [{ ticketTypeId, quantity: 1 }],
        idempotencyKey: operationKey
      })
    }
  );
  if (!response.ok) throw new Error(await response.text());
  return response.json();
}
03

Claim free ticket

async function claimFreeTicket({ eventUri, ticketTypeId, buyerDid, buyerAssertionJwt, customerEmail, operationKey }) {
  const idempotencyKey = operationKey; // Persist once per intentional claim.
  const response = await fetch(
    "https://checkout.atmosphere.money/xrpc/tickets.atmosphere.claimFreeTicket",
    {
      method: "POST",
      headers: {
        authorization: "Bearer " + appServiceAuthJwt,
        "content-type": "application/json"
      },
      body: JSON.stringify({
        environment: "test",
        eventUri,
        items: [{ ticketTypeId, quantity: 1 }],
        customerEmail,
        buyerDid,
        buyerAssertionJwt,
        idempotencyKey
      })
    }
  );
  if (!response.ok) throw new Error(await response.text());
  return response.json();
}
04

Check in token

async function checkInTicket({ ticketToken, checkInListId }) {
  const response = await fetch(
    "https://checkout.atmosphere.money/xrpc/tickets.atmosphere.checkInTicket",
    {
      method: "POST",
      headers: {
        authorization: "Bearer " + appServiceAuthJwt,
        "content-type": "application/json"
      },
      body: JSON.stringify({ environment: "test", ticketToken, checkInListId })
    }
  );
  if (!response.ok) throw new Error(await response.text());
  return response.json();
}

API examples

POST

Create paid hold

{
  "environment": "test",
  "eventUri": "at://did:plc:organizer/community.lexicon.calendar.event/demo",
  "buyerDid": "did:plc:buyer",
  "buyerAssertionJwt": "REPLACE_WITH_ASSERT_PAYER_JWT",
  "items": [{ "ticketTypeId": "ttyp_paid", "quantity": 2 }],
  "returnUrl": "https://events.example/orders/123",
  "cancelUrl": "https://events.example/e/demo",
  "idempotencyKey": "hold:order-123"
}
POST

Claim free tickets

{
  "environment": "test",
  "eventUri": "at://did:plc:organizer/community.lexicon.calendar.event/demo",
  "buyerDid": "did:plc:buyer",
  "buyerAssertionJwt": "REPLACE_WITH_CLAIM_FREE_TICKET_JWT",
  "customerEmail": "buyer@example.com",
  "items": [{ "ticketTypeId": "ttyp_free", "quantity": 1 }],
  "idempotencyKey": "claim:order-124"
}
POST

Check in with app authority

{
  "environment": "test",
  "ticketToken": "REPLACE_WITH_OPAQUE_SCAN_TOKEN",
  "checkInListId": "tcil_..."
}

Private ticket lists use buyer or paymentId (exactly one), and organizer. Assertions never belong in query strings.

Interactive API explorer

Generate copyable request shells for common Tickets calls. This docs widget does not send requests; it gives your app server a starting point.

curl "https://checkout.atmosphere.money/xrpc/tickets.atmosphere.getTicketAvailability?eventUri=at://...&environment=test" \
  -H "Authorization: Bearer $APP_SERVICE_AUTH"

Holds and checkout

Paid holds atomically reserve every applicable private capacity pool and return holdId, statusToken, token, url, expiresAt, final amount/currency and item snapshots. Quantity stays fixed.

Use the returned expiry and current lifecycle state. ATM uses a 30-minute base processor window with recovery bounded by an immutable reservation deadline. Browser cancellation, a timeout or a client timer does not prove the processor session is dead; release follows ATM's guarded reconciliation.

Persist one idempotency key/request per purchase and retry that exact operation after an uncertain result. Issue only after ATM confirms settlement, including the distinct paid order discounted to $0. A redirect is UX, not issuance evidence.

Concurrency and idempotency

Concurrent holdsATM locks/revalidates type, event and pool authority, then reserves every applicable pool atomically. Refresh availability on capacity conflicts.
Unknown outcomePersist and reuse the same hold/claim/release key and body. Do not create a replacement purchase while the original is unresolved.
ExpiryOnly ATM's guarded processor/session lifecycle releases paid capacity. Do not release based solely on browser return or a local timer.
CallbacksUse durable, atomic delivery-processing state with application side effects; recover interrupted handlers and deduplicate redrive.
Check-inIdempotent per ticket/list, with checked-in, already-checked-in or not-valid. No current wire idempotency key is required.

Free tickets and release

claimFreeTicket issues zero-price types transactionally without checkout, a payment row or public payment proof. Every fresh claim requires app service-auth, verified buyerDid/buyerAssertionJwt, private customerEmail and items: [{ ticketTypeId, quantity }]. Supply a stable idempotencyKey for safe retries; its claim wire field is optional. Email omission is reserved for exact replay of legacy completed no-email claims.

releaseFreeTicket is the authenticated inverse: required buyer assertion plus caller-stable idempotencyKey, optionally scoped by ticketIds. It voids active free-claim tickets only, releases seats, revokes passes and resumes waitlist/RSVP cleanup. It does not release paid, transferred or checked-in tickets. A no-op returns releasedCount: 0; retry the original release key so later claims are not accidentally consumed.

Forms, waitlists, codes and event control

Use setTicketTypeForm for private type questions. listTicketFormResponses, listEventWaitlist and listCollaborations are POST procedures with user assertions in the body.

joinWaitlist targets a sold-out type without consuming capacity. ATM offers a reserved hold when seats return; claimWaitlistOffer starts checkout using that hold, never auto-charges. Supply a buyer assertion whenever supplying a buyer DID.

Co-organizer acceptance requires the named co-organizer's own assertion. Operational delegation does not permit primary-only commercial/capacity/archival/cancellation actions, and revenueShareBps never auto-splits ticket revenue.

Price coupons differ from hidden-type access codes. Managed event codes may combine them and count ticket quantities, not orders. A Free/100%-off coupon on a paid type uses $0 checkout and a private payment audit, without a public payment proof. New comp-code creation is retired.

cancelTicketEvent irreversibly stops sales/admission, revokes passes and queues applicable refunds and notifications. Handle partial/failed-refund review events explicitly; do not infer release success from a browser timeout.

QR and wallet passes

QR codes and wallet passes are presentation layers over issued tickets. Apps can enable hosted QR tickets, Apple Wallet passes, and Google Wallet passes per environment without building a pass provider integration themselves. Pass branding should use the app profile plus organizer, event, and type display metadata. QR payloads must contain only opaque scan tokens or scan URLs, never DIDs, emails, payment ids, ticket ids, attendee answers, or raw secrets.

Hosted buyer ticket pages use tickets.atmosphere.tickets. Scanner staff can use the default QR scanner app at scan.atmosphere.tickets, so event apps do not need to ship scanner infrastructure just to launch ticketed events. Apps can still build custom scanner UX later by calling the same verification and check-in methods. Both hosted surfaces are served by the ATM runtime for launch, with atmosphere.money fallback URLs available for operational recovery.

Stored privately

Scan authority

Hashed token secrets, revocation status, ticket status, wallet provider ids, and sync errors.

Returned to apps

Presentation

Safe event, type, brand, and scanner URL data for displaying the ticket to the buyer.

Hosted delivery

Ticket page and QR fallback

Ticket emails link to the hosted ticket page and can include safe QR presentation data; PDFs are not required for launch.

Wallet passes

Apple and Google Wallet

Wallet provider rows carry only opaque ATM scan URLs; hosted QR tickets remain the fallback when wallet passes are unavailable.

Default scanner

scan.atmosphere.tickets

Scanner staff can verify and check in opaque tokens with the hosted scanner app before an event app builds its own door tools.

Custom scanner

Same verification contract

Apps that need branded door workflows can call verifyTicket and checkInTicket while preserving the same private token model.

Current content restrictions may suppress event/type copy, images, email or wallet presentation without deleting an issued ticket or revoking valid admission. Keep purchase-time monetary snapshots for receipt history; scanner admission resolves private ticket/grant state even when presentation is unavailable.

Scanner authentication

The staff PWA at scan.atmosphere.tickets uses identity-only AT Protocol OAuth and an active private event/list grant. Its session supplies scanner DID; the browser does not nominate its own admission authority.

Event-app scanner backends use app service-auth for verifyTicket/checkInTicket. An optional scannerDid on that XRPC is app-asserted audit identity; ATM does not independently authenticate it. The app must authenticate its staff.

The private machine pilot is a separate POST /api/pilot/tickets/check-in on the exact stable checkout origin, using an ATM-issued short-lived bearer. Its body accepts only ticketToken; grant/event/list/scanner binding is server-owned. It returns exactly status, eventId, ticketId, did and checkedInAt; guest DID is null. Deduplicate badge jobs by ticketId. Retry an indeterminate result with the identical QR and bounded backoff.

Read the pilot scanner contract. Its deployment requires separately configured ATM_TICKET_SCANNER_API_PILOT_CONFIG, exact credential/grant scope and expiry; ordinary app registration or a staff DID alone does not enable the pilot.