Skip to main content

Channel Groups API

Manage the rules that classify traffic into channels (GA4-style channel grouping). Each rule matches against utm_source, utm_medium, and utm_campaign via regex; the rule with the highest priority that matches wins.

Base path: /channel-groups

Default scope: read. Mutations require write (editor or higher).


List Rules

GET /channel-groups?account_id={account_id}
ParameterTypeDefaultDescription
account_idstringrequiredAccount ID
include_inactivebooleanfalseInclude inactive rules
include_defaultsbooleantrueInclude system-default rules

Response:

{
"success": true,
"data": {
"rules": [
{
"id": 12,
"account_id": "acme",
"channel_name": "Paid Search",
"source_pattern": "google|bing",
"medium_pattern": "cpc|ppc|paid",
"campaign_pattern": null,
"priority": 100,
"is_default": false,
"is_active": true,
"created_at": "2025-01-05T10:00:00Z",
"updated_at": "2025-01-05T10:00:00Z"
}
],
"total": 18,
"default_count": 12,
"custom_count": 6
}
}

Default rules have account_id = null and is_default = true. They cannot be modified or deleted, only overridden via higher-priority custom rules.


Create Rule

POST /channel-groups?account_id={account_id}

Required scope: write (editor or higher)

Request Body:

FieldTypeRequiredDescription
channel_namestringYesChannel name (1-50 chars, e.g. "Paid Social")
source_patternstringNoRegex against utm_source (max 255). Null matches any
medium_patternstringNoRegex against utm_medium (max 255). Null matches any
campaign_patternstringNoRegex against utm_campaign (max 255). Null matches any
priorityintegerNoEvaluation priority 0-1000 (default 50). Higher wins
is_activebooleanNoDefault true

Response (201 Created): full rule object (same shape as list response items).


Get Rule

GET /channel-groups/{rule_id}?account_id={account_id}

Returns the rule or 404 if not found.


Update Rule

PATCH /channel-groups/{rule_id}?account_id={account_id}

Required scope: write

All fields from create are optional. Default (system) rules cannot be modified — attempting returns 403.


Delete Rule

DELETE /channel-groups/{rule_id}?account_id={account_id}

Required scope: write. Default rules cannot be deleted (403). Returns 204 No Content.

Draft-only guard (for MCP / automation)

Both PATCH and DELETE accept only_if_inactive=true as a query parameter. When set, the mutation is applied atomically only if the rule is a draft (is_active = false). If the rule has gone live between your read and your write, the endpoint returns 409 Conflict with error.code = "rule_is_live". This is what the MCP update_channel_rule / delete_channel_rule tools use to guarantee they never touch a live rule.

The dashboard omits this flag so live rules remain editable there.


Export Rules

GET /channel-groups/export?account_id={account_id}

Returns the account's custom rules and the current active defaults in a versioned JSON envelope suitable for import back into another site or for external storage.

Response:

{
"success": true,
"data": {
"version": 1,
"rules": [
{
"channel_name": "Paid Social — Meta",
"source_pattern": "^(facebook|instagram|fb|ig)$",
"medium_pattern": "^(cpc|paid)$",
"campaign_pattern": null,
"priority": 110,
"is_active": true
}
],
"default_rules": [ /* read-only, cannot be imported */ ]
}
}

Only the entries in rules are importable. default_rules are surfaced for reference and to allow tooling to compute an "override" set.


Import Rules

POST /channel-groups/import?account_id={account_id}&dry_run={bool}&scope={all|drafts}

Required scope: write.

Atomic replace-all import of custom rules. Body carries the same envelope as the export.

Query parameterTypeDefaultDescription
dry_runbooleanfalseValidate and report per-rule errors without writing
scopestringallall = replace every custom rule; drafts = replace only drafts (live rules kept intact) and force incoming rules to is_active=false (MCP draft-only invariant)

Rules:

  • All-or-nothing. Any invalid row → 422 with a per-row report; no partial writes.
  • Replace-all semantics. A successful scope=all import removes any custom rule not present in the payload. Passing rules: [] returns the site to defaults-only.
  • Limits: 100 custom rules per account (drafts included), body ≤ 256 KB.
  • Regex validated in RE2 (google-re2) — the same engine the pixel uses. Lookaheads / lookbehinds / backrefs are rejected at import time, so a rule that passes here matches identically in production.

Response (dry-run):

{
"success": true,
"data": {
"dry_run": true,
"would_insert": 5,
"would_delete": 2,
"errors": []
}
}

Response (real import):

{
"success": true,
"data": {
"inserted": 5,
"deleted": 2,
"total_after": 5
}
}

Test a Visit (rule tester)

POST /channel-groups/test?account_id={account_id}

Classifies a hypothetical visit using the exact same matching engine as the pixel (same RE2 library, same custom-before-default ordering). This is what powers the "Test a visit" panel in Site settings → Channels.

Request Body:

{
"source": "google",
"medium": "cpc",
"campaign": "brand-summer",
"include_inactive": true
}
FieldTypeDescription
sourcestringValue to test against utm_source (case-insensitive)
mediumstringValue to test against utm_medium
campaignstringValue to test against utm_campaign
include_inactivebooleanIf true, drafts are also evaluated (default false, i.e. only what the pixel is running today)

Response:

{
"success": true,
"data": {
"channel": "Paid Search Brand",
"matched_rule": {
"id": 42,
"channel_name": "Paid Search Brand",
"source_pattern": "^(google|bing)$",
"medium_pattern": "^cpc$",
"campaign_pattern": "brand",
"priority": 110,
"is_active": true,
"is_default": false
}
}
}

If nothing matches, channel is "Unassigned" and matched_rule is null.


Helper: my IP

Not on the channel-groups router, but paired with this feature set for the "Add my current IP" shortcut in Site settings → IP Exclusions:

GET /utils/my-ip

Echoes the caller's public IP, resolved through the trusted-proxy chain. Used by the dashboard's "Add my current IP" button.

{ "success": true, "data": { "ip": "203.0.113.7" } }

Get Channel Metrics

GET /channel-groups/stats/channels?account_id={account_id}

Traffic metrics rolled up by the channels resolved through the current rule set.

ParameterTypeDefaultDescription
account_idstringrequiredAccount ID
start_date / end_datedate-Local dates in account timezone
periodstring-Period shortcut (overrides explicit dates). Default 30d when nothing provided
countrystring-Filter by country (ISO 2-letter)
pageinteger1Page number
page_sizeinteger50Items per page (1-100)

Response:

{
"success": true,
"data": {
"channels": [
{
"channel": "Organic Search",
"entrances": 25000,
"engaged_entrances": 20000,
"bounces": 5000,
"bounce_rate": 20.0,
"page_views": 75000,
"microconversions": 3400,
"conversions": 350,
"conversion_rate": 1.4,
"revenue": 28000.00
}
],
"total": 8
}
}

This is similar to GET /stats/channels but limited to the current rule set's channels and without comparison/sort options.