MULTI-TENANT EVENT TICKETING BACKEND

Ticketing Platform

A multi-tenant ticketing backend in Go: waiting-room admission, Redis-backed seat holds, transactional order confirmation, and an outbox-pattern email dispatcher. Runs entirely on localhost — no hosted environment.

Base URL & authentication

Fan and admin endpoints require a JWT from /auth/login (Authorization: Bearer <token>). Tenant scope is never taken from a header on authenticated calls — it's embedded in the token itself. Public, pre-auth endpoints (event browsing, joining the queue) instead require an X-Tenant-ID header. The one exception is admin bootstrap, which uses a shared X-Bootstrap-Secret instead of a JWT, since it exists to create the very first admin for a tenant.

Base URL

http://localhost:8080

Every request needs

Authorization: Bearer <access_token>

Architecture & trade-offs

Clean layered design — domain (entities + interfaces, no infrastructure imports), service (business logic, transactions, idempotency), repository (Postgres + Redis implementations), handler (chi HTTP + middleware). Two binaries share the same config and domain code: cmd/api serves requests, cmd/worker runs the background tickers.

Data stores

Postgres is the source of truth for tenants, users, venues, events, inventory, orders, and the notification outbox. Redis holds everything short-lived and high-churn.

  • Postgres — row-level locks + CHECK constraints prevent overselling inventory at the database level, independent of application logic
  • Redis — waiting-room queues and seat holds, both with TTLs; a worker sweep reconciles anything that drifts

Multi-tenancy

Every query is scoped by tenant_id, but the source of that scope differs by endpoint type.

  • Authenticated endpoints (fan or admin) — tenant comes from the JWT, never a client-supplied header, so a valid token for tenant A can't be pointed at tenant B's data
  • Public endpoints (browsing events, joining the queue before login) — tenant comes from X-Tenant-ID, since there's no token yet to carry it

Waiting room & admission

Joining the queue is an HTTP call, but admission out of it deliberately is not.

  • POST /events/{id}/queue enqueues a fan; GET on the same path reports position and whether they're admitted
  • Admission only happens via the worker's ticker (or AdmitNext internally) — there is no HTTP route that jumps the queue
  • HoldSeat requires admitted: true first, so cmd/worker has to be running for the purchase path to complete end to end

Payments

Stripe integration runs in mock mode whenever STRIPE_SECRET_KEY is left empty, which is the expected local setup.

  • Mock mode returns deterministic responses instead of calling out to Stripe, so the full authorize → webhook → paid flow is testable with no real keys
  • Webhook signatures still have to be computed by the caller: hex(HMAC-SHA256(raw_body, STRIPE_WEBHOOK_SECRET)) — no timestamp prefix, unlike Stripe's real t=...,v1=... format

Notifications (outbox pattern)

Order confirmation emails are queued, not sent inline, so a slow or failing email provider can never affect payment confirmation.

  • ConfirmPayment enqueues a notification row in the same Postgres transaction that marks the order paid — both commit or neither does
  • cmd/worker's dispatch loop picks up pending notifications and sends them through a swappable EmailSender (SMTP in real config, a console logger when SMTP_* is unset)

Trade-offs made explicit

  • Redis holds give low-latency contention handling at the cost of eventual consistency with Postgres — accepted because a hold that times out is a far better failure mode than overselling a seat
  • Waiting-room admission is intentionally worker-only with no direct HTTP path, trading a bit of testing convenience for a queue that can't be bypassed
  • Mock payment mode trades payment realism for full local testability — the webhook signature still has to be computed correctly, so the integration is exercised, just not against real Stripe
  • Notifications are fully decoupled via an outbox table rather than sent synchronously — a stalled email provider can never block or roll back a paid order

Run it locally

