Raxx · internal docs

internal · gated ↑ index

Ticketing System — Build vs. Buy Analysis

Status: Decision-pending (awaiting Kristerpher's call on DQ-1 through DQ-6) Author: software-architect Date: 2026-05-02 Trigger: Kristerpher, 2026-05-03 — "Can we look at what creating our own would look like… if we're going to have to pay for FreeScout."


1. TL;DR Recommendation

Short answer: buy the FreeScout modules now, revisit build at the 200-ticket milestone.

The one-time module cost is $148 ($69 Webhooks + $79 OAuth Login). Annualised against the Lightsail bill that is ~$268/yr all-in — a rounding error at this stage. Building a purpose-fit ticketing system in the console's visual vocabulary is genuinely attractive and architecturally sound, but it carries a 15–20 day agent-time estimate spread over 4–6 calendar weeks, during which FreeScout remains the live system anyway. At current ticket volume (sub-50) there is no operational pain that $148 cannot buy today. The build path makes strong sense once two conditions are true: (a) the console "Investigate" integration is a daily-use feature and a bare REST call to FreeScout feels brittle, or (b) FreeScout's annual module/maintenance drag crosses $300/yr. Neither is true yet. If you commit to the build, start after MBT v1 and the Velvet GA are out the door — not in parallel.


2. Cost Comparison

Pricing anchors (2026-05 rates)

Line item Source / notes
AWS Lightsail micro_3_0 $10/mo = $120/yr
FreeScout Webhooks module $69 one-time
FreeScout OAuth Login (Tagras) $79 one-time
Heroku Basic dyno $7/mo = $84/yr
Heroku Eco dyno (shared, sleeps) $5/mo = $60/yr (not suitable for webhook ingest)
Heroku Postgres Essential-0 $5/mo = $60/yr (10k rows)
Heroku Postgres Essential-1 $9/mo = $108/yr (1M rows — right tier for a small ticketing DB)
Postmark inbound processing Free up to 100 msg/mo; $1.50/mo per additional 1k inbound
Postmark outbound Included in existing Postmark account (already paying)
Agent time at notional $150/hr Used for TCO; adjust to your own rate

Buy path — keep FreeScout

Horizon Capital Annual recurring Cumulative
12 months $148 modules (one-time) $120 Lightsail $268
24 months $0 incremental $120 Lightsail $388
36 months $0 incremental (possible major version upgrade) $120 Lightsail $508

Hidden costs: FreeScout is PHP 8.2 + Laravel 5.5 (frozen, no active development on the CE core). Each OS/PHP security patch cycle on the Lightsail instance is a manual SSH + apt-upgrade operation, ~30 min operator time/quarter. Over 36 months that is ~360 min / 6 hr. At $150/hr: $900 in maintenance drag not counted above. Real cumulative 36-month TCO: ~$1,408.

Time to ship: Zero. SMTP already works. Buying the Webhooks module unlocks the POST /api/conversations endpoint for the console "Investigate" integration within hours of purchase.

Build path — custom ticketing blueprint

Agent-time estimate: 15–20 days (120–160 hrs), plus operator review and iteration cycles (+20%).

Phase Agent hrs At $150/hr
M1 — Inbound email loop 24–40 $3,600–$6,000
M2 — Outbound reply 16–24 $2,400–$3,600
M3 — Console Investigate integration 8–16 $1,200–$2,400
M4 — Customer record + thread merge 16–24 $2,400–$3,600
M5 — Agent assignment + statuses + notes 16–24 $2,400–$3,600
M6 — Search + tags 24–32 $3,600–$4,800
Operator review cycles (~20%) 21–32 $3,150–$4,800
Total build cost 125–192 hrs $18,750–$28,800

Infrastructure once built:

Line item Annual
Heroku Basic dyno (raxx-tickets-prod) $84
Heroku Postgres Essential-1 $108
Postmark inbound (at current volume) ~$18
Total recurring ~$210/yr
Horizon Capital (build) Annual recurring Cumulative
12 months $18,750–$28,800 $210 $18,960–$29,010
24 months same $210 $19,170–$29,220
36 months same $210 $19,380–$29,430

Break-even against buy: The build cost is 37–57x the 36-month buy TCO. The build is justified by capability and brand cohesion, not by cost savings.

Time to ship MVP (M1–M5): 4–6 calendar weeks assuming agent-driven development with daily operator check-ins. FreeScout must remain live during this window.


3. MVP Architecture (if we build)

3.1 Codename

Three candidates, all moose-vocabulary and currently unused:

Candidate Meaning in context Fit
Echo What calls travel through — the thread of a conversation bouncing between parties Strong. Evokes the back-and-forth of support. Short, clear.
Trail What you follow back — the conversation history, breadcrumbs to resolution Strong. Maps well to audit trail concept.
Hoof What a moose leaves behind — evidence of presence, impression in the ground Evocative but opaque to newcomers; trail and echo read faster

Recommendation: Echo. It names the core metaphor (a conversation that bounces back), it's a single syllable, it pairs cleanly with the other names (Raptor handles trades; Echo handles support), and it does not collide with any existing codenames.

3.2 Surface placement

Three options:

Option Pros Cons
A. Blueprint inside raxx-console-prod Zero new Heroku apps. Same auth layer (CF Access + existing session). Shares console_audit_log. Cheapest infra. Shares Postgres. Console dyno handles more traffic. Ticketing UI is accessible wherever console is accessible — no independent access control. Operational coupling: ticketing outage = console outage.
B. Blueprint inside raxx-api-prod Shares Raptor's Postgres. Postmark inbound webhook goes to a known stable host. Conceptually wrong: Raptor is a customer-facing API, not an ops surface. Blurs the console/API boundary. RBAC complications.
C. New Heroku app raxx-tickets-prod Clean separation. Independent deploy and scale. Own Postgres. Own dyno. Fault-isolated. Hostname choices are fully flexible. CF Access protects it independently. $84+$108 = $192/yr additional Heroku spend. New app = new deploy pipeline, new Infisical paths, new CF Access application.

Recommendation: Option A — blueprint inside raxx-console-prod. Rationale: at MVP volume (sub-200 tickets), the operational coupling is acceptable and the infra cost advantage is real. The console Postgres already holds audit logs and admin state; tickets are operational data that belongs in the same ownership tier. If Echo outgrows the console dyno, extracting it to its own app is a well-understood migration (split blueprint + DNS swap). Keep Option C as the v2 extraction path.

3.3 Data model

erDiagram
    CUSTOMERS {
        uuid id PK
        text email UK
        text display_name
        timestamptz first_seen_at
        timestamptz last_seen_at
        timestamptz deleted_at
    }

    TICKETS {
        uuid id PK
        text number UK
        uuid customer_id FK
        uuid assigned_to FK
        text status
        text subject
        text[] tags
        timestamptz created_at
        timestamptz updated_at
        timestamptz closed_at
    }

    THREADS {
        uuid id PK
        uuid ticket_id FK
        text direction
        text body_html
        text body_text
        text postmark_message_id
        text in_reply_to_message_id
        boolean is_internal_note
        uuid authored_by FK
        timestamptz created_at
    }

    AGENTS {
        uuid id FK
        text role
    }

    ATTACHMENTS {
        uuid id PK
        uuid thread_id FK
        text filename
        text s3_key
        bigint size_bytes
        timestamptz created_at
    }

    CUSTOMERS ||--o{ TICKETS : "opens"
    TICKETS ||--o{ THREADS : "contains"
    TICKETS }o--o| AGENTS : "assigned to"
    THREADS ||--o{ ATTACHMENTS : "has"
    THREADS }o--o| AGENTS : "authored by"

Notes: - AGENTS is a logical view over the existing admins table in the console — no separate table needed at MVP. - status enum: open, pending (waiting on customer), resolved, closed. - direction enum: inbound (customer email), outbound (agent reply), internal (note). - number is a human-readable ticket number (e.g., TKT-0042), auto-incremented via a Postgres sequence. - deleted_at on CUSTOMERS supports GDPR erasure — soft-delete the customer row, scrub email and display_name to [deleted].

3.4 Email ingest — sequence diagram

sequenceDiagram
    participant Customer
    participant Postmark as Postmark<br/>(inbound processing)
    participant Echo as Echo<br/>(POST /tickets/inbound)
    participant DB as Console Postgres
    participant AuditLog as console_audit_log

    Customer->>Postmark: Email to support@raxx.app
    Postmark->>Echo: POST /tickets/inbound<br/>(JSON: From, Subject, TextBody, HtmlBody,<br/>MessageID, InReplyTo, attachments)
    Echo->>Echo: Validate Postmark webhook token<br/>(header X-Postmark-Signature or shared secret)
    Echo->>DB: Upsert CUSTOMERS by email
    alt InReplyTo matches existing thread
        Echo->>DB: Append THREADS row (direction=inbound)
    else New conversation
        Echo->>DB: INSERT TICKETS + THREADS (direction=inbound)
    end
    Echo->>AuditLog: INSERT action=tickets.inbound<br/>target_type=ticket, target_id=uuid
    Echo-->>Postmark: 200 OK
    Echo->>Echo: Notify assigned agent (ops@ email via Postmark)

Threading key: match InReplyTo or References header against threads.postmark_message_id. Postmark preserves these headers faithfully for IMAP-fetched inbound.

3.5 Outbound reply — sequence diagram

sequenceDiagram
    participant Agent as Agent browser
    participant Echo
    participant DB as Console Postgres
    participant Postmark as Postmark<br/>(Server API)
    participant Customer

    Agent->>Echo: POST /tickets/{id}/reply<br/>(body_html, is_internal)
    Echo->>Echo: CF Access + role check (ops/superadmin)
    alt is_internal = true
        Echo->>DB: INSERT THREADS (direction=internal)
        Echo-->>Agent: 201 Created (no email sent)
    else is_internal = false
        Echo->>DB: INSERT THREADS (direction=outbound)
        Echo->>Postmark: POST /email<br/>From: support@raxx.app<br/>ReplyTo: support@raxx.app<br/>In-Reply-To: last thread MessageID
        Postmark->>Customer: Email reply
        Postmark-->>Echo: 200 + Postmark MessageID
        Echo->>DB: UPDATE threads.postmark_message_id
    end
    Echo->>DB: INSERT audit_log (action=tickets.reply)
    Echo-->>Agent: 201 Created

Bounce handling: subscribe to Postmark's Bounce webhook. On hard bounce: mark tickets.customer_email_bounced = true, surface warning in ticket UI, halt further outbound to that address.

3.6 UI surface

Two hostname options:

Option URL Pros Cons
Subfolder in console console.raxx.app/tickets/ Single app, single deploy, same session cookie URL reveals internal tooling structure
Subdomain tickets.raxx.app/ Current FreeScout hostname — zero DNS change required for cutover Requires CF Access application update if on a different Heroku app

Recommendation: console.raxx.app/tickets/ if Echo lives inside the console blueprint (Option A). The subdomain tickets.raxx.app currently points to the Lightsail FreeScout instance and is needed until cutover; once cutover is ready, a single DNS CNAME edit moves it to Heroku. At that point, keep both hostnames working (redirect tickets.raxx.appconsole.raxx.app/tickets/) for bookmarked links.

Visual vocabulary: Tailwind dark theme, same tile/card language as _status_grid.html, mobile-first 390 px breakpoint. Confidence Engine voice in empty states (no Bandz for ops-internal surfaces — Bandz lives in Antlers). Ticket list renders with same HTMX partial-swap pattern as the status grid.

3.7 Auth

CF Access protects console.raxx.app at the CDN edge — inherited automatically if Echo is a console blueprint. Role check inside the view: ops or superadmin (from admins.role, existing model). No customer-facing login surface; customers interact only by replying to email.

3.8 Audit

Every ticket state change, every reply, every assignment writes a row to console_audit_log with:

Retention: follows the existing console audit log retention policy (90 days active, 7 years cold storage per GDPR DPA-ready logging requirement).


4. Build Milestones

All estimates are agent-time. Calendar time assumes one focused sprint per milestone plus operator review.

Milestone Scope Agent-time Calendar
M1 — Inbound email loop Postmark inbound webhook → /tickets/inbound endpoint → ticket + thread row → read-only ticket list view in console 3–5 days Week 1
M2 — Outbound reply Reply form in UI → POST to API → Postmark send → thread record + audit row; bounce webhook handler 2–3 days Week 2
M3 — Console Investigate integration POST /tickets/from-snapshot endpoint accepting deploy_id + surface_id + audit-log JSON snapshot; wires to "Investigate" button on degraded status tiles 1–2 days Week 2
M4 — Customer record + thread merge Customer upsert on inbound; In-Reply-To threading logic; merge-ticket UI (two open threads from same email → one ticket) 2–3 days Week 3
M5 — Agent assignment + statuses + internal notes Assignment dropdown, status transitions (open → pending → resolved → closed), internal note flag, email notification to assigned agent 2–3 days Week 4
M6 — Search + tags Postgres tsvector full-text index on threads.body_text + customers.email + tickets.subject; tag input on ticket; filter bar on list view 3–4 days Weeks 5–6
Total 13–20 days 4–6 weeks

M1–M3 are the minimum viable handoff. At M3 the system can receive inbound email, send replies, and accept structured payloads from the console status grid. M4–M6 harden it for sustained daily use.


5. Migration Plan

While building (M1–M5)

FreeScout remains live at tickets.raxx.app. All real inbound email routes through FreeScout. The Echo blueprint under console.raxx.app/tickets/ runs in dark mode — the only traffic it receives is synthetic test emails and console "Investigate" payloads once M3 is live.

At M5 cutover

  1. History decision (see DQ-3 below): at current volume (sub-50 tickets), the recommended path is drop history and start fresh. Export a PDF/CSV of FreeScout conversations to Google Drive for the record, then point all traffic to Echo. Importing FreeScout's MariaDB schema into Echo's Postgres is a non-trivial ETL with diminishing returns at this volume.

  2. DNS swap: Update Cloudflare DNS tickets.raxx.app CNAME from the Lightsail static IP to raxx-console-prod.herokuapp.com (or keep console.raxx.app/tickets/ as the canonical URL and make tickets.raxx.app a redirect). Either is a single Terraform change.

  3. Postmark inbound route: Update the Postmark inbound domain rule for support@raxx.app from the current SMTP fetch setup to a direct webhook to https://console.raxx.app/tickets/inbound. This is a one-line change in the Postmark dashboard.

  4. Decommission FreeScout: Stop the Lightsail instance. Keep the Terraform state and last snapshot for 30 days, then destroy. Saves $120/yr.

Rollback

If Echo has a critical bug post-cutover: revert the Cloudflare DNS CNAME (single Terraform apply), revert the Postmark inbound webhook URL. FreeScout instance is still running and still has its data. The window where both systems are live simultaneously means no data is at risk during rollback.


6. Risk Register

Risk Likelihood Impact Mitigation
Email threading breaks (customer reply opens new ticket) Medium Medium Match on In-Reply-To + References headers AND on customer email + ticket-number pattern in subject line (e.g., [TKT-0042]). Postmark preserves headers; most email clients do too.
Bounce loop — agent reply bounces, system retries Low High Hard-stop on bounce: set customer_email_bounced flag, surface in UI, no auto-retry. Manual operator override only.
Spam ingest floods the DB Low (CF Access + Postmark spam score) Medium Postmark scores inbound; filter SpamScore > 5.0. Add a DB-level rate limit: max 10 tickets/24h from a single email address before auto-quarantine.
Postgres LIKE search too slow at scale Low at MVP Low Use pg_trgm trigram index from M6. Migration is additive (CREATE INDEX CONCURRENTLY) — no downtime.
Operator burnout from no auto-close Medium Low M6 stretch: cron job auto-closes tickets with pending status and no reply in 14 days. Customer gets a notification email.
Console dyno pressure from webhook bursts Low Medium Postmark inbound is fire-and-forget; webhook handler must respond 200 within 5s or Postmark retries. Keep handler lightweight (DB write + audit row only; no outbound email in the webhook path). Outbound notifications fire from a deferred worker.
GDPR: customer PII in ticket body Certain High DSR erasure path: soft-delete CUSTOMERS row, scrub email and display_name to [deleted], replace thread body_html/body_text with [erased per DSR YYYY-MM-DD]. Attachments: delete from S3, mark attachments.erased_at. Audit log entries: keep action metadata, scrub any PII in payload_redacted.

7. Decision Matrix

The following are binary decisions that must be resolved before any sub-cards are filed. These are Kristerpher's calls.

DQ-1 — Surface placement Options: (A) Blueprint inside raxx-console-prod [recommended], (B) Blueprint inside raxx-api-prod, (C) New Heroku app raxx-tickets-prod. Default if no answer: A.

DQ-2 — Customer portal Should customers have a portal (log in, see ticket history)? Recommendation: NO for MVP. Customers reply by email only. Re-evaluate at 500 tickets. Default if no answer: No portal.

DQ-3 — Cutover history strategy Options: (a) Drop FreeScout history, start fresh [recommended — sub-50 tickets, low value to migrate], (b) ETL FreeScout MariaDB export → Echo Postgres (adds ~2–3 agent days), (c) Archive FreeScout PDF/CSV to Google Drive + start fresh. Recommendation: (c) — archive PDF/CSV to Drive for reference, start fresh in Echo. Default if no answer: (c).

DQ-4 — Hostname at cutover Options: (a) Keep tickets.raxx.app [zero customer-visible change], (b) Move to console.raxx.app/tickets/ as canonical and redirect tickets.raxx.app, (c) New subdomain support.raxx.app or inbox.raxx.app. Recommendation: (b) — console.raxx.app/tickets/ canonical, tickets.raxx.app redirects. Keeps the console as the single ops domain. No confusion about which surface has authority. Default if no answer: (b).

DQ-5 — Codename Options: Echo [recommended], Trail, Hoof. This is cosmetic but affects every sub-card title, doc reference, and Heroku app name. Default if no answer: Echo.

DQ-6 — Mobile-first breakpoint Options: (a) 390 px mobile-first [recommended — consistent with console UX direction and Kristerpher's phone use], (b) 1280 px desktop-first. Default if no answer: (a) 390 px mobile-first.


8. Security + GDPR Checklist


9. Open Questions

These are unresolved and block sub-card filing if the build path is chosen.


10. Honest Recommendation

If you ship Echo weekend-and-evenings over 4–6 weeks while keeping the main focus on MBT v1 and Velvet GA, you end up with a ticketing surface that looks and feels like the console — dark theme, Confidence Engine voice, mobile-first, one-click "Investigate" that does not leave the ops context. You own the code, you own the schema, you own the DSR erasure path, and you never wrestle with PHP on a Lightsail box again. The brand coherence is real and will matter as the customer base grows.

But 4–6 weeks is not free. Every week on Echo is a week not on MBT v1 or Velvet polish. At sub-50 tickets and zero customer-facing integrations, the operational gap between FreeScout+Webhooks-module and a custom system is near zero. The $148 module cost is genuinely trivial.

The honest call: buy the Webhooks module this week ($69), get the console "Investigate" button working against FreeScout, and set a trigger: when the console Investigate button is used more than 5 times in a week, or when FreeScout causes more than 2 ops incidents in a quarter, greenlight the Echo build. You will not regret the $69. You will regret starting the build while MBT v1 is half-done.