Malaysia Team Onboarding — POS / Payment Integration
One-stop page for developing against ticketing's POS/Payment/Email
integration config: how to run it, how auth actually works, working curl
sequences, and — just as important — what's genuinely solid vs. what's a
known gap. Written 2026-09-16, verified against source and against a real
running local stack, not against other docs.
If anything here disagrees with the code, trust the code — see
Middleware and pkg/middleware/auth_middleware.go,
domain/catalogue/routes/routes.go.
1. Getting a stack running
cd Quickstart
make malaysia # points Postgres + Zitadel at the shared dev environment;
# CoreAPI, AuthAPI, SpatioViewAdmin, ticketcms still run locally
or, fully local (no shared dependency, recommended if you don't need to see the shared team's live data):
cd Quickstart
docker compose --env-file .env \
-f compose/infrastructure/networks-volumes.yml \
-f compose/infrastructure/postgres.yml \
-f compose/infrastructure/seaweedfs.yml \
-f compose/access/AuthAPI.yml \
-f compose/services/CoreAPI.yml \
-f compose/infrastructure/nats.yml \
-f compose/infrastructure/zitadel.yml \
-f compose/access/TellyID.yml \
-f compose/services/SpatioAdmin.yml \
-f compose/services/TicketCMS.yml \
up -d --build
The Makefile's spatio: target is broken — don't use it
make spatio currently references the legacy, uncloned ticketadmin/ticketing repos ($(TICKETUI) instead of $(SPATIO) in the Makefile). Use the explicit docker compose command above, or make coreapi plus SpatioAdmin.yml/TicketCMS.yml manually, until that's fixed.
SpatioViewAdmin: http://localhost:5160. CoreAPI: http://localhost:3000. AuthAPI: http://localhost:3002.
2. The account you'll use
| Field | Value |
|---|---|
| Company code | PlayTellyPlatform |
| Username | admin@playtelly |
| Password | 0 |
This is a real, working account — ORG_ADMIN on the one platform org that exists in this environment. It passes every workspace permission check automatically (see §3), so you don't need to be added to any specific workspace to use it.
Ask for your own account if you're doing real development, not just a smoke test
This is currently one shared dev credential. It's fine to start with, but if more than one person on your side is working against this environment, ask for your own account instead — same permission level, but a real audit trail of who changed what.
You'll also need the workspace ID for whichever environment you're pointed at — every scoped route takes it as a URL segment:
| Environment | SPATIO_TICKETING_WORKSPACE_ID |
|---|---|
Shared dev (make malaysia) |
8b9b89ba-f784-4493-a95d-0f4d7c484685 |
Local (.env) |
8c13fde2-7739-4f74-8ee5-17d046a3b93f |
(Every environment pins its own UUID — check .env/.env.shared-dev for the one you're actually running against, don't hardcode either of these.)
3. How auth actually works (the short version)
Full trace with file:line references lives in Middleware. Short version:
POST /api/v1/loginwith{username, password, company_code}→ AuthAPI verifies against Zitadel directly (your password never touches AuthAPI's own database).- AuthAPI resolves
company_code→ local org → Zitadel org → confirms membership → issues an RS256 JWT with{org_id, org_slug, role}baked in, set as an HttpOnlyaccess_tokencookie. - Every CoreAPI admin route checks that JWT (
ProtectedRS256), then checks a workspace permission against the:workspaceIdin the URL (RequireWorkspacePermission). Your account'sORG_ADMINrole short-circuits this — it passes on any workspace under its org without needing a specificworkspace_membersrow.
One org, one workspace ID per environment, no org switcher. If you ever need a different org/workspace, that's a different login (different company_code), not a header or a switch.
4. curl walkthrough — login → CRUD the integration config
Working end-to-end, run against the shared dev environment (swap in WS for your environment):
BASE=https://<shared-dev-coreapi-host> # or http://localhost:3000 for local
AUTH=https://<shared-dev-authapi-host> # or http://localhost:3002 for local
WS=8b9b89ba-f784-4493-a95d-0f4d7c484685 # shared dev workspace ID
# 1. Log in — save cookies, we need access_token out of them
curl -s -i -X POST "$AUTH/api/v1/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin@playtelly","password":"0","company_code":"PlayTellyPlatform"}' \
-c cookies.txt -o login_body.json
ACCESS=$(awk -F'\t' '/access_token/ {print $NF}' cookies.txt)
# 2. List products + their POS/Pay/Email configured status
curl -s "$BASE/api/ticketGroups/$WS/integrations" \
-H "Authorization: Bearer $ACCESS" | python3 -m json.tool
# 3. Read one product's full config (404 if none exists yet)
curl -s "$BASE/api/ticketGroups/$WS/1/integration" \
-H "Authorization: Bearer $ACCESS" | python3 -m json.tool
# 4. Create/update (upsert) — every field is required, email fields included
# (email fields are stored but have NO runtime effect — see §6)
curl -s -X PUT "$BASE/api/ticketGroups/$WS/1/integration" \
-H "Authorization: Bearer $ACCESS" -H "Content-Type: application/json" \
-d '{
"posBaseUrl":"https://your-pos-host","posQrEndpoint":"/qr","posTokenEndpoint":"/tok","posTicketEndpoint":"/tix","posApiUsername":"u","posApiPassword":"p",
"payGatewayUrl":"https://your-pay-host","payPaymentEndpoint":"/pay","payRedflowEndpoint":"/rf","payBankListEndpoint":"/banks","payApiKey":"k","payAgToken":"t",
"emailUsername":"tester@example.com","emailPassword":"e","emailFrom":"tester@example.com","emailClientId":"","emailClientSecret":"","emailRefreshToken":""
}' | python3 -m json.tool
# 5. Delete it (added 2026-09-16 — did not exist before)
curl -s -X DELETE "$BASE/api/ticketGroups/$WS/1/integration" \
-H "Authorization: Bearer $ACCESS"
Field names are camelCase (not snake_case) — the most common mistake when hand-writing these requests. emailUsername/emailFrom must additionally be valid email-format strings or the request 400s with a FieldErrors object naming exactly what's wrong.
Full request/response shapes: catalogue.yaml (OpenAPI — rendered inline on the Catalogue domain page, under "Integration Config").
5. What's genuinely solid vs. what to be careful with
Solid — build on this:
- Login → RS256 → workspace permission chain (§3) — real, tested, works end-to-end.
- The integration config's Create/Read/Update/Delete — all four verified working against a live stack.
- TLS certificate verification is now on for every outbound call CoreAPI makes to your POS/payment endpoints (fixed 2026-09-16 — previously
InsecureSkipVerify: true, inherited from the original legacy port, not something anyone here deliberately weakened). If your actual POS/JohorPay endpoint serves a self-signed or internal-CA cert, connections will now fail with a TLS error instead of silently working insecurely — that's expected; talk to us if it happens and we'll sort out trusting your CA rather than turning verification back off.
Be careful with:
- Workspace permission ≠ resource ownership, on this specific table. The system checks "does this caller have permission in the workspace named in the URL" but does not yet check "does this ticket group actually belong to that workspace." Harmless with one workspace in play (today's reality); would be a real cross-tenant issue with two. See the "Known gap" section on the Middleware page.
- No genuinely pluggable POS/payment provider abstraction. The config table lets you point the same hardcoded Zoo/KSM and JohorPay protocols at new URLs/credentials — it does not let you plug in a different vendor's API shape. That requires new client code (
pkg/external/zoo_api_client.go-equivalent), not just filling in the form. - The email fields do nothing. Fully CRUD-able, validated the same as the working fields, zero consumers at runtime.
- No delete endpoint existed until today — if you pulled this repo before 2026-09-16,
DELETE .../integrationis new to you.
6. Docs to actually keep open while working
- Middleware — the auth/permission trace in full.
- Catalogue domain — full route tables, including the ones this page doesn't repeat (ticket group basicInfo/details/variants/etc).
catalogue.yaml— OpenAPI spec, importable into Postman/Insomnia.CoreAPI/domain/catalogue/dto/integration_config/integration_config_request.go— the actual validation rules, if a request keeps 400ing and the reason isn't obvious fromFieldErrors.