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 /v1/auth/register |
Create an account (email, password, name) |
POST /v1/auth/login |
Exchange credentials for a JWT |
Users — src/users
| Method & path | Purpose |
|---|---|
GET /v1/users/me |
Current user's profile |
PATCH /v1/users/me/wallet |
Link/update the caller's Stellar public key |
GET /v1/users/lookup |
Look up a user (e.g. by email) for transfers |
Organizations — src/organizations
| Method & path | Purpose |
|---|---|
POST /v1/organizations |
Create an organization |
GET /v1/organizations/mine |
Organizations the caller is a member of |
GET /v1/organizations/:id |
Organization detail |
Webhooks — src/webhooks
| Method & path | Purpose |
|---|---|
POST /v1/organizations/:organizationId/webhooks |
Register a webhook endpoint |
GET /v1/organizations/:organizationId/webhooks |
List registered webhook endpoints |
DELETE /v1/organizations/:organizationId/webhooks/:webhookId |
Delete a webhook endpoint |
Events — src/events
| Method & path | Purpose |
|---|---|
GET /v1/events |
Public marketplace listing |
GET /v1/events/:eventId |
Event detail |
GET /v1/organizations/:organizationId/events |
Events under an organization |
POST /v1/organizations/:organizationId/events |
Create a draft event |
POST /v1/events/:eventId/ticket-types |
Add a ticket type (name, price, quantity) to a draft event |
POST /v1/events/:eventId/publish |
build — unsigned XDR for the on-chain create_event call |
POST /v1/events/:eventId/confirm-publish |
confirm — submits the signed XDR, sets chainEventId and status: PUBLISHED |
Tickets — src/tickets
| Method & path | Purpose |
|---|---|
GET /v1/tickets/mine |
Tickets the caller owns |
GET /v1/tickets/resale |
Active resale listings (marketplace) |
GET /v1/tickets/verify/:qrSecret |
Look up a ticket by its QR secret, for the /verify gate-scanner flow |
POST /v1/tickets/issue / confirm-issue |
Organizer-authorized issuance (off-chain payment already settled) |
POST /v1/tickets/purchase / confirm-purchase |
Fully on-chain primary sale |
POST /v1/tickets/:ticketId/transfer / confirm-transfer |
Direct transfer to another user |
POST /v1/tickets/:ticketId/check-in / confirm-check-in |
Mark used at the gate |
POST /v1/tickets/:ticketId/revoke / confirm-revoke |
Organizer voids a ticket |
POST /v1/tickets/:ticketId/list-resale / confirm-list-resale |
List for resale (price capped by the event's anti-scalping policy on-chain) |
POST /v1/tickets/:ticketId/cancel-resale / confirm-cancel-resale |
Pull a listing |
POST /v1/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 db:seed # optional: populate demo users, org, event & ticket types
npm run start:dev # http://localhost:3000, hot-reloadingThe seed script (prisma/seed.ts) is idempotent — running it more than once
is safe. It creates:
- alice@example.com (attendee) and organiser@example.com (organiser),
both with password
Password123! - A demo organisation Stellar Events Demo
- A draft event StellarFest 2027 with General Admission and VIP ticket types
Seed data can be wiped and recreated with:
npm run db:reset # drops all tables, re-applies migrations, then seedsOr 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.
Errors below were reproduced on a clean checkout of main, not copied from an
issue tracker. Where a fix is a workaround rather than a repair, it says so.
npm ci fails with EUSAGE / "can only install packages when your package.json and package-lock.json are in sync"
package-lock.json is out of date with package.json (it is missing
webpack, and pins stale enhanced-resolve, terser and ajv versions).
Workaround: use npm install instead. This rewrites the lockfile, so do
not commit that unless the point of your change is refreshing dependencies.
main does not currently typecheck. There are 9 errors, most of them from one
cause:
src/pending-tx/pending-tx.service.ts:26: error TS2339:
Property 'pendingTx' does not exist on type 'PrismaService'.
The add_pending_tx migration creates the table, but model PendingTx is
missing from prisma/schema.prisma, so the generated Prisma client has no
pendingTx delegate. The other errors are unrelated strictness issues
(strictPropertyInitialization in a few DTOs and specs, and a
string | string[] param in scanner-device.guard.ts).
This is a real break in main, not a local problem — the same commands run in
CI. You can still work on docs, which is why this section exists. Fixing it
means adding the model back to the schema or dropping the module, and that is
a schema decision rather than a docs change.
Boot fails with Invalid environment configuration: An instance of EnvironmentVariables has failed the validation
The app validates every variable at boot and refuses to start on a missing or malformed value. The message lists all failures at once, which makes it look worse than it is — one unfilled variable is usually the whole cause.
The most common version of this is right after cp .env.example .env, where
every secret is still blank. You must fill in at least:
| Variable | Notes |
|---|---|
DATABASE_URL |
Must point at a reachable Postgres, not just be non-empty. |
JWT_SECRET |
At least 32 characters, or you get a minLength failure. |
SOROBAN_RPC_URL |
Any Soroban RPC endpoint; testnet is https://soroban-testnet.stellar.org. |
TICKETING_CONTRACT_ID |
A deployed C... address. |
PLATFORM_SIGNER_SECRET |
A Stellar secret key, used read-only. |
OFFLINE_SIGNING_* |
Three values; see docs/OFFLINE_VERIFICATION.md. |
The full list with types and defaults is in
docs/CONFIGURATION.md.
npx prisma migrate dev could not authenticate with the host in
DATABASE_URL. The credentials in the URL do not match the server. If you
started the database with docker compose up, the defaults are
stellartickets / stellartickets on port 5432 — anything else means
DATABASE_URL and the running container disagree.
ioredis is an optional dependency, loaded only when you actually select
Redis. Same for RATE_LIMIT_STORE=redis and WEBHOOK_QUEUE_ENABLED=true with
bullmq. Either npm install ioredis (and bullmq), or stay on the default
memory driver, which is correct for single-instance local development.
REDIS_URL is only validated when something needs it. Setting any of
CACHE_DRIVER=redis, RATE_LIMIT_STORE=redis or
WEBHOOK_QUEUE_ENABLED=true makes it mandatory, and leaving it unset fails at
boot rather than at first use.
APP_URL is the CORS allow-list origin, not the API's own address. If the
browser reports a blocked request, APP_URL does not match the origin the
frontend is served from. Note .env.example sets PORT=3000 and
APP_URL=http://localhost:3001 — the API on 3000, the frontend on 3001.
memory is the default for both rate limits and cache, and it is
per-process. With more than one instance, counters and cached entries are not
shared. Set RATE_LIMIT_STORE=redis and CACHE_DRIVER=redis for any
multi-instance deployment — see docs/RATE_LIMITING.md
and docs/CACHING.md.
Optional OpenTelemetry tracing is documented in docs/TRACING.md.
See .env.example and the generated
environment variable table.
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 |
adr/ |
Architecture decision records — the non-custodial design, Postgres as a chain-state cache, and BigInt serialization |
bruno/ |
Generated API collection for exercising every endpoint |
CONFIGURATION.md |
Every environment variable, generated from the validator |
Two things in docs/ are generated and must not be hand-edited:
| Generated from | Output | Regenerate |
|---|---|---|
src/config/env.validation.ts + .env.example |
the env table in docs/CONFIGURATION.md |
npm run docs:env |
src/**/*.controller.ts + DTOs |
docs/bruno/ |
npm run docs:api |
npm run docs:check fails if either has drifted from its source, and CI runs
it on every pull request — so an endpoint added without regenerating, or a
variable added to .env.example but not the validator, fails the build
instead of quietly going undocumented.
See also CONTRIBUTING.md, SECURITY.md,
and CHANGELOG.md.