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:8080Every 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
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
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
Run migrations
Pulls dependencies and applies every migration up to the current schema.
go mod tidy && make migrate-up - 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
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
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
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
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.
/healthzLiveness probe
Returns 200 if the process is alive. Does not check Postgres or Redis.
curl http://localhost:8080/healthzResponse
{
"status": "ok"
}/readyzReadiness probe
Pings Postgres and Redis. Returns 503 if either is unreachable.
curl http://localhost:8080/readyzResponse
{
"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.
/api/v1/auth/registerRegister a fan
Always creates a fan-role account — there's no role field to set here. Admins are created only via bootstrap.
Body parameters
| Name | Type | Description |
|---|---|---|
email* | string | Must be unique |
password* | string | Plaintext over the wire, hashed server-side |
first_name* | string | |
last_name* | string |
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"
}
}/api/v1/auth/loginLog in
Works for both fan and admin accounts. Returns an access/refresh pair.
Body parameters
| Name | Type | Description |
|---|---|---|
email* | string | |
password* | string |
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"
}
}/api/v1/auth/refreshRefresh the token pair
Exchanges a valid refresh token for a new access/refresh pair. The old refresh token is invalidated immediately.
Body parameters
| Name | Type | Description |
|---|---|---|
refresh_token* | string |
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"
}
}/api/v1/auth/logoutLog out
Invalidates the given refresh token server-side.
Body parameters
| Name | Type | Description |
|---|---|---|
refresh_token* | string |
curl -X POST http://localhost:8080/api/v1/auth/logout \
-H "Content-Type: application/json" \
-d '{ "refresh_token": "'"$REFRESH_TOKEN"'" }'Response
{
"data": { "success": true }
}/api/v1/meCurrent user
Returns the identity embedded in the access token.
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.
/api/v1/admin/bootstrapCreate 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
| Name | Type | Description |
|---|---|---|
tenant_id* | string | From the tenant you seeded via SQL |
email* | string | |
password* | string | |
first_name* | string | |
last_name* | string |
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.
/api/v1/admin/venuesCreate a venue
Body parameters
| Name | Type | Description |
|---|---|---|
name* | string | |
address* | string | |
city* | string | |
country* | string | |
timezone* | string | IANA timezone, e.g. Europe/London |
capacity* | integer |
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
}
}/api/v1/admin/venuesList venues
curl http://localhost:8080/api/v1/admin/venues \
-H "Authorization: Bearer $ADMIN_TOKEN"Response
{
"data": [
{ "id": "ven_1a2b3c", "name": "My Club Stadium", "capacity": 45000 }
]
}/api/v1/admin/seat-categoriesCreate a seat category
A reusable label (e.g. "North Stand") that ticket categories attach to per event.
Body parameters
| Name | Type | Description |
|---|---|---|
name* | string | |
color* | string | Hex color used in seat-map UIs |
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" }
}/api/v1/admin/eventsCreate an event
Creates the event shell. No inventory exists until a ticket category is published against it.
Body parameters
| Name | Type | Description |
|---|---|---|
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) |
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"
}
}/api/v1/admin/events/{eventID}/ticket-categoriesAttach a ticket category to an event
Links a seat category to this event with a price. Still no purchasable inventory until published.
Path parameters
| Name | Type | Description |
|---|---|---|
eventID* | string |
Body parameters
| Name | Type | Description |
|---|---|---|
seat_category_id* | string | |
price_cents* | integer | |
currency* | string | ISO 4217, e.g. GBP |
max_per_order* | integer |
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"
}
}/api/v1/admin/events/{eventID}/publishPublish 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
| Name | Type | Description |
|---|---|---|
eventID* | string |
Body parameters
| Name | Type | Description |
|---|---|---|
event_ticket_category_id* | string | |
quantity* | integer | How many GA units to generate |
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.
/api/v1/eventsList events
Returns events for the given tenant.
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"
}
]
}/api/v1/events/{id}Get event details
Includes ticket categories and live available counts.
Path parameters
| Name | Type | Description |
|---|---|---|
id* | string |
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.
/api/v1/events/{eventID}/queueJoin the waiting room
Path parameters
| Name | Type | Description |
|---|---|---|
eventID* | string |
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 }
}/api/v1/events/{eventID}/queueCheck queue status
Poll until admitted is true — make sure cmd/worker is running in a second terminal, or this never flips.
Path parameters
| Name | Type | Description |
|---|---|---|
eventID* | string |
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.
/api/v1/inventory/{id}/holdHold an inventory unit
Reserves it for a short TTL. Grab the id from GET /events/{id} in local testing.
Path parameters
| Name | Type | Description |
|---|---|---|
id* | string | Inventory unit id |
Body parameters
| Name | Type | Description |
|---|---|---|
event_id* | string |
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" }
}/api/v1/inventory/{id}/releaseRelease a hold early
Returns the unit to available inventory before its TTL expires naturally.
Path parameters
| Name | Type | Description |
|---|---|---|
id* | string |
Body parameters
| Name | Type | Description |
|---|---|---|
hold_token* | string |
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.
/api/v1/ordersCreate an order from active holds
idempotency_key makes retries safe. Each item references an inventory_id + the hold_token that reserved it.
Body parameters
| Name | Type | Description |
|---|---|---|
idempotency_key* | string | |
items* | array | [{ inventory_id, hold_token }] |
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
}
}/api/v1/ordersList your own orders
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 }
]
}/api/v1/orders/{id}Get a single order
Check status: paid here after firing the webhook below.
Path parameters
| Name | Type | Description |
|---|---|---|
id* | string |
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.
/webhooks/stripeStripe 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.
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 }
}