Everything below runs against localhost only. Two terminals are needed: cmd/api serves the endpoints below; cmd/worker handles waiting-room admission and email dispatch, and the purchase path won't complete without it.

  1. 1

    Start Postgres and Redis

    Bring up local infra with Docker, or point the API at your own Postgres/Redis instances via the env vars in the next step.

    docker-compose up -d
  2. 2

    Configure environment

    Copy the example env file and fill in DATABASE_URL, REDIS_ADDR, JWT_SECRET, JWT_REFRESH_SECRET, and ADMIN_BOOTSTRAP_SECRET. Leave STRIPE_SECRET_KEY empty to run payments in mock mode — that's the intended local setup. SMTP_* is optional; without it, the worker logs emails to the console instead of sending them.

    cp .env.example .env
  3. 3

    Run migrations

    Pulls dependencies and applies every migration up to the current schema.

    go mod tidy && make migrate-up
  4. 4

    Start the API server

    This is the only binary that serves HTTP traffic — every endpoint below talks to it.

    go run ./cmd/api
  5. 5

    Start the worker (second terminal)

    Runs the waiting-room admission ticker and the notification dispatch loop. Without this running, joining the queue will never flip to admitted: true and paid orders will never send a confirmation email.

    go run ./cmd/worker
  6. 6

    Seed a tenant

    There's no endpoint for this yet — it's a direct insert. Keep the returned id; it's the X-Tenant-ID for every public request and the tenant_id for bootstrap.

    INSERT INTO tenants (id, slug, name) VALUES (gen_random_uuid(), 'my-club', 'My Club') RETURNING id;
  7. 7

    Bootstrap the first admin

    Uses the shared X-Bootstrap-Secret rather than a JWT, since no admin exists yet to issue one. See Admin Bootstrap below for the full request.

  8. 8

    Verify

    Confirms the API is up and that it can reach Postgres and Redis.

    curl localhost:8080/healthz && curl localhost:8080/readyz

Health & Status

No auth required. Used for liveness/readiness checks, not by fans or admins.

GET/healthz

Liveness probe

Returns 200 if the process is alive. Does not check Postgres or Redis.

Request
curl http://localhost:8080/healthz

Response

{
  "status": "ok"
}
GET/readyz

Readiness probe

Pings Postgres and Redis. Returns 503 if either is unreachable.

Request
curl http://localhost:8080/readyz

Response

{
  "status": "ready",
  "checks": {
    "postgres": "ok",
    "redis": "ok"
  }
}

Auth

Registration and login are public. Refresh rotates the token pair; the old refresh token is invalidated on use.

POST/api/v1/auth/register

Register a fan

Always creates a fan-role account — there's no role field to set here. Admins are created only via bootstrap.

Body parameters

NameTypeDescription
email*stringMust be unique
password*stringPlaintext over the wire, hashed server-side
first_name*string
last_name*string
Request
curl -X POST http://localhost:8080/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "email": "fan@example.com",
    "password": "hunter2-but-better",
    "first_name": "Alex",
    "last_name": "Morgan"
  }'

Response

{
  "data": {
    "id": "usr_2f1a9c",
    "email": "fan@example.com",
    "role": "fan"
  }
}
POST/api/v1/auth/login

Log in

Works for both fan and admin accounts. Returns an access/refresh pair.

Body parameters

NameTypeDescription
email*string
password*string
Request
curl -X POST http://localhost:8080/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{ "email": "fan@example.com", "password": "hunter2-but-better" }'

Response

{
  "data": {
    "access_token": "eyJhbGciOiJIUzI1NiIs...",
    "refresh_token": "eyJhbGciOiJIUzI1NiIs...",
    "expires_at": "2026-09-17T15:30:00Z"
  }
}
POST/api/v1/auth/refresh

Refresh the token pair

Exchanges a valid refresh token for a new access/refresh pair. The old refresh token is invalidated immediately.

Body parameters

NameTypeDescription
refresh_token*string
Request
curl -X POST http://localhost:8080/api/v1/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{ "refresh_token": "'"$REFRESH_TOKEN"'" }'

Response

{
  "data": {
    "access_token": "eyJhbGciOiJIUzI1NiIs...",
    "refresh_token": "eyJhbGciOiJIUzI1NiIs...",
    "expires_at": "2026-09-17T16:30:00Z"
  }
}
POST/api/v1/auth/logout

