REST API for StellarTickets — Secure. Verifiable. Powered by Stellar.
Built with NestJS + Prisma
(PostgreSQL). This service owns organizer/event metadata, authentication, and
the marketplace search surface — it never custodies ticket ownership itself.
The ticketing Soroban
contract is the source of truth for who owns a ticket and whether it's valid;
this API reads and writes through it, and keeps its own Postgres copy only as
a fast, searchable cache.
- New to this stack? Start here
- Non-custodial by design
- How this fits with the other repos
- Domain model
- Modules
- API reference
- Getting started
- Environment
- Testing
- Project structure
- More documentation
A plain-language glossary for anyone new to NestJS, Prisma, or the Stellar-specific pieces. Skip this if you already know the stack.
| Term | What it means | Why it matters here |
|---|---|---|
| NestJS | A TypeScript backend framework built around modules, controllers, and services (dependency-injected, à la Angular's structure but for the server). | Every feature area (auth, events, tickets, organizations, users, stellar) is its own Nest module — see Modules below. |
| Controller | The class that maps HTTP routes (@Get, @Post, …) to method calls. Has no business logic itself. |
tickets.controller.ts is only routing; the actual issue/transfer/check-in logic lives in tickets.service.ts. |
| Service | The class that holds business logic, injected into controllers (and other services) by Nest's DI container. | stellar.service.ts is the one service every other domain service calls through to touch the blockchain. |
| DTO (Data Transfer Object) | A plain class describing the shape of an incoming request body, decorated with class-validator rules. |
Combined with the global ValidationPipe in main.ts, a malformed request body is rejected before it reaches any service code. |
| Guard | Code that runs before a route handler and can block the request (return false/throw) — used here for JWT auth and role checks. |
JwtAuthGuard and RolesGuard in src/auth/guards gate every endpoint that requires a signed-in user or a specific role. |
| Prisma | A TypeScript ORM: you describe your schema in prisma/schema.prisma, and it generates a fully-typed database client plus SQL migration files. |
prisma/schema.prisma in this repo is the single source of truth for the Postgres schema; PrismaService wraps the generated client as an injectable Nest provider. |
| Migration | A versioned SQL file (generated by prisma migrate dev) that changes the database schema and is checked into git, so every environment's schema history is reproducible. |
prisma/migrations/20260803160811_init is the first one, in this repo. |
| JWT (JSON Web Token) | A signed token proving "this request came from an already-authenticated user" without a database lookup on every request. | Issued by POST /auth/login; JwtAuthGuard verifies it on protected routes using JWT_SECRET. |
| XDR | Stellar's binary transaction format. See the blockchain repo's glossary for the full picture. | This API's "build" endpoints return unsigned XDR; its "confirm" endpoints accept signed XDR back. |
| Non-custodial | This service never holds a private key that could move a user's funds or sign on their behalf. | See the next section — it's the single most important architectural decision in this repo. |
| Source account vs. signer | A Stellar transaction has a "source account" (whose sequence number/fee it uses) which is not necessarily the account that must authorize the operations inside it. | PLATFORM_SIGNER_SECRET is only ever used as a disposable source account for read-only simulations — never to sign a state-changing write. |
This backend never holds a user's Stellar secret key. Every on-chain action (publishing an event, issuing/purchasing/transferring/checking in/revoking/ reselling a ticket) is a two-step flow:
POST /.../<action>— the API simulates the contract call against the caller's own public key and returns an unsigned, fee-prepared XDR envelope.- The caller's wallet (Freighter, etc.) signs it client-side — the private key never leaves the browser extension, let alone reaches this server.
POST /.../confirm-<action>— the API relays the signed envelope to Soroban RPC, polls it to completion, and updates its own read-model (Ticket.status,Event.status, …) to match what's now true on-chain.
PLATFORM_SIGNER_SECRET is the one Stellar key this service does hold, and
it is deliberately limited: it's used only as a disposable source account for
read-only simulations (verify_ticket, get_event) that don't need any
particular signer's authorization. It never signs a transaction that changes
state.
See src/stellar/stellar.service.ts for
the implementation and
src/tickets/tickets.service.ts for how
each ticket action wires build → sign (client-side) → confirm together.
┌───────────────────┐ build-* ┌──────────────────────┐ submit signed XDR ┌────────────────────┐
│ frontend │◀──────────│ this repo │────────────────────▶│ blockchain │
│ (Next.js, browser) │ │ (NestJS + Postgres) │ │ `ticketing` contract │
│ │──────────▶│ │◀─── read state ──────│ (Stellar network) │
└──────────────────────┘ confirm-* └──────────────────────┘ └────────────────────┘
The frontend never talks to Soroban directly — it only ever calls this API, which owns the build/confirm XDR flow and the Postgres cache that makes marketplace search and dashboard listing fast without a chain read on every page load.
One flexible schema covers all twelve supported industries (concerts,
flights, sports, festivals, conferences, bus, movie theaters, museums,
tourist attractions, public transport, universities, corporate events) — the
Industry enum is the only industry-specific piece, used for filtering and
display copy. See prisma/schema.prisma for the full
schema with comments; the shape in brief:
User ──┬── memberships ──▶ OrganizationMember ◀── Organization
├── tickets ───────▶ Ticket
└── resaleListings ▶ ResaleListing
Organization ── events ──▶ Event ──┬── ticketTypes ──▶ TicketType
└── tickets ───────▶ Ticket ── resaleListings ▶ ResaleListing
A few fields worth calling out:
Event.chainEventId/Ticket.chainTicketId— theu64IDs returned by the contract'screate_event/issue_ticket.nulluntil the on-chain call succeeds, which is how the API knows an event/ticket exists in the database but hasn't actually been published/minted yet.Ticket.status— a cached projection of the contract's on-chainTicketStatus. The contract remains the source of truth; this column exists purely so listing/search queries don't need a Soroban RPC round trip. It's reconciled on every write path and by a periodic reconciliation job.Ticket.qrSecret— an opaque per-ticket secret embedded in the scannable QR code at/verify. Check-in validates this against both the database and the on-chain owner/status, so a photographed QR code alone can't be replayed as a valid entry.
| Module | Responsibility |
|---|---|
auth |
Registration/login, JWT issuance, password hashing (bcrypt) |
organizations |
Organizer accounts, membership roles (owner/admin/staff), the Stellar account that signs on-chain writes |
events |
Event/ticket-type CRUD, publishing an event on-chain |
tickets |
Issuance, primary sale, transfer, check-in, revocation, resale marketplace |
users |
Profile lookup, linking a Stellar public key to an account |
stellar |
The Soroban ticketing contract client — every module above calls through it for on-chain reads/writes |
prisma |
Wraps PrismaClient as an injectable PrismaService/PrismaModule |
common |
Shared decorators, e.g. a Stellar public-key validator for DTOs |
config |
Typed environment variable validation at boot |
All routes are prefixed with the app's base path; auth routes are public,
everything else requires a valid JWT (Authorization: Bearer <token>) unless
noted. "build" endpoints return unsigned XDR for the caller's wallet to sign;
the matching "confirm" endpoint accepts the signed XDR back.
Auth — src/auth
| Method & path | Purpose |
|---|---|
POST /auth/register |
Create an account (email, password, name) |
POST /auth/login |
Exchange credentials for a JWT |
Users — src/users
| Method & path | Purpose |
|---|---|
GET /users/me |
Current user's profile |
PATCH /users/me/wallet |
Link/update the caller's Stellar public key |
GET /users/lookup |
Look up a user (e.g. by email) for transfers |
Organizations — src/organizations
| Method & path | Purpose |
|---|---|
POST /organizations |
Create an organization |
GET /organizations/mine |
Organizations the caller is a member of |
GET /organizations/:id |
Organization detail |
Events — src/events
| Method & path | Purpose |
|---|---|
GET /events |
Public marketplace listing |
GET /events/:eventId |
Event detail |
GET /organizations/:organizationId/events |
Events under an organization |
POST /organizations/:organizationId/events |
Create a draft event |
POST /events/:eventId/ticket-types |
Add a ticket type (name, price, quantity) to a draft event |
POST /events/:eventId/publish |
build — unsigned XDR for the on-chain create_event call |
POST /events/:eventId/confirm-publish |
confirm — submits the signed XDR, sets chainEventId and status: PUBLISHED |
Tickets — src/tickets
| Method & path | Purpose |
|---|---|
GET /tickets/mine |
Tickets the caller owns |
GET /tickets/resale |
Active resale listings (marketplace) |
GET /tickets/verify/:qrSecret |
Look up a ticket by its QR secret, for the /verify gate-scanner flow |
POST /tickets/issue / confirm-issue |
Organizer-authorized issuance (off-chain payment already settled) |
POST /tickets/purchase / confirm-purchase |
Fully on-chain primary sale |
POST /tickets/:ticketId/transfer / confirm-transfer |
Direct transfer to another user |
POST /tickets/:ticketId/check-in / confirm-check-in |
Mark used at the gate |
POST /tickets/:ticketId/revoke / confirm-revoke |
Organizer voids a ticket |
POST /tickets/:ticketId/list-resale / confirm-list-resale |
List for resale (price capped by the event's anti-scalping policy on-chain) |
POST /tickets/:ticketId/cancel-resale / confirm-cancel-resale |
Pull a listing |
POST /tickets/:ticketId/buy-resale / confirm-buy-resale |
Buy a listed ticket; royalty + seller payout settle atomically on-chain |
See docs/API.md for full request/response shapes.
Prerequisites: Node.js ≥ 22 (see .nvmrc), a PostgreSQL
instance, and a deployed instance of the
ticketing contract (use its
testnet deployment walkthrough if you don't have one yet).
git clone https://github.com/StellarTickets/backend.git
cd backend
npm install
cp .env.example .env # fill in DATABASE_URL, JWT_SECRET, Soroban RPC config
npx prisma migrate dev # creates the database schema
npm run start:dev # http://localhost:3000, hot-reloadingOr with Docker (brings up Postgres only, for use with npm run start:dev on
the host):
docker compose upOr the full dev stack — Postgres and the API, with hot reload, in one command:
cp .env.example .env # fill in JWT_SECRET, Soroban RPC config, etc.
docker compose --profile full upThis runs pending Prisma migrations automatically before starting the API,
and mounts the working directory into the container so edits on the host
reload the running server. The API is then reachable at
http://localhost:3000.
See .env.example for the full list. The Stellar-specific
ones are worth calling out:
| Variable | Meaning |
|---|---|
SOROBAN_RPC_URL |
The Soroban RPC endpoint used to simulate and submit transactions (e.g. https://soroban-testnet.stellar.org) |
STELLAR_NETWORK |
testnet / futurenet / mainnet — must match the frontend's NEXT_PUBLIC_STELLAR_NETWORK and whatever network the user's wallet is set to |
TICKETING_CONTRACT_ID |
The deployed ticketing contract address (starts with C) |
PLATFORM_SIGNER_SECRET |
A disposable Stellar secret key used only as the source account for read-only simulations — never used to sign a write, and never a user's key (see Non-custodial by design) |
npm test # unit tests (Jest, colocated *.spec.ts files)
npm run test:cov # with coverage
npm run test:e2e # end-to-end, against test/jest-e2e.json
npm run lint # ESLint, --fix.
├── prisma
│ ├── schema.prisma # the schema — see Domain model above
│ └── migrations
├── src
│ ├── auth # register/login, JWT, guards
│ ├── organizations
│ ├── events
│ ├── tickets
│ ├── users
│ ├── stellar # the Soroban contract client (build/sign/submit)
│ ├── prisma # injectable PrismaService
│ ├── common # shared decorators
│ ├── config # env var validation
│ ├── app.module.ts
│ └── main.ts # bootstrap: helmet, CORS, global ValidationPipe
├── test # e2e suite
├── docs # architecture, auth, database, API, FAQ
├── Dockerfile / docker-compose.yml
└── README.md
The docs/ directory goes deeper on specific topics:
| Doc | Covers |
|---|---|
ARCHITECTURE.md |
How this API fits into the wider system |
API.md |
Full request/response reference |
AUTHENTICATION.md |
JWT flow, guards, roles |
DATABASE.md |
Schema design notes |
NON_CUSTODIAL.md |
The build/sign/submit flow in depth |
PRISMA_7_NOTE.md |
Why Prisma is pinned to 6.x, not 7 |
ERROR_HANDLING.md |
Error response shape and conventions |
VALIDATION.md |
DTO/class-validator conventions |
RATE_LIMITING.md |
Rate limiting approach |
OBSERVABILITY.md |
Logging and monitoring |
CORS.md |
CORS configuration |
DEPLOYMENT.md |
Production deployment notes |
TESTING.md |
Test suite conventions |
GLOSSARY.md |
Extended terminology |
FAQ.md |
Common questions |
See also CONTRIBUTING.md, SECURITY.md,
and CHANGELOG.md.