Raxx · internal docs

internal · gated ↑ index

Runbook: FreeScout custom fields for status.raxx.app integration

System: FreeScout helpdesk — tickets.raxx.app Sub-card: #605 (component_tag + public_status), #631 (incident_severity) Parent epic: #581 (status.raxx.app) Architecture reference: docs/architecture/status-raxx-app.md — section 5b Created: 2026-04-28 UTC Last reviewed: 2026-04-28 UTC


Overview

Three custom fields must exist in FreeScout before the webhook receiver (#607) can wire ticket events to status.raxx.app state changes. This runbook covers:

  1. What each field does and why
  2. Automated creation via scripts/freescout/create-status-fields.sh
  3. Manual creation via FreeScout admin UI (fallback when API is unavailable)
  4. Slug stability contract (what #607 reads)
  5. Validation: create a test ticket, set all three fields, confirm JSON shape
  6. How to extend (adding a new surface to the component_tag dropdown)

1. Field definitions

1a. Canonical field slugs

These are the slugs the webhook receiver reads. They must not change after first deploy. If a slug changes, update the webhook receiver in #607 and this runbook simultaneously, and document the migration in CHANGELOG.md.

Display name Slug Type Required Options / notes
Component Tag component_tag Select Yes One option per surface_id (see §1b)
Public Status public_status Text No Free text, max 280 chars
Incident Severity incident_severity Select No degraded / partial / down

Documentation convention: In prose and code comments, custom field slugs are sometimes written with a cf_ prefix (e.g., cf_component_tag) to distinguish them from standard FreeScout fields. The actual slug values stored in FreeScout and returned by the API do NOT include the cf_ prefix. When writing the webhook receiver, use the bare slug (component_tag, not cf_component_tag).

1b. component_tag options

Each option value is a surface id from config/status-surfaces.yaml. The list below is authoritative as of this runbook's creation date. When a new surface is added to the YAML, follow the procedure in §5.

# Owned — Sites
raxx-app
getraxx-com
app-raxx-app
demo-raxx-app
docs-raxx-app
internal-docs-raxx-app
console-raxx-app
tickets-raxx-app
status-raxx-app

# Owned — Email flows
email-outbound-transactional
email-inbound-support

# Owned — Internal services
api-service
vault-raxx-app
ci-runners

# Upstream 3P
cloudflare
heroku-platform
email-delivery
billing-payment
workspace
secrets-management

# Downstream 3P
broker-connectivity
market-data-feed
trade-execution

1c. incident_severity options

Value Public label State entered (per ADR-0030)
degraded Degraded DEGRADED (or current if worse)
partial Partial outage PARTIAL
down Service disruption DOWN

Default when field is unset or empty: degraded.

The resolve_ticket_state() logic (see docs/architecture/status-raxx-app.md section 5d) uses this value. If the prober has already moved a surface to DOWN, a ticket with incident_severity=degraded does not downgrade the state — the worst state wins.


2. Automated creation

2a. Prerequisites

Requirement Notes
FreeScout "API" module Enabled at /manage/modules
FreeScout admin API key Profile page → "API Access Key"
curl + jq Standard; install via package manager
yq (optional) Reads surface list from YAML; falls back to hardcoded list if absent

The admin API key must belong to an account with the Admin role. A regular agent or viewer key will return HTTP 403 on custom-field creation.

The API key is a secret. Do not commit it. Store it in Infisical at path /ops/freescout/api_key (staging) or /ops/freescout/api_key (prod), or export it in your shell session.

2b. Run the script

# Source the key from Infisical or set manually
export FREESCOUT_BASE_URL=https://tickets.raxx.app
export FREESCOUT_API_KEY=<your-admin-api-key>

bash scripts/freescout/create-status-fields.sh

Expected output (first run):

==> Verifying FreeScout API connectivity...
    OK — API reachable.

==> Checking custom-fields API support...
    OK — custom-fields API is available.

==> Reading surface IDs from config/status-surfaces.yaml...
    Found 23 surfaces.

==> Creating field: component_tag ("Component Tag")
    Created — ID: 1
    Slug: component_tag

==> Creating field: public_status ("Public Status")
    Created — ID: 2
    Slug: public_status

==> Creating field: incident_severity ("Incident Severity")
    Created — ID: 3
    Slug: incident_severity

================================================================
  FreeScout custom field setup complete
================================================================

  Field           ID                    Slug
  --------------- --------------------- -------------------------
  Component Tag   1                     component_tag
  Public Status   2                     public_status
  Incident Severity 3                   incident_severity
...

Re-running after a partial failure is safe — existing fields are skipped.

2c. Slug mismatch warning

If FreeScout generates a slug that differs from the expected value (e.g., component_tag_1 due to a prior field with the same name), the script prints a warning and exits 0 (the field was created; only the slug differs).

Resolution: 1. Delete the unexpected field in the FreeScout admin UI. 2. Re-run the script. 3. If FreeScout persistently auto-generates a different slug, rename the field to the exact expected name and confirm the slug matches. 4. Update CANONICAL_SLUGS in scripts/freescout/validate-status-fields.sh (the validation script created in §3).


3. Manual creation (fallback)

Use this path when: - The FreeScout "API" module is not installed - The API key is unavailable and must be rotated first - FreeScout is behind Cloudflare Access and service-token auth is not set up

3a. Navigate to custom fields

  1. Log in to tickets.raxx.app as an admin account.
  2. Open the Manage menu in the top navigation.
  3. Click Custom Fields (URL: https://tickets.raxx.app/manage/customfields).

3b. Create "Component Tag"

  1. Click New Custom Field (top right of the custom fields page).
  2. Fill in the form: - Name: Component Tag - Type: Dropdown - Required: Yes (check the box)
  3. Add options. Each option value must be one surface ID from the list in §1b. FreeScout's dropdown field editor typically shows an "Add option" input. Enter each ID on its own line or one at a time: raxx-app getraxx-com app-raxx-app demo-raxx-app docs-raxx-app internal-docs-raxx-app console-raxx-app tickets-raxx-app status-raxx-app email-outbound-transactional email-inbound-support api-service vault-raxx-app ci-runners cloudflare heroku-platform email-delivery billing-payment workspace secrets-management broker-connectivity market-data-feed trade-execution
  4. Click Save.
  5. After saving, click the field name to view its detail page. The URL path will contain the field ID (e.g., /manage/customfields/1/edit). Note this ID — use it for the API validation step in §4.

3c. Create "Public Status"

  1. Click New Custom Field.
  2. Fill in: - Name: Public Status - Type: Single-line text (or "Text" depending on FreeScout version) - Required: No
  3. Click Save.

3d. Create "Incident Severity"

  1. Click New Custom Field.
  2. Fill in: - Name: Incident Severity - Type: Dropdown - Required: No
  3. Add options (one per entry): degraded partial down
  4. Click Save.

3e. Confirm slugs after manual creation

After creating all three fields manually, confirm the slugs via the API:

curl -s \
  -H "Authorization: Bearer ${FREESCOUT_API_KEY}" \
  -H "Accept: application/json" \
  "https://tickets.raxx.app/api/custom-fields" \
  | jq '._embedded.custom_fields[] | {id, name, slug, type}'

Expected output:

{ "id": 1, "name": "Component Tag",     "slug": "component_tag",     "type": "select" }
{ "id": 2, "name": "Public Status",     "slug": "public_status",     "type": "text"   }
{ "id": 3, "name": "Incident Severity", "slug": "incident_severity", "type": "select" }

If a slug does not match, rename the field (Delete + recreate with the exact name) until the slug matches. The slug is derived from the name by FreeScout on creation; it cannot be edited directly in the UI. Once confirmed, record the IDs and slugs below in §3f.

3f. Confirmed field IDs and slugs (fill in after creation)

Field ID Slug
Component Tag ___ component_tag
Public Status ___ public_status
Incident Severity ___ incident_severity

Fill in the IDs after creation. The webhook receiver in #607 uses slugs, not IDs, so the ID is for human reference only.


4. Validation — test ticket

4a. Create a test ticket via the FreeScout API

export FREESCOUT_BASE_URL=https://tickets.raxx.app
export FREESCOUT_API_KEY=<your-admin-api-key>

# Get mailbox IDs (need one for the ticket)
curl -s \
  -H "Authorization: Bearer ${FREESCOUT_API_KEY}" \
  "https://tickets.raxx.app/api/mailboxes" \
  | jq '._embedded.mailboxes[] | {id, name}'

# Create test ticket (replace MAILBOX_ID and CUSTOMER_EMAIL)
curl -s -X POST \
  -H "Authorization: Bearer ${FREESCOUT_API_KEY}" \
  -H "Content-Type: application/json" \
  "https://tickets.raxx.app/api/conversations" \
  -d '{
    "subject": "[TEST] Status fields validation — do not action",
    "mailboxId": MAILBOX_ID,
    "type": "email",
    "status": "active",
    "customer": {"email": "CUSTOMER_EMAIL"},
    "threads": [{
      "type": "customer",
      "text": "This is a test ticket for validating status.raxx.app custom fields. Do not action."
    }],
    "customFields": [
      {"id": COMPONENT_TAG_FIELD_ID, "value": "app-raxx-app"},
      {"id": PUBLIC_STATUS_FIELD_ID, "value": "Test: login flow is intermittently failing. We are investigating."},
      {"id": SEVERITY_FIELD_ID,      "value": "degraded"}
    ]
  }' \
  | jq '{id, number, status}'

Note the test ticket ID from the response. Record it here: TEST_TICKET_ID = ___

4b. Retrieve the ticket and confirm custom fields

TEST_TICKET_ID=<id from above>

curl -s \
  -H "Authorization: Bearer ${FREESCOUT_API_KEY}" \
  "https://tickets.raxx.app/api/conversations/${TEST_TICKET_ID}" \
  | jq '.customFields'

Expected JSON shape (this is what the webhook receiver in #607 will parse):

[
  {
    "id": 1,
    "name": "Component Tag",
    "slug": "component_tag",
    "value": "app-raxx-app"
  },
  {
    "id": 2,
    "name": "Public Status",
    "slug": "public_status",
    "value": "Test: login flow is intermittently failing. We are investigating."
  },
  {
    "id": 3,
    "name": "Incident Severity",
    "slug": "incident_severity",
    "value": "degraded"
  }
]

Verify: - slug values match the canonical slugs in §1a exactly - value for component_tag is app-raxx-app (a valid surface_id) - value for public_status is the text you entered - value for incident_severity is degraded

4c. Webhook receiver JSON path

The webhook receiver at POST /api/webhooks/freescout reads the custom fields from the webhook payload, not the conversation API. The webhook payload shape is:

{
  "event": "ticket.updated",
  "ticket": {
    "id": TEST_TICKET_ID,
    "status": "active",
    "customFields": [
      {
        "id": 1,
        "name": "Component Tag",
        "slug": "component_tag",
        "value": "app-raxx-app"
      },
      {
        "id": 2,
        "name": "Public Status",
        "slug": "public_status",
        "value": "Test: login flow is intermittently failing. We are investigating."
      },
      {
        "id": 3,
        "name": "Incident Severity",
        "slug": "incident_severity",
        "value": "degraded"
      }
    ]
  }
}

The receiver extracts field values by slug (not by ID):

def get_custom_field(fields: list, slug: str, default=None):
    for f in fields:
        if f.get("slug") == slug:
            return f.get("value", default)
    return default

component_tag     = get_custom_field(custom_fields, "component_tag")
public_status     = get_custom_field(custom_fields, "public_status")
incident_severity = get_custom_field(custom_fields, "incident_severity", "degraded")

4d. Close the test ticket

After validation, close the test ticket to keep the inbox clean:

curl -s -X PATCH \
  -H "Authorization: Bearer ${FREESCOUT_API_KEY}" \
  -H "Content-Type: application/json" \
  "https://tickets.raxx.app/api/conversations/${TEST_TICKET_ID}" \
  -d '{"status": "closed"}'

5. Extending — adding a new surface to component_tag

When a new surface is added to config/status-surfaces.yaml:

  1. Add the new surface entry to config/status-surfaces.yaml with a stable id.
  2. Add the new surface id to the HARDCODED_SURFACE_IDS array in scripts/freescout/create-status-fields.sh to keep the hardcoded fallback in sync.
  3. Add the new option to the FreeScout component_tag dropdown:

Via admin UI: - Navigate to https://tickets.raxx.app/manage/customfields - Click the "Component Tag" field - Click "Add option" and enter the new surface ID - Save

Via API (if available): ```bash # Get current field definition curl -s \ -H "Authorization: Bearer ${FREESCOUT_API_KEY}" \ "https://tickets.raxx.app/api/custom-fields/${COMPONENT_TAG_FIELD_ID}" \ | jq '{id, name, options}'

# Update field with new option appended # FreeScout requires sending the full options list on update curl -s -X PATCH \ -H "Authorization: Bearer ${FREESCOUT_API_KEY}" \ -H "Content-Type: application/json" \ "https://tickets.raxx.app/api/custom-fields/${COMPONENT_TAG_FIELD_ID}" \ -d '{ "options": [ <...existing options...>, {"value": "new-surface-id"} ] }' ```

  1. Update this runbook's §1b option list with the new surface ID.
  2. Open a PR with both the YAML change and the runbook update.

6. FreeScout API authentication notes

FreeScout uses token-based API auth. Two methods are supported:

Bearer header (preferred)

curl -H "Authorization: Bearer ${FREESCOUT_API_KEY}" \
     "https://tickets.raxx.app/api/custom-fields"

Query parameter (fallback for older FreeScout versions)

curl "https://tickets.raxx.app/api/custom-fields?token=${FREESCOUT_API_KEY}"

If your FreeScout version returns HTTP 401 for Bearer auth, try the query param method.

Cloudflare Access layer

tickets.raxx.app is behind Cloudflare Access (email allowlist: kris@moosequest.net). Scripts that hit the FreeScout API from a non-browser context must either: - Run from a session with a valid CF Access cookie, or - Use a Cloudflare Access service token (see Cloudflare Zero Trust dashboard → Access → Service Auth). Pass the service token as: bash -H "CF-Access-Client-Id: <client_id>" \ -H "CF-Access-Client-Secret: <client_secret>"

The create-status-fields.sh script assumes the FreeScout URL is reachable directly (operator machine has CF Access cookie or service token header is pre-configured). If running from CI, add CF Access service token headers.


7. Troubleshooting

API returns 404 for /api/custom-fields

The "Custom Fields" FreeScout module is not installed. Navigate to https://tickets.raxx.app/manage/modules and enable it.

API returns 401

Check that FREESCOUT_API_KEY is a valid admin key: - Profile → API Access Key - Regenerate if necessary

Slug does not match expected value

FreeScout slugifies the field name on creation. If a field named "Component Tag" already exists (or was deleted), FreeScout may append _2 or similar.

Resolution: delete all fields with the conflicting name, then re-run the script or recreate manually. The slug is set at creation time and cannot be edited.

Option values show display labels instead of slugs

Some FreeScout custom-field implementations store a human-readable label as the option value, not the slug. If the webhook payload shows "value": "App (raxx.app)" instead of "value": "app-raxx-app", the option was saved with the wrong format.

Resolution: edit each option in the admin UI to use the bare surface_id slug as both the option label and value. The create-status-fields.sh script sends {"value": "raxx-app"} (no separate label), which should result in the slug being stored and returned.


8. References