Log out

Invalidates the given refresh token server-side.

Body parameters

NameTypeDescription
refresh_token*string
Request
curl -X POST http://localhost:8080/api/v1/auth/logout \
  -H "Content-Type: application/json" \
  -d '{ "refresh_token": "'"$REFRESH_TOKEN"'" }'

Response

{
  "data": { "success": true }
}
GET/api/v1/me

Current user

Returns the identity embedded in the access token.

Request
curl http://localhost:8080/api/v1/me \
  -H "Authorization: Bearer $TOKEN"

Response

{
  "data": {
    "id": "usr_2f1a9c",
    "email": "fan@example.com",
    "role": "fan",
    "tenant_id": "ten_9e2b4f"
  }
}

Admin Bootstrap

Shared-secret, not JWT. This is the only way to create the first admin for a tenant — every other admin action requires an admin JWT.

POST/api/v1/admin/bootstrap

Create the first admin for a tenant

Requires the ADMIN_BOOTSTRAP_SECRET configured in .env, not a token. Run this once per tenant, right after seeding it.

Body parameters

NameTypeDescription
tenant_id*stringFrom the tenant you seeded via SQL
email*string
password*string
first_name*string
last_name*string
Request
curl -X POST http://localhost:8080/api/v1/admin/bootstrap \
  -H "X-Bootstrap-Secret: $ADMIN_BOOTSTRAP_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "tenant_id": "ten_9e2b4f",
    "email": "admin@my-club.local",
    "password": "change-me-locally",
    "first_name": "Site",
    "last_name": "Admin"
  }'

Response

{
  "data": {
    "id": "usr_admin_1a2b",
    "email": "admin@my-club.local",
    "role": "admin",
    "tenant_id": "ten_9e2b4f"
  }
}

Admin — Venues & Events

All require an admin JWT (from logging in as the bootstrapped admin). Tenant scope comes from the token — there's no tenant field or header to set here.

POST/api/v1/admin/venues

Create a venue

Body parameters

NameTypeDescription
name*string
address*string
city*string
country*string
timezone*stringIANA timezone, e.g. Europe/London
capacity*integer
Request
curl -X POST http://localhost:8080/api/v1/admin/venues \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "My Club Stadium",
    "address": "1 Stadium Way",
    "city": "Manchester",
    "country": "UK",
    "timezone": "Europe/London",
    "capacity": 45000
  }'

Response

{
  "data": {
    "id": "ven_1a2b3c",
    "name": "My Club Stadium",
    "capacity": 45000
  }
}
GET/api/v1/admin/venues

List venues

Request
curl http://localhost:8080/api/v1/admin/venues \
  -H "Authorization: Bearer $ADMIN_TOKEN"

Response

{
  "data": [
    { "id": "ven_1a2b3c", "name": "My Club Stadium", "capacity": 45000 }
  ]
}
POST/api/v1/admin/seat-categories

Create a seat category

A reusable label (e.g. "North Stand") that ticket categories attach to per event.

Body parameters

NameTypeDescription
name*string
color*stringHex color used in seat-map UIs
Request
curl -X POST http://localhost:8080/api/v1/admin/seat-categories \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "name": "North Stand", "color": "#3b82f6" }'

Response

{
  "data": { "id": "cat_north_stand", "name": "North Stand", "color": "#3b82f6" }
}
POST/api/v1/admin/events

Create an event

Creates the event shell. No inventory exists until a ticket category is published against it.

Body parameters

NameTypeDescription
venue_id*string
name*string
home_team*string
away_team*string
starts_at*string (RFC3339)
sales_start_at*string (RFC3339)
sales_end_at*string (RFC3339)
Request
curl -X POST http://localhost:8080/api/v1/admin/events \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "venue_id": "ven_1a2b3c",
    "name": "My Club vs Visitors FC",
    "home_team": "My Club",
    "away_team": "Visitors FC",
    "starts_at": "2026-11-01T15:00:00Z",
    "sales_start_at": "2026-10-01T09:00:00Z",
    "sales_end_at": "2026-11-01T13:00:00Z"
  }'

Response

{
  "data": {
    "id": "evt_7f3a9c",
    "name": "My Club vs Visitors FC",
    "status": "draft"
  }
}
POST/api/v1/admin/events/{eventID}/ticket-categories

Attach a ticket category to an event

Links a seat category to this event with a price. Still no purchasable inventory until published.

Path parameters

NameTypeDescription
eventID*string

Body parameters

NameTypeDescription
seat_category_id*string
price_cents*integer
currency*stringISO 4217, e.g. GBP
max_per_order*integer
Request
curl -X POST http://localhost:8080/api/v1/admin/events/evt_7f3a9c/ticket-categories \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "seat_category_id": "cat_north_stand",
    "price_cents": 4500,
    "currency": "GBP",
    "max_per_order": 6
  }'

Response

{
  "data": {
    "id": "etc_4c8e2d",
    "seat_category_id": "cat_north_stand",
    "price_cents": 4500,
    "currency": "GBP"
  }
}
POST/api/v1/admin/events/{eventID}/publish

Publish inventory and flip the event on sale

Generates the actual purchasable inventory rows for a ticket category and moves the event to on_sale.

Path parameters

NameTypeDescription
eventID*string

Body parameters

NameTypeDescription
event_ticket_category_id*string
quantity*integerHow many GA units to generate
Request
curl -X POST http://localhost:8080/api/v1/admin/events/evt_7f3a9c/publish \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "event_ticket_category_id": "etc_4c8e2d", "quantity": 500 }'

Response

{
  "data": {
    "event_id": "evt_7f3a9c",
    "status": "on_sale",
    "generated": 500
  }
}

Fan — Browsing

Public — no JWT required, but every request needs X-Tenant-ID since there's no token yet to carry tenant scope.

GET/api/v1/events

List events

Returns events for the given tenant.

Request
curl http://localhost:8080/api/v1/events \
  -H "X-Tenant-ID: $TENANT_ID"

Response

{
  "data": [
    {
      "id": "evt_7f3a9c",
      "name": "My Club vs Visitors FC",
      "status": "on_sale",
      "starts_at": "2026-11-01T15:00:00Z"
    }
  ]
}
GET/api/v1/events/{id}

Get event details

Includes ticket categories and live available counts.

Path parameters

NameTypeDescription
id*string
Request
curl http://localhost:8080/api/v1/events/evt_7f3a9c \
  -H "X-Tenant-ID: $TENANT_ID"

Response

{
  "data": {
    "id": "evt_7f3a9c",
    "name": "My Club vs Visitors FC",
    "status": "on_sale",
    "ticket_categories": [
      { "id": "etc_4c8e2d", "name": "North Stand", "price_cents": 4500, "available": 487 }
    ]
  }
}

Fan — Waiting Room

Bearer token + X-Tenant-ID both required. Admission happens automatically via cmd/worker within a few seconds — there is no HTTP route to admit yourself.

POST/api/v1/events/{eventID}/queue

Join the waiting room

Path parameters

NameTypeDescription
eventID*string
Request
curl -X POST http://localhost:8080/api/v1/events/evt_7f3a9c/queue \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Tenant-ID: $TENANT_ID"

Response

{
  "data": { "admitted": false, "position": 42 }
}
GET/api/v1/events/{eventID}/queue

Check queue status

Poll until admitted is true — make sure cmd/worker is running in a second terminal, or this never flips.

Path parameters

NameTypeDescription
eventID*string
Request
curl http://localhost:8080/api/v1/events/evt_7f3a9c/queue \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Tenant-ID: $TENANT_ID"

Response

{
  "data": { "admitted": true, "position": 0 }
}

Fan — Inventory & Holds

Bearer token + X-Tenant-ID both required. Requires admitted: true from the queue above.

POST/api/v1/inventory/{id}/hold

Hold an inventory unit

Reserves it for a short TTL. Grab the id from GET /events/{id} in local testing.

Path parameters

NameTypeDescription
id*stringInventory unit id

Body parameters

NameTypeDescription
event_id*string
Request
curl -X POST http://localhost:8080/api/v1/inventory/inv_1a2b/hold \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Tenant-ID: $TENANT_ID" \
  -H "Content-Type: application/json" \
  -d '{ "event_id": "evt_7f3a9c" }'

Response

{
  "data": { "hold_token": "hld_9e2b4f", "expires_at": "2026-09-17T15:38:00Z" }
}
POST/api/v1/inventory/{id}/release

Release a hold early

Returns the unit to available inventory before its TTL expires naturally.

Path parameters

NameTypeDescription
id*string

Body parameters

NameTypeDescription
hold_token*string
Request
curl -X POST http://localhost:8080/api/v1/inventory/inv_1a2b/release \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Tenant-ID: $TENANT_ID" \
  -H "Content-Type: application/json" \
  -d '{ "hold_token": "hld_9e2b4f" }'

Response

{
  "data": { "released": true }
}

Fan — Orders

Bearer token + X-Tenant-ID both required. Confirming payment happens via the Stripe webhook below, not a client-facing endpoint.

POST/api/v1/orders

Create an order from active holds

idempotency_key makes retries safe. Each item references an inventory_id + the hold_token that reserved it.

Body parameters

NameTypeDescription
idempotency_key*string
items*array[{ inventory_id, hold_token }]
Request
curl -X POST http://localhost:8080/api/v1/orders \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Tenant-ID: $TENANT_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "idempotency_key": "'"$(uuidgen)"'",
    "items": [{ "inventory_id": "inv_1a2b", "hold_token": "hld_9e2b4f" }]
  }'

Response

{
  "data": {
    "id": "ord_4c8e2d",
    "status": "pending_payment",
    "total_cents": 4500
  }
}
POST/api/v1/orders/{id}/authorize

Authorize payment

In mock mode (STRIPE_SECRET_KEY unset) this returns a deterministic client_secret rather than calling Stripe.

Path parameters

NameTypeDescription
id*string
Request
curl -X POST http://localhost:8080/api/v1/orders/ord_4c8e2d/authorize \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Tenant-ID: $TENANT_ID"

Response

{
  "data": { "client_secret": "pi_mock_3Nq_secret_abc123" }
}
GET/api/v1/orders

List your own orders

Request
curl http://localhost:8080/api/v1/orders \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Tenant-ID: $TENANT_ID"

Response

{
  "data": [
    { "id": "ord_4c8e2d", "status": "paid", "total_cents": 4500 }
  ]
}
GET/api/v1/orders/{id}

Get a single order

Check status: paid here after firing the webhook below.

Path parameters

NameTypeDescription
id*string
Request
curl http://localhost:8080/api/v1/orders/ord_4c8e2d \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Tenant-ID: $TENANT_ID"

Response

{
  "data": { "id": "ord_4c8e2d", "status": "paid", "total_cents": 4500 }
}

Webhooks

No Bearer token — authenticated instead by a computed Stripe-Signature header. This is the call that actually flips an order to paid and enqueues its confirmation notification.

POST/webhooks/stripe

Stripe payment webhook

In mock mode the signature is hex(HMAC-SHA256(raw_body, STRIPE_WEBHOOK_SECRET)) — no timestamp prefix, unlike real Stripe's t=...,v1=... format. Compute it outside Postman with openssl, or with a pre-request script.

Request
BODY='{"type":"payment_intent.succeeded","data":{"object":{"id":"pi_mock_3Nq"}}}'
SIG=$(openssl dgst -sha256 -hmac "$STRIPE_WEBHOOK_SECRET" <<< "$BODY" | sed 's/^.* //')

curl -X POST http://localhost:8080/webhooks/stripe \
  -H "Stripe-Signature: $SIG" \
  -H "Content-Type: application/json" \
  -d "$BODY"

Response

{
  "data": { "received": true }
}