Skip to content

Latest commit

 

History

History
1302 lines (974 loc) · 40.3 KB

File metadata and controls

1302 lines (974 loc) · 40.3 KB

API reference

Every route below is real: paths come from the controllers, request bodies from the class-validator DTOs, and response bodies from what the services return. When they drift, the generated Bruno collection under bruno/ fails CI, so treat this file and that collection as two views of the same source.

The examples assume:

API=http://localhost:3000
TOKEN=<accessToken from /v1/auth/login>

Conventions

Versioning. All routes live under /v1 (URI versioning, default version 1).

Authentication. Everything except /v1/health, /v1/industries, /v1/auth/*, GET /v1/events, GET /v1/events/:eventId and /v1/stellar/* needs Authorization: Bearer <JWT>. Scanner devices authenticate POST /v1/tickets/:ticketId/scanner-check-in with their own device token instead (see that route).

Bodies. Send Content-Type: application/json. The global ValidationPipe runs with whitelist, forbidNonWhitelisted and transform, so a property that is not in the DTO is a 400, not ignored, and query-string numbers are coerced.

Big numbers. Prices and on-chain ids are BigInt columns (price, chainEventId, chainTicketId, royaltyFee, sellerProceeds). They are strings in JSON both ways: send "2500000", receive "2500000". Prices are in the settlement token's smallest unit.

Dates. ISO-8601 strings in UTC, e.g. 2026-12-01T20:00:00.000Z.

Status codes. POST returns 201 unless noted, GET, PATCH and DELETE return 200. A handler that returns nothing sends an empty body with that status.

Idempotency. Every build endpoint (POST /v1/tickets/issue, purchase, :ticketId/transfer, check-in, revoke, list-resale, cancel-resale, buy-resale) accepts an optional Idempotency-Key header. A retry with the same key from the same user within IDEMPOTENCY_KEY_TTL_MINUTES (default 15) replays the first response instead of building a second envelope.

The build / confirm pair. Each on-chain action is two calls. The build call returns { "unsignedXdr": "AAAAAgAA..." }, the caller's wallet signs it, and the matching confirm-* call takes the result back as signedXdr. Every confirm-* body extends ConfirmSignedTxDto:

field type rules
signedXdr string required, the wallet-signed envelope, unmodified
txHash string optional, 64 hex characters, when the client already knows the hash

The examples below abbreviate XDR as "AAAAAgAA...".

Errors. Four shapes, all JSON:

// 400 from ValidationPipe: message is an array, one entry per failed rule
{ "statusCode": 400, "message": ["toPublicKey must be a valid Stellar public key (G...)", "property foo should not exist"], "error": "Bad Request" }

// 401 / 403 / 404 / 409 thrown by a service
{ "statusCode": 404, "message": "Ticket not found", "error": "Not Found" }
{ "statusCode": 401, "message": "Unauthorized" }

// domain errors carry a stable code
{ "statusCode": 409, "code": "TICKET_TYPE_SOLD_OUT", "message": "This ticket type is sold out" }
{ "statusCode": 400, "code": "LISTING_INACTIVE", "message": "This ticket is not listed for resale" }

// database errors are mapped, never leaked
{ "statusCode": 409, "code": "PRISMA_P2002", "message": "A record with this value already exists" }
{ "statusCode": 500, "code": "INTERNAL_ERROR", "message": "Internal server error" }

See ERROR_HANDLING.md.

Interactive docs. Outside production, Swagger UI is served at /docs.

Health and reference data

GET /v1/health

Public liveness probe.

curl -s "$API/v1/health"
{ "status": "ok", "service": "stellar-tickets-backend" }

GET /v1/industries

Public. The Industry enum used by Organization.industry and Event.category.

curl -s "$API/v1/industries"
["CONCERTS", "FLIGHTS", "SPORTS", "FESTIVALS", "CONFERENCES", "BUS", "MOVIE_THEATERS", "MUSEUMS", "TOURIST_ATTRACTIONS", "PUBLIC_TRANSPORT", "UNIVERSITIES", "CORPORATE_EVENTS"]

Auth

POST /v1/auth/register

Public. Creates an ATTENDEE account and returns a JWT.

field type rules
email string required, valid email
password string required, at least 10 characters
name string required, 1 to 256 characters after trimming
curl -s -X POST "$API/v1/auth/register" \
  -H 'Content-Type: application/json' \
  -d '{"email":"ada@example.com","password":"correct-horse-battery-staple","name":"Ada Lovelace"}'
{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "user": { "id": "9d3a5c1e-7b2f-4e8a-9c6d-1f2e3a4b5c6d", "email": "ada@example.com", "name": "Ada Lovelace", "role": "ATTENDEE" }
}

409 when the email is already registered.

POST /v1/auth/login

Public. Returns 200.

field type rules
email string required, valid email
password string required
curl -s -X POST "$API/v1/auth/login" \
  -H 'Content-Type: application/json' \
  -d '{"email":"ada@example.com","password":"correct-horse-battery-staple"}'

Same body as register. 401 on a wrong email or password.

Users

GET /v1/users/me

curl -s "$API/v1/users/me" -H "Authorization: Bearer $TOKEN"
{
  "id": "9d3a5c1e-7b2f-4e8a-9c6d-1f2e3a4b5c6d",
  "email": "ada@example.com",
  "name": "Ada Lovelace",
  "role": "ATTENDEE",
  "stellarPublicKey": "GBAHZWO3UI3GAHPQCPSW6IR5N7HJ4UBRZNAFMSYB6DAKVNHQDOZIV2YJ",
  "createdAt": "2026-09-27T12:00:00.000Z"
}

PATCH /v1/users/me/wallet

Links the caller's Stellar account. Required before any ticket action.

field type rules
stellarPublicKey string required, G... ed25519 public key with a valid checksum
curl -s -X PATCH "$API/v1/users/me/wallet" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"stellarPublicKey":"GBAHZWO3UI3GAHPQCPSW6IR5N7HJ4UBRZNAFMSYB6DAKVNHQDOZIV2YJ"}'
{ "id": "9d3a5c1e-7b2f-4e8a-9c6d-1f2e3a4b5c6d", "stellarPublicKey": "GBAHZWO3UI3GAHPQCPSW6IR5N7HJ4UBRZNAFMSYB6DAKVNHQDOZIV2YJ" }

409 when another account already uses that wallet.

GET /v1/users/lookup?email=

Resolves a recipient before an issue or transfer. email must be a valid email address.

curl -s "$API/v1/users/lookup?email=grace@example.com" -H "Authorization: Bearer $TOKEN"
{ "id": "5e2b9f0c-1a3d-4b7e-8f9a-0c1d2e3f4a5b", "name": "Grace Hopper", "email": "grace@example.com", "stellarPublicKey": "GD6ROJBYLKQMOW3E7N4M2YBPUHMZD7PL65VRHRMO24BOVSBV5H3BQRSL" }

404 when no account has that email.

Organizations

An organization row looks like this everywhere it is returned:

{
  "id": "2c7e4a10-5b6d-4f8e-9a1b-3c4d5e6f7a8b",
  "name": "Fillmore Live",
  "slug": "fillmore-live",
  "industry": "CONCERTS",
  "stellarAccount": "GDWUSKGGFDI4FRXK5EBTRECZSVQSSWJHHJOGH6JWG3AUMFFMQ435DIAG",
  "logoUrl": null,
  "websiteUrl": "https://fillmore.example.com",
  "deletedAt": null,
  "createdAt": "2026-09-27T12:05:00.000Z",
  "updatedAt": "2026-09-27T12:05:00.000Z"
}

POST /v1/organizations

Creates the organization, makes the caller its OWNER, and promotes an ATTENDEE caller to ORGANIZER.

field type rules
name string required, 2 to 256 characters after trimming
slug string required, lowercase letters, digits and single hyphens, up to 128 characters, unique
industry string required, one of GET /v1/industries
stellarAccount string required, the organizer's G... public key that signs issue, check-in and revoke transactions
logoUrl string optional, URL with protocol
websiteUrl string optional, URL with protocol
curl -s -X POST "$API/v1/organizations" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"name":"Fillmore Live","slug":"fillmore-live","industry":"CONCERTS","stellarAccount":"GDWUSKGGFDI4FRXK5EBTRECZSVQSSWJHHJOGH6JWG3AUMFFMQ435DIAG","websiteUrl":"https://fillmore.example.com"}'

409 when the slug is taken.

GET /v1/organizations/mine

Organizations the caller belongs to, newest first, soft-deleted ones excluded. Returns an array of organization rows.

curl -s "$API/v1/organizations/mine" -H "Authorization: Bearer $TOKEN"

GET /v1/organizations/:id

One organization row. 404 when missing or soft-deleted.

curl -s "$API/v1/organizations/2c7e4a10-5b6d-4f8e-9a1b-3c4d5e6f7a8b" -H "Authorization: Bearer $TOKEN"

DELETE /v1/organizations/:id

Soft delete (members only). Returns the row with deletedAt set.

curl -s -X DELETE "$API/v1/organizations/2c7e4a10-5b6d-4f8e-9a1b-3c4d5e6f7a8b" -H "Authorization: Bearer $TOKEN"

POST /v1/organizations/:id/restore

Clears deletedAt (members only). Returns the row.

curl -s -X POST "$API/v1/organizations/2c7e4a10-5b6d-4f8e-9a1b-3c4d5e6f7a8b/restore" -H "Authorization: Bearer $TOKEN"

Webhooks

All three routes require membership in :organizationId. See WEBHOOKS.md for delivery and signing.

POST /v1/organizations/:organizationId/webhooks

field type rules
url string required, http or https URL
events string optional, event filter, up to 512 characters; defaults to *
secret string optional, up to 256 characters; generated (32 random bytes, hex) when omitted
curl -s -X POST "$API/v1/organizations/2c7e4a10-5b6d-4f8e-9a1b-3c4d5e6f7a8b/webhooks" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"url":"https://example.com/webhook","events":"ticket.issued,ticket.checked_in"}'
{
  "id": "5d7f9b1c-3e4a-4b6c-8d0e-1f2a3b4c5d6e",
  "organizationId": "2c7e4a10-5b6d-4f8e-9a1b-3c4d5e6f7a8b",
  "url": "https://example.com/webhook",
  "secret": "3f9c1b2a4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8",
  "events": "ticket.issued,ticket.checked_in",
  "isActive": true,
  "createdAt": "2026-09-27T12:10:00.000Z",
  "updatedAt": "2026-09-27T12:10:00.000Z"
}

GET /v1/organizations/:organizationId/webhooks

Array of endpoint rows, newest first.

curl -s "$API/v1/organizations/2c7e4a10-5b6d-4f8e-9a1b-3c4d5e6f7a8b/webhooks" -H "Authorization: Bearer $TOKEN"

DELETE /v1/organizations/:organizationId/webhooks/:webhookId

Returns the deleted row. 404 when the endpoint is not in that organization.

curl -s -X DELETE "$API/v1/organizations/2c7e4a10-5b6d-4f8e-9a1b-3c4d5e6f7a8b/webhooks/5d7f9b1c-3e4a-4b6c-8d0e-1f2a3b4c5d6e" \
  -H "Authorization: Bearer $TOKEN"

Events

An event row:

{
  "id": "7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c",
  "organizationId": "2c7e4a10-5b6d-4f8e-9a1b-3c4d5e6f7a8b",
  "name": "Launch Night",
  "category": "CONCERTS",
  "venue": "The Fillmore",
  "startsAt": "2026-12-01T20:00:00.000Z",
  "endsAt": "2026-12-01T23:00:00.000Z",
  "chainEventId": null,
  "publishedTxHash": null,
  "maxResaleMultiplierBps": 11000,
  "royaltyBps": 500,
  "status": "DRAFT",
  "deletedAt": null,
  "createdAt": "2026-09-27T12:15:00.000Z",
  "updatedAt": "2026-09-27T12:15:00.000Z"
}

A ticket type row:

{
  "id": "4b8d2f6a-0c1e-4a3b-9d5f-6e7a8b9c0d1e",
  "eventId": "7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c",
  "name": "General Admission",
  "price": "2500000",
  "quantityTotal": 500,
  "quantityIssued": 0,
  "saleStartsAt": null,
  "saleEndsAt": null,
  "isHidden": false,
  "createdAt": "2026-09-27T12:16:00.000Z"
}

GET /v1/events

Public marketplace listing: PUBLISHED events of live organizations, soonest first, each with its visible ticket types and the organizer's name and slug. Served with Cache-Control: public, max-age=60, s-maxage=300 and an application cache (CACHE_TTL_SECONDS).

curl -s "$API/v1/events"
[
  {
    "id": "7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c",
    "name": "Launch Night",
    "status": "PUBLISHED",
    "chainEventId": "42",
    "publishedTxHash": "abababababababababababababababababababababababababababababababab",
    "ticketTypes": [{ "id": "4b8d2f6a-0c1e-4a3b-9d5f-6e7a8b9c0d1e", "name": "General Admission", "price": "2500000", "quantityTotal": 500, "quantityIssued": 12, "isHidden": false }],
    "organization": { "name": "Fillmore Live", "slug": "fillmore-live" }
  }
]

(Other event columns omitted here for brevity; the response carries the full row.)

GET /v1/events/:eventId

Public. One event row with its organization row embedded. 404 when missing.

curl -s "$API/v1/events/7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c"

GET /v1/organizations/:organizationId/events

Members only. Paginated, newest first, soft-deleted events excluded.

query type rules
status string optional, DRAFT, PUBLISHED or CANCELLED
page integer optional, 1-based, default 1, max 100000
limit integer optional, default 20, 1 to 100
curl -s "$API/v1/organizations/2c7e4a10-5b6d-4f8e-9a1b-3c4d5e6f7a8b/events?status=DRAFT&page=1&limit=20" \
  -H "Authorization: Bearer $TOKEN"
{
  "items": [{ "id": "7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c", "name": "Launch Night", "status": "DRAFT", "ticketTypes": [] }],
  "total": 1,
  "page": 1,
  "limit": 20
}

POST /v1/organizations/:organizationId/events

Members only. Creates a DRAFT event.

field type rules
name string required, 2 to 256 characters after trimming
category string required, an Industry value
venue string required, 1 to 256 characters after trimming
startsAt date required, not more than 60 seconds in the past
endsAt date optional
maxResaleMultiplierBps integer optional, 10000 to 50000, default 11000 (resale capped at 110% of face value)
royaltyBps integer optional, 0 to 2000, default 500 (5% of each resale to the organizer)
curl -s -X POST "$API/v1/organizations/2c7e4a10-5b6d-4f8e-9a1b-3c4d5e6f7a8b/events" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"name":"Launch Night","category":"CONCERTS","venue":"The Fillmore","startsAt":"2026-12-01T20:00:00.000Z","endsAt":"2026-12-01T23:00:00.000Z","maxResaleMultiplierBps":11000,"royaltyBps":500}'

Returns the event row.

POST /v1/events/:eventId/ticket-types

Members only.

field type rules
name string required, 1 to 256 characters after trimming
price string required, digits only, no sign, no leading zeros, up to 39 digits
quantityTotal integer required, positive
saleStartsAt date optional
saleEndsAt date optional, must be after saleStartsAt when both are set
isHidden boolean optional, default false; hidden types are left out of GET /v1/events
curl -s -X POST "$API/v1/events/7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c/ticket-types" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"name":"General Admission","price":"2500000","quantityTotal":500}'

Returns the ticket type row.

POST /v1/events/:eventId/publish

Members only. Build step: reserves a chainEventId and returns the unsigned create_event envelope for the organization's Stellar account to sign. 400 unless the event is a DRAFT without a chain id, 409 until it has at least one ticket type.

curl -s -X POST "$API/v1/events/7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c/publish" -H "Authorization: Bearer $TOKEN"
{ "unsignedXdr": "AAAAAgAA..." }

POST /v1/events/:eventId/confirm-publish

Members only. Relays the signed envelope, reads the event back from the contract to confirm the id and organizer match, then sets status: PUBLISHED and publishedTxHash.

field type rules
signedXdr string required
txHash string optional, 64 hex characters
curl -s -X POST "$API/v1/events/7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c/confirm-publish" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"signedXdr":"AAAAAgAA..."}'

Returns the event row with status: "PUBLISHED", chainEventId: "42" and publishedTxHash set.

POST /v1/events/:eventId/unpublish

Members only. Back to DRAFT. 400 unless PUBLISHED, 409 once any ticket exists.

curl -s -X POST "$API/v1/events/7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c/unpublish" -H "Authorization: Bearer $TOKEN"

DELETE /v1/events/:eventId and POST /v1/events/:eventId/restore

Members only. Soft delete and restore; both return the event row.

curl -s -X DELETE "$API/v1/events/7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c" -H "Authorization: Bearer $TOKEN"
curl -s -X POST "$API/v1/events/7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c/restore" -H "Authorization: Bearer $TOKEN"

Gates

Members of the event's organization only. A gate is a named entrance that check-ins can be attributed to.

POST /v1/events/:eventId/gates

field type rules
name string required, 1 to 256 characters after trimming
curl -s -X POST "$API/v1/events/7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c/gates" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"name":"Front Door"}'
{ "id": "8c0e2a4b-6d1f-4c3e-9a5b-7d8e9f0a1b2d", "eventId": "7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c", "name": "Front Door", "createdAt": "2026-09-27T12:20:00.000Z" }

GET /v1/events/:eventId/gates

Array of gate rows, oldest first.

curl -s "$API/v1/events/7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c/gates" -H "Authorization: Bearer $TOKEN"

DELETE /v1/events/:eventId/gates/:gateId

200 with an empty body. 404 when the gate is not on that event.

curl -s -X DELETE "$API/v1/events/7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c/gates/8c0e2a4b-6d1f-4c3e-9a5b-7d8e9f0a1b2d" -H "Authorization: Bearer $TOKEN"

Promo codes

Create, list and revoke need membership in the event's organization. Preview is for any signed-in buyer.

POST /v1/events/:eventId/promo-codes

field type rules
code string required, 3 to 32 of A-Z, 0-9, _, -; unique per event
discountType string required, PERCENT or FIXED
discountValue integer required, at least 1; basis points (0 to 10000) for PERCENT, a smallest-unit amount for FIXED
maxRedemptions integer optional, at least 1
expiresAt string optional, ISO-8601
isActive boolean optional, default true
curl -s -X POST "$API/v1/events/7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c/promo-codes" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"code":"EARLYBIRD","discountType":"PERCENT","discountValue":2000,"maxRedemptions":100,"expiresAt":"2026-11-01T00:00:00.000Z"}'
{
  "id": "3a5c7e9b-1d2f-4e4a-8c6b-9d0e1f2a3b4c",
  "eventId": "7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c",
  "code": "EARLYBIRD",
  "discountType": "PERCENT",
  "discountValue": 2000,
  "maxRedemptions": 100,
  "redemptionCount": 0,
  "expiresAt": "2026-11-01T00:00:00.000Z",
  "isActive": true,
  "createdAt": "2026-09-27T12:25:00.000Z",
  "updatedAt": "2026-09-27T12:25:00.000Z"
}

GET /v1/events/:eventId/promo-codes

Array of promo code rows, newest first.

curl -s "$API/v1/events/7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c/promo-codes" -H "Authorization: Bearer $TOKEN"

POST /v1/events/:eventId/promo-codes/preview

Prices a code for the caller against a ticket type without redeeming it. 400 with a reason when the code is unknown, inactive, expired, exhausted or already used by the caller.

field type rules
ticketTypeId string required, UUID
code string required, 3 to 32 characters (case-insensitive)
curl -s -X POST "$API/v1/events/7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c/promo-codes/preview" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"ticketTypeId":"4b8d2f6a-0c1e-4a3b-9d5f-6e7a8b9c0d1e","code":"EARLYBIRD"}'
{
  "promoCode": { "id": "3a5c7e9b-1d2f-4e4a-8c6b-9d0e1f2a3b4c", "discountType": "PERCENT", "discountValue": 2000 },
  "discountedPrice": "2000000",
  "discountAmount": "500000"
}

DELETE /v1/events/:eventId/promo-codes/:promoCodeId

Deactivates the code (isActive: false) and returns the row.

curl -s -X DELETE "$API/v1/events/7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c/promo-codes/3a5c7e9b-1d2f-4e4a-8c6b-9d0e1f2a3b4c" -H "Authorization: Bearer $TOKEN"

Scanner devices

Members of the event's organization only. A device token lets a gate scanner confirm check-ins without a staff JWT.

POST /v1/events/:eventId/scanner-devices

field type rules
name string required, 1 to 256 characters after trimming
curl -s -X POST "$API/v1/events/7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c/scanner-devices" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"name":"Front Door Scanner"}'
{
  "device": {
    "id": "0b2d4f6a-8c1e-4a3b-9d5f-6e7a8b9c0d2e",
    "eventId": "7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c",
    "name": "Front Door Scanner",
    "tokenHash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
    "lastUsedAt": null,
    "revokedAt": null,
    "createdAt": "2026-09-27T12:30:00.000Z"
  },
  "token": "scn_4f1c...e2a9"
}

token is shown once; only its hash is stored.

GET /v1/events/:eventId/scanner-devices

Array of device rows (without tokens), newest first.

curl -s "$API/v1/events/7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c/scanner-devices" -H "Authorization: Bearer $TOKEN"

PATCH /v1/events/:eventId/scanner-devices/:deviceId/revoke

Sets revokedAt; the token stops authenticating immediately. Returns the row.

curl -s -X PATCH "$API/v1/events/7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c/scanner-devices/0b2d4f6a-8c1e-4a3b-9d5f-6e7a8b9c0d2e/revoke" \
  -H "Authorization: Bearer $TOKEN"

Waitlist

Routes live under /v1/ticket-types/:ticketTypeId/waitlist. Position is 1-based FIFO among WAITING entries. A waitlist entry row:

{
  "id": "2f4a6c8e-0b1d-4c3e-9a5b-7d8e9f0a1b3e",
  "ticketTypeId": "4b8d2f6a-0c1e-4a3b-9d5f-6e7a8b9c0d1e",
  "userId": "9d3a5c1e-7b2f-4e8a-9c6d-1f2e3a4b5c6d",
  "status": "WAITING",
  "offeredAt": null,
  "offerExpiresAt": null,
  "createdAt": "2026-09-27T12:35:00.000Z",
  "updatedAt": "2026-09-27T12:35:00.000Z"
}

POST /v1/ticket-types/:ticketTypeId/waitlist

Joins (or re-joins) the waitlist. 409 while the type still has capacity or when the caller is already waiting.

curl -s -X POST "$API/v1/ticket-types/4b8d2f6a-0c1e-4a3b-9d5f-6e7a8b9c0d1e/waitlist" -H "Authorization: Bearer $TOKEN"
{ "entry": { "id": "2f4a6c8e-0b1d-4c3e-9a5b-7d8e9f0a1b3e", "status": "WAITING" }, "position": 3 }

DELETE /v1/ticket-types/:ticketTypeId/waitlist

Leaves the waitlist; returns the entry with status: "CANCELLED". 404 when the caller is not WAITING.

curl -s -X DELETE "$API/v1/ticket-types/4b8d2f6a-0c1e-4a3b-9d5f-6e7a8b9c0d1e/waitlist" -H "Authorization: Bearer $TOKEN"

GET /v1/ticket-types/:ticketTypeId/waitlist/me

The caller's entry and position, same shape as join.

curl -s "$API/v1/ticket-types/4b8d2f6a-0c1e-4a3b-9d5f-6e7a8b9c0d1e/waitlist/me" -H "Authorization: Bearer $TOKEN"

GET /v1/ticket-types/:ticketTypeId/waitlist

Organizer view (members only): entries oldest first, each with user: { id, name, email }.

curl -s "$API/v1/ticket-types/4b8d2f6a-0c1e-4a3b-9d5f-6e7a8b9c0d1e/waitlist" -H "Authorization: Bearer $TOKEN"

POST /v1/ticket-types/:ticketTypeId/waitlist/offer-next

Members only. Moves the next count waiting entries to OFFERED with an expiry window.

field type rules
count integer required, 1 to 500
windowMs integer optional, at least 60000; default 24 hours
curl -s -X POST "$API/v1/ticket-types/4b8d2f6a-0c1e-4a3b-9d5f-6e7a8b9c0d1e/waitlist/offer-next" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"count":10,"windowMs":86400000}'

Returns the offered entries with status: "OFFERED", offeredAt and offerExpiresAt set.

Tickets

A ticket row, as returned by every ticket-producing route:

{
  "id": "6f0a2c4e-8b1d-4f3a-9e5c-7d8e9f0a1b2c",
  "eventId": "7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c",
  "ticketTypeId": "4b8d2f6a-0c1e-4a3b-9d5f-6e7a8b9c0d1e",
  "ownerId": "9d3a5c1e-7b2f-4e8a-9c6d-1f2e3a4b5c6d",
  "chainTicketId": "7",
  "seat": "A-12",
  "status": "VALID",
  "qrSecret": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
  "issuedTxHash": "abababababababababababababababababababababababababababababababab",
  "checkedInAt": null,
  "checkInReason": null,
  "checkedInGateId": null,
  "createdAt": "2026-09-27T12:40:00.000Z",
  "updatedAt": "2026-09-27T12:40:00.000Z"
}

status is one of VALID, USED, REVOKED, RESALE. The flows behind the build / confirm pairs are drawn in ARCHITECTURE.md.

GET /v1/tickets/mine?status=

The caller's tickets, newest first, each with event and ticketType embedded. status is optional and must be a TicketStatus value; anything else is a 400.

curl -s "$API/v1/tickets/mine?status=VALID" -H "Authorization: Bearer $TOKEN"
[
  {
    "id": "6f0a2c4e-8b1d-4f3a-9e5c-7d8e9f0a1b2c",
    "status": "VALID",
    "seat": "A-12",
    "chainTicketId": "7",
    "event": { "id": "7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c", "name": "Launch Night", "startsAt": "2026-12-01T20:00:00.000Z" },
    "ticketType": { "id": "4b8d2f6a-0c1e-4a3b-9d5f-6e7a8b9c0d1e", "name": "General Admission", "price": "2500000" }
  }
]

GET /v1/tickets/by-chain/:chainTicketId

Staff lookup by on-chain id (members of the ticket's organization). The row comes with event (including organization) and ticketType. 404 for an unknown or malformed id.

curl -s "$API/v1/tickets/by-chain/7" -H "Authorization: Bearer $TOKEN"

GET /v1/tickets/verify/:qrSecret

Gate verification (members only, rate limited per SCAN_RATE_LIMIT_*, 429 when exceeded). Reads the ticket from the contract, reconciles the cached status, and falls back to the cached row with stale: true when Soroban RPC is unreachable.

curl -s "$API/v1/tickets/verify/c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f" -H "Authorization: Bearer $TOKEN"
{
  "ticketId": "6f0a2c4e-8b1d-4f3a-9e5c-7d8e9f0a1b2c",
  "eventName": "Launch Night",
  "tier": "General Admission",
  "seat": "A-12",
  "ownerName": "Ada Lovelace",
  "status": "VALID",
  "onChainOwner": "GBAHZWO3UI3GAHPQCPSW6IR5N7HJ4UBRZNAFMSYB6DAKVNHQDOZIV2YJ",
  "stale": false
}

GET /v1/tickets/offline-public-keys

Ed25519 public keys (PEM) keyed by key id, for verifying offline tokens at a gate with no connectivity. See OFFLINE_VERIFICATION.md.

curl -s "$API/v1/tickets/offline-public-keys" -H "Authorization: Bearer $TOKEN"
{ "2026-09": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA...\n-----END PUBLIC KEY-----\n" }

GET /v1/tickets/:ticketId/offline-token

Members only. A signed snapshot of the ticket valid for 12 hours.

curl -s "$API/v1/tickets/6f0a2c4e-8b1d-4f3a-9e5c-7d8e9f0a1b2c/offline-token" -H "Authorization: Bearer $TOKEN"
{
  "payload": { "ticketId": "6f0a2c4e-8b1d-4f3a-9e5c-7d8e9f0a1b2c", "chainTicketId": "7", "eventId": "7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c", "status": "VALID", "exp": 1790592000 },
  "kid": "2026-09",
  "signature": "q2Zk...base64url...Ag"
}

Issue

Organizer-authorized issuance for a sale settled off-chain. Members of the event's organization only; the organization's Stellar account signs.

POST /v1/tickets/issue

field type rules
ticketTypeId string required, UUID
toUserId string required, UUID of the recipient account
toPublicKey string required, must equal the recipient's connected wallet
seat string optional, 1 to 64 characters of letters, digits, spaces, -, /, .; defaults to unassigned
curl -s -X POST "$API/v1/tickets/issue" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'Idempotency-Key: issue-7f3a' \
  -d '{"ticketTypeId":"4b8d2f6a-0c1e-4a3b-9d5f-6e7a8b9c0d1e","toUserId":"5e2b9f0c-1a3d-4b7e-8f9a-0c1d2e3f4a5b","toPublicKey":"GD6ROJBYLKQMOW3E7N4M2YBPUHMZD7PL65VRHRMO24BOVSBV5H3BQRSL","seat":"A-12"}'
{ "unsignedXdr": "AAAAAgAA..." }

400 when the event is unpublished, sales are closed, or the wallet does not match; 409 (TICKET_TYPE_SOLD_OUT) when the type is full.

POST /v1/tickets/confirm-issue

Same four fields plus signedXdr and optional txHash.

curl -s -X POST "$API/v1/tickets/confirm-issue" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"ticketTypeId":"4b8d2f6a-0c1e-4a3b-9d5f-6e7a8b9c0d1e","toUserId":"5e2b9f0c-1a3d-4b7e-8f9a-0c1d2e3f4a5b","toPublicKey":"GD6ROJBYLKQMOW3E7N4M2YBPUHMZD7PL65VRHRMO24BOVSBV5H3BQRSL","seat":"A-12","signedXdr":"AAAAAgAA..."}'

Returns the new ticket row. 409 when the seat is already issued for that event.

Purchase

The buyer signs with their own wallet.

POST /v1/tickets/purchase

field type rules
ticketTypeId string required, UUID
seat string optional, same rules as issue
promoCode string optional, 3 to 32 characters; priced at build time, redeemed after confirm
curl -s -X POST "$API/v1/tickets/purchase" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'Idempotency-Key: buy-91c2' \
  -d '{"ticketTypeId":"4b8d2f6a-0c1e-4a3b-9d5f-6e7a8b9c0d1e","promoCode":"EARLYBIRD"}'
{ "unsignedXdr": "AAAAAgAA..." }

POST /v1/tickets/confirm-purchase

field type rules
ticketTypeId string required, UUID
seat string optional
promoCode string optional, 3 to 32 characters
signedXdr string required
txHash string optional, 64 hex characters
curl -s -X POST "$API/v1/tickets/confirm-purchase" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"ticketTypeId":"4b8d2f6a-0c1e-4a3b-9d5f-6e7a8b9c0d1e","promoCode":"EARLYBIRD","signedXdr":"AAAAAgAA..."}'

Returns the new ticket row (ownerId is the caller) and emails a receipt.

Transfer

The current owner signs.

POST /v1/tickets/:ticketId/transfer

field type rules
toUserId string required, UUID
toPublicKey string required, must equal the recipient's connected wallet
curl -s -X POST "$API/v1/tickets/6f0a2c4e-8b1d-4f3a-9e5c-7d8e9f0a1b2c/transfer" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"toUserId":"5e2b9f0c-1a3d-4b7e-8f9a-0c1d2e3f4a5b","toPublicKey":"GD6ROJBYLKQMOW3E7N4M2YBPUHMZD7PL65VRHRMO24BOVSBV5H3BQRSL"}'
{ "unsignedXdr": "AAAAAgAA..." }

403 when the caller does not own the ticket.

POST /v1/tickets/:ticketId/confirm-transfer

Same two fields plus signedXdr and optional txHash.

curl -s -X POST "$API/v1/tickets/6f0a2c4e-8b1d-4f3a-9e5c-7d8e9f0a1b2c/confirm-transfer" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"toUserId":"5e2b9f0c-1a3d-4b7e-8f9a-0c1d2e3f4a5b","toPublicKey":"GD6ROJBYLKQMOW3E7N4M2YBPUHMZD7PL65VRHRMO24BOVSBV5H3BQRSL","signedXdr":"AAAAAgAA..."}'

Returns the ticket row with the new ownerId and status: "VALID".

Check-in

Members of the event's organization; the organization's Stellar account signs.

POST /v1/tickets/:ticketId/check-in

No body.

curl -s -X POST "$API/v1/tickets/6f0a2c4e-8b1d-4f3a-9e5c-7d8e9f0a1b2c/check-in" -H "Authorization: Bearer $TOKEN"
{ "unsignedXdr": "AAAAAgAA..." }

POST /v1/tickets/:ticketId/confirm-check-in

field type rules
signedXdr string required
txHash string optional, 64 hex characters
gateId string optional, UUID of a gate on this event
reason string optional, up to 512 characters, for manual overrides
curl -s -X POST "$API/v1/tickets/6f0a2c4e-8b1d-4f3a-9e5c-7d8e9f0a1b2c/confirm-check-in" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"signedXdr":"AAAAAgAA...","gateId":"8c0e2a4b-6d1f-4c3e-9a5b-7d8e9f0a1b2d"}'

Returns the ticket row with status: "USED", checkedInAt, checkedInGateId and checkInReason.

POST /v1/tickets/:ticketId/scanner-check-in

Same body as confirm-check-in, authenticated with a scanner device token instead of a JWT: either Authorization: Bearer <token> or x-device-token: <token>. 401 when the token is unknown, revoked, or registered for a different event than the ticket's.

curl -s -X POST "$API/v1/tickets/6f0a2c4e-8b1d-4f3a-9e5c-7d8e9f0a1b2c/scanner-check-in" \
  -H "x-device-token: scn_4f1c...e2a9" -H 'Content-Type: application/json' \
  -d '{"signedXdr":"AAAAAgAA...","gateId":"8c0e2a4b-6d1f-4c3e-9a5b-7d8e9f0a1b2d"}'

Returns the ticket row with status: "USED".

Revoke

Members of the event's organization.

POST /v1/tickets/:ticketId/revoke

No body. Returns { "unsignedXdr": "AAAAAgAA..." }.

curl -s -X POST "$API/v1/tickets/6f0a2c4e-8b1d-4f3a-9e5c-7d8e9f0a1b2c/revoke" -H "Authorization: Bearer $TOKEN"

POST /v1/tickets/:ticketId/confirm-revoke

Body is ConfirmSignedTxDto (signedXdr, optional txHash). Returns the ticket row with status: "REVOKED" and writes an audit entry.

curl -s -X POST "$API/v1/tickets/6f0a2c4e-8b1d-4f3a-9e5c-7d8e9f0a1b2c/confirm-revoke" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"signedXdr":"AAAAAgAA..."}'

POST /v1/tickets/events/:eventId/revoke-batch

Off-chain bulk revoke for fraud response.

field type rules
ticketIds string[] required, up to 100 ids of at most 100 characters each, all on this event
curl -s -X POST "$API/v1/tickets/events/7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c/revoke-batch" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"ticketIds":["6f0a2c4e-8b1d-4f3a-9e5c-7d8e9f0a1b2c","0d1e2f3a-4b5c-4d6e-8f7a-8b9c0d1e2f3a"]}'
{ "count": 2 }

400 when any id is missing from the event or more than 100 are sent.

Resale

A resale listing row:

{
  "id": "1e3c5a7b-9d0f-4b2c-8e4a-6f7a8b9c0d1f",
  "ticketId": "6f0a2c4e-8b1d-4f3a-9e5c-7d8e9f0a1b2c",
  "sellerId": "9d3a5c1e-7b2f-4e8a-9c6d-1f2e3a4b5c6d",
  "price": "2750000",
  "status": "ACTIVE",
  "txHash": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd",
  "expiresAt": "2026-11-30T00:00:00.000Z",
  "createdAt": "2026-09-27T13:00:00.000Z",
  "updatedAt": "2026-09-27T13:00:00.000Z"
}

The asking price may not exceed floor(ticketType.price * event.maxResaleMultiplierBps / 10000) (400 otherwise), and a seller may hold at most MAX_ACTIVE_RESALE_LISTINGS_PER_USER active listings (409).

POST /v1/tickets/:ticketId/list-resale

Owner only.

field type rules
price string required, digits only, up to 39 digits
expiresAt string optional, ISO-8601 (stored at confirm time)
curl -s -X POST "$API/v1/tickets/6f0a2c4e-8b1d-4f3a-9e5c-7d8e9f0a1b2c/list-resale" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"price":"2750000"}'
{ "unsignedXdr": "AAAAAgAA..." }

POST /v1/tickets/:ticketId/confirm-list-resale

field type rules
price string required, digits only, up to 39 digits
expiresAt string optional, ISO-8601
signedXdr string required
txHash string optional, 64 hex characters
curl -s -X POST "$API/v1/tickets/6f0a2c4e-8b1d-4f3a-9e5c-7d8e9f0a1b2c/confirm-list-resale" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"price":"2750000","expiresAt":"2026-11-30T00:00:00.000Z","signedXdr":"AAAAAgAA..."}'

Returns the new listing row; the ticket moves to status: "RESALE". 409 when the ticket already has an active listing.

GET /v1/tickets/resale

Marketplace of ACTIVE listings, newest first, cursor paginated.

query type rules
cursor string optional, the previous response's nextCursor
limit integer optional, default 20, 1 to 100
curl -s "$API/v1/tickets/resale?limit=20" -H "Authorization: Bearer $TOKEN"
{
  "items": [
    {
      "id": "1e3c5a7b-9d0f-4b2c-8e4a-6f7a8b9c0d1f",
      "price": "2750000",
      "status": "ACTIVE",
      "expiresAt": "2026-11-30T00:00:00.000Z",
      "ticket": {
        "id": "6f0a2c4e-8b1d-4f3a-9e5c-7d8e9f0a1b2c",
        "seat": "A-12",
        "event": { "id": "7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c", "name": "Launch Night", "royaltyBps": 500 },
        "ticketType": { "id": "4b8d2f6a-0c1e-4a3b-9d5f-6e7a8b9c0d1e", "name": "General Admission", "price": "2500000" }
      },
      "seller": { "name": "Ada Lovelace" },
      "royaltyFee": "137500",
      "sellerProceeds": "2612500"
    }
  ],
  "nextCursor": "MjAyNi0wOS0yN1QxMzowMDowMC4wMDBafDFlM2M1YTdiLTlkMGYtNGIyYy04ZTRhLTZmN2E4YjljMGQxZg",
  "limit": 20
}

royaltyFee = floor(price * royaltyBps / 10000) and sellerProceeds = price - royaltyFee. nextCursor is null on the last page; a malformed cursor is a 400.

GET /v1/tickets/resale/:listingId/price-history

Every asking price the listing has had, oldest first.

curl -s "$API/v1/tickets/resale/1e3c5a7b-9d0f-4b2c-8e4a-6f7a8b9c0d1f/price-history" -H "Authorization: Bearer $TOKEN"
[
  { "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "resaleListingId": "1e3c5a7b-9d0f-4b2c-8e4a-6f7a8b9c0d1f", "price": "2750000", "createdAt": "2026-09-27T13:00:00.000Z" },
  { "id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e", "resaleListingId": "1e3c5a7b-9d0f-4b2c-8e4a-6f7a8b9c0d1f", "price": "2600000", "createdAt": "2026-09-28T09:00:00.000Z" }
]

PATCH /v1/tickets/resale/:listingId/price

Seller only, listing must be ACTIVE. Off-chain: records the new asking price and a history row.

field type rules
price string required, digits only, within the event cap
curl -s -X PATCH "$API/v1/tickets/resale/1e3c5a7b-9d0f-4b2c-8e4a-6f7a8b9c0d1f/price" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"price":"2600000"}'

Returns the listing row with the new price.

POST /v1/tickets/:ticketId/buy-resale

Any signed-in user with a wallet. 400 (LISTING_INACTIVE) unless the ticket is in RESALE.

curl -s -X POST "$API/v1/tickets/6f0a2c4e-8b1d-4f3a-9e5c-7d8e9f0a1b2c/buy-resale" -H "Authorization: Bearer $TOKEN"
{ "unsignedXdr": "AAAAAgAA..." }

POST /v1/tickets/:ticketId/confirm-buy-resale

Body is ConfirmSignedTxDto. Marks the listing SOLD and returns the ticket row with the buyer as ownerId and status: "VALID".

curl -s -X POST "$API/v1/tickets/6f0a2c4e-8b1d-4f3a-9e5c-7d8e9f0a1b2c/confirm-buy-resale" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"signedXdr":"AAAAAgAA..."}'

POST /v1/tickets/:ticketId/cancel-resale

Owner only. Returns { "unsignedXdr": "AAAAAgAA..." }.

curl -s -X POST "$API/v1/tickets/6f0a2c4e-8b1d-4f3a-9e5c-7d8e9f0a1b2c/cancel-resale" -H "Authorization: Bearer $TOKEN"

POST /v1/tickets/:ticketId/confirm-cancel-resale

Body is ConfirmSignedTxDto. Marks the listing CANCELLED and the ticket VALID. Responds 201 with an empty body.

curl -s -X POST "$API/v1/tickets/6f0a2c4e-8b1d-4f3a-9e5c-7d8e9f0a1b2c/confirm-cancel-resale" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"signedXdr":"AAAAAgAA..."}'

POST /v1/tickets/resale/cancel-expired

Sweeps ACTIVE listings whose expiresAt has passed: listing to CANCELLED, ticket back to VALID. The scheduler runs the same job; see RESALE_EXPIRY.md.

curl -s -X POST "$API/v1/tickets/resale/cancel-expired" -H "Authorization: Bearer $TOKEN"
{ "cancelledCount": 3 }

Stellar

GET /v1/stellar/circuit-breaker/metrics

Public. State of the breaker that wraps every Soroban RPC call (CLOSED, OPEN or HALF_OPEN).

curl -s "$API/v1/stellar/circuit-breaker/metrics"
{ "state": "CLOSED", "consecutiveFailures": 0, "totalSuccesses": 1240, "totalFailures": 2, "rejectedCalls": 0 }