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:
- What each field does and why
- Automated creation via
scripts/freescout/create-status-fields.sh - Manual creation via FreeScout admin UI (fallback when API is unavailable)
- Slug stability contract (what #607 reads)
- Validation: create a test ticket, set all three fields, confirm JSON shape
- 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
- Log in to
tickets.raxx.appas an admin account. - Open the Manage menu in the top navigation.
- Click Custom Fields (URL:
https://tickets.raxx.app/manage/customfields).
3b. Create "Component Tag"
- Click New Custom Field (top right of the custom fields page).
- Fill in the form:
- Name:
Component Tag- Type: Dropdown - Required: Yes (check the box) - 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 - Click Save.
- 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"
- Click New Custom Field.
- Fill in:
- Name:
Public Status- Type: Single-line text (or "Text" depending on FreeScout version) - Required: No - Click Save.
3d. Create "Incident Severity"
- Click New Custom Field.
- Fill in:
- Name:
Incident Severity- Type: Dropdown - Required: No - Add options (one per entry):
degraded partial down - 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:
- Add the new surface entry to
config/status-surfaces.yamlwith a stableid. - Add the new surface
idto theHARDCODED_SURFACE_IDSarray inscripts/freescout/create-status-fields.shto keep the hardcoded fallback in sync. - Add the new option to the FreeScout
component_tagdropdown:
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"} ] }' ```
- Update this runbook's §1b option list with the new surface ID.
- 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
- Architecture:
docs/architecture/status-raxx-app.md— section 5b (custom fields) + 5d (webhook logic) - Surface registry:
config/status-surfaces.yaml - Creation script:
scripts/freescout/create-status-fields.sh - FreeScout API docs: https://freescout.net/article/api/
- FreeScout infrastructure runbook:
docs/ops/runbooks/freescout.md - Parent epic: #581
- Sub-cards: #605 (this runbook), #631 (incident_severity field), #607 (webhook receiver)