Support Portal (support.raxx.app) — Operations Runbook
Status: Pre-launch
Epic: #651
Architecture doc: docs/architecture/support-raxx-app.md
Flag: FLAG_SUPPORT_PORTAL_API (default off)
Required Heroku config vars
Set these before flipping FLAG_SUPPORT_PORTAL_API=1. All values must be
silenced to avoid leaking credentials in Heroku's deploy output:
heroku config:set FREESCOUT_API_TOKEN=<value> >/dev/null 2>&1
heroku config:set FREESCOUT_BASE_URL=https://tickets.raxx.app >/dev/null 2>&1
heroku config:set FREESCOUT_SUPPORT_MAILBOX_ID=<value> >/dev/null 2>&1
SESSION_KEY is already required by #1028 (session auth middleware) and
does not need to be set again. Verify it exists:
heroku config:get SESSION_KEY | wc -c # should print > 1
Where to find each value
| Config var | Source |
|---|---|
FREESCOUT_API_TOKEN |
Infisical /raxx/prod/freescout-api-token. See ADR-0046 for the secret path. |
FREESCOUT_BASE_URL |
https://tickets.raxx.app (fixed for prod). Use http://localhost:9999 in local dev only — the service auto-stubs this in FLASK_ENV=testing. |
FREESCOUT_SUPPORT_MAILBOX_ID |
Retrieve after #707 lands: GET https://tickets.raxx.app/api/mailboxes with Authorization: Bearer <FREESCOUT_API_TOKEN>. Use the id field of the "Support" mailbox. |
SESSION_KEY |
Already set (see #1028). |
Migrations
Three migrations must be applied before the flag is flipped on:
backend_v2/db/migrations/005_support_customer_map.sql
backend_v2/db/migrations/006_support_audit_log.sql
backend_v2/db/migrations/007_support_pending_submissions.sql
The migration runner applies them in filename order. Confirm they ran:
SELECT version, applied_at FROM schema_migrations
WHERE version LIKE '00%support%'
ORDER BY version;
Rollback
Flip the flag off immediately if issues are detected:
heroku config:set FLAG_SUPPORT_PORTAL_API=0 >/dev/null 2>&1
All /api/v1/support/* routes return 501 Not Implemented with
{"error": "support_portal_api_disabled"} within one dyno restart.
Email intake (Postmark → support mailbox) is unaffected — customers can
still submit via email to support@raxx.app.
To fully roll back the schema (no customer data in these tables before GA):
DROP TABLE IF EXISTS support_pending_submissions;
DROP TABLE IF EXISTS support_audit_log;
DROP TABLE IF EXISTS support_customer_map;
GDPR / DSR procedure (Open Question OQ-3)
When a customer requests erasure:
-
Raptor's GDPR DSR flow deletes the
usersrow. TheON DELETE CASCADEconstraint onsupport_customer_mapandsupport_pending_submissionsautomatically removes all Raptor-side rows. -
Manual step (operator): FreeScout conversations that contain PII in the ticket body are NOT automatically deleted. The operator must: a. Open
tickets.raxx.appas an admin. b. Search for all conversations with the customer's email. c. Delete or anonymize each conversation within the GDPR 30-day window. -
The
support_audit_logrows forcustomer_id = <deleted_user_id>are purged by the nightly GDPR DSR job (same job that handles the main audit_log).
Note: OQ-3 from the design doc is formally documented here. Attorney guidance on whether Raptor-automated FreeScout deletion is required is pending. Until that decision is made, this manual step is the DSR procedure.
FreeScout webhook (support portal events)
A second FreeScout webhook endpoint is provisioned for support portal events:
- Endpoint:
POST /api/webhooks/freescout/support - Secret: Infisical
/MooseQuest/freescout/FREESCOUT_SUPPORT_WEBHOOK_SECRET(envprod) — note: ADR-0046 specifies/raxx/prod/freescout-support-webhook-secret, but that path/folder does not exist anywhere in the live vault (verified 2026-07-21) and no other Raptor secret uses it —/MooseQuest/freescout/is the actual, working convention for every other FreeScout-related secret (FREESCOUT_API_KEY,FREESCOUT_AUDIT_WEBHOOK_SECRET, etc.), including the sibling secret rotated the same day. Provisioned here for consistency; ADR-0046 should be corrected or a follow-up migration should relocate secrets to match it. - Heroku config var:
FREESCOUT_SUPPORT_WEBHOOK_SECRET— set on bothraxx-api-stagingandraxx-api-prod(2026-07-21).
This endpoint is separate from the status-page webhook at /api/webhooks/freescout.
Configure it in FreeScout: Admin → Webhooks → Add webhook → URL:
https://api.raxx.app/api/webhooks/freescout/support
2026-07-21 — registered as webhook ID 5 (events convo.customer.reply.created,
convo.agent.reply.created — FreeScout has no conversation.replied event; see
docs/ops/runbooks/freescout.md §"Failure mode K"). Synthetic HMAC-SHA256/hex signed requests
verify correctly (200) on both staging and prod; a live E2E test (real FreeScout operator reply)
confirmed the delivery reaches Raptor and is rejected 401 invalid signature — FreeScout's
ApiWebhooks module signs with HMAC-SHA1/base64 using a secret derived from its own APP_KEY,
which cannot be made to match Raptor's HMAC-SHA256/hex check against an arbitrary vault secret,
regardless of what FREESCOUT_SUPPORT_WEBHOOK_SECRET is set to. This is a receiver-code bug,
not a secret-provisioning gap — see Failure mode K for the required fix before this webhook can
deliver real operator-reply-email notifications end-to-end.
Smoke test
After flipping the flag on:
# 1. Confirm the flag-off path returns 501 (before flip)
curl -s https://api.raxx.app/api/v1/support/tickets \
-H "Authorization: Bearer test-token"
# Expected: {"error": "support_portal_api_disabled"} HTTP 501
# 2. Get a valid session token (passkey flow)
SESSION_TOKEN="<bearer-token-from-POST-/api/sessions>"
# 3. Verify health endpoint returns 200
curl -s -H "Authorization: Bearer $SESSION_TOKEN" \
https://api.raxx.app/api/v1/support/health
# Expected: {"status": "ok"}
# 4. Verify session endpoint returns customer info
curl -s -H "Authorization: Bearer $SESSION_TOKEN" \
https://api.raxx.app/api/v1/support/session
# Expected: {"customer_id": "...", "freescout_linked": true|false}
# 5. Verify ticket list returns empty array (not 404) for a new customer
curl -s -H "Authorization: Bearer $SESSION_TOKEN" \
https://api.raxx.app/api/v1/support/tickets
# Expected: {"tickets": [], "total": 0} (or real tickets if the customer has any)
Wiring the SPA to real endpoints (VITE_USE_MOCK_DATA)
The support portal SPA at support.raxx.app (frontend/support/) ships with a
mock-data layer gated by the VITE_USE_MOCK_DATA environment variable set in the
CF Pages build settings.
VITE_USE_MOCK_DATA |
FLAG_SUPPORT_PORTAL_API |
Result |
|---|---|---|
true (default dev) |
any | SPA uses mock fixtures — no Raptor calls |
false |
false (default) |
SPA calls Raptor; all routes return 501 |
false |
true |
SPA calls Raptor live endpoints — real data |
To wire the SPA to real data without a frontend deploy:
- Set
VITE_USE_MOCK_DATA=falsein the CF Pages build environment variable for theraxx-supportproject (CF dashboard → Pages → raxx-support → Settings → Environment variables → Production). - Trigger a new CF Pages build to pick up the env var change.
- Flip the flag on Raptor:
heroku config:set FLAG_SUPPORT_PORTAL_API=1 >/dev/null 2>&1
No additional configuration change is needed in the frontend code.
Kill-switch (mock data): Set VITE_USE_MOCK_DATA=true in CF Pages env and
rebuild. This reverts the SPA to mock data without touching Raptor.
Monitoring
support_customer_maprow count should grow as customers authenticate.support_audit_logaccumulates on each support request. Check for unexpectederror_code = 'privacy_violation'rows.support_pending_submissionsrows withdelivered_at IS NULLandsubmitted_at < NOW() - INTERVAL '24h'indicate FreeScout was unreachable for an extended period — investigate and notify affected customers.