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
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:
scripts/freescout/create-status-fields.shThese 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).
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
| 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.
| 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.
# 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.
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).
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
tickets.raxx.app as an admin account.https://tickets.raxx.app/manage/customfields).Component Tag
- Type: Dropdown
- Required: Yes (check the box)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/manage/customfields/1/edit).
Note this ID — use it for the API validation step in §4.Public Status
- Type: Single-line text (or "Text" depending on FreeScout version)
- Required: NoIncident Severity
- Type: Dropdown
- Required: Nodegraded
partial
downAfter 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.
| 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.
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 = ___
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
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")
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"}'
When a new surface is added to config/status-surfaces.yaml:
config/status-surfaces.yaml with a stable id.id to the HARDCODED_SURFACE_IDS array in
scripts/freescout/create-status-fields.sh to keep the hardcoded fallback
in sync.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"} ] }' ```
FreeScout uses token-based API auth. Two methods are supported:
curl -H "Authorization: Bearer ${FREESCOUT_API_KEY}" \
"https://tickets.raxx.app/api/custom-fields"
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.
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.
The "Custom Fields" FreeScout module is not installed.
Navigate to https://tickets.raxx.app/manage/modules and enable it.
Check that FREESCOUT_API_KEY is a valid admin key:
- Profile → API Access Key
- Regenerate if necessary
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.
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.
docs/architecture/status-raxx-app.md — section 5b (custom fields) + 5d (webhook logic)config/status-surfaces.yamlscripts/freescout/create-status-fields.shdocs/ops/runbooks/freescout.md