diff --git a/cmd/admin/analytics.go b/cmd/admin/analytics.go
index 029e679..d07ca57 100644
--- a/cmd/admin/analytics.go
+++ b/cmd/admin/analytics.go
@@ -38,7 +38,7 @@ func init() {
The base URL for these APIs is **analytics.webexapis.com**, which does not work with the **Try It** feature.
`,
RunE: func(cmd *cobra.Command, args []string) error {
- req := client.NewRequest(config.CallingBaseURL, "GET", "/v1/analytics/messagingMetrics/dailyTotals")
+ req := client.NewRequest(config.AnalyticsBaseURL, "GET", "/v1/analytics/messagingMetrics/dailyTotals")
if last != "" {
var err error
from, to, err = timeutil.ParseLastISO(last)
@@ -79,7 +79,7 @@ func init() {
The base URL for these APIs is **analytics.webexapis.com**, which does not work with the **Try It** feature.
`,
RunE: func(cmd *cobra.Command, args []string) error {
- req := client.NewRequest(config.CallingBaseURL, "GET", "/v1/analytics/roomDeviceMetrics/dailyTotals")
+ req := client.NewRequest(config.AnalyticsBaseURL, "GET", "/v1/analytics/roomDeviceMetrics/dailyTotals")
if last != "" {
var err error
from, to, err = timeutil.ParseLastISO(last)
@@ -121,7 +121,7 @@ func init() {
The base URL for these APIs is **analytics.webexapis.com**, which does not work with the **Try It** feature.
`,
RunE: func(cmd *cobra.Command, args []string) error {
- req := client.NewRequest(config.CallingBaseURL, "GET", "/v1/analytics/meetingsMetrics/aggregates")
+ req := client.NewRequest(config.AnalyticsBaseURL, "GET", "/v1/analytics/meetingsMetrics/aggregates")
if last != "" {
var err error
from, to, err = timeutil.ParseLastISO(last)
diff --git a/cmd/calling/reports_detailed_call_history.go b/cmd/calling/reports_detailed_call_history.go
index ed86be2..1984032 100644
--- a/cmd/calling/reports_detailed_call_history.go
+++ b/cmd/calling/reports_detailed_call_history.go
@@ -35,7 +35,7 @@ func init() {
Short: "Get Detailed Call History",
Long: "Provides Webex Calling Detailed Call History data for your organization.\n\nResults can be filtered with the `startTime`, `endTime` and `locations` request parameters. The `startTime` and `endTime` parameters specify the start and end of the time period for the Detailed Call History reports you wish to collect. The API will return all reports that were created between `startTime` and `endTime`.\n\n
\nResponse entries may be added as more information is made available for the reports.\nValues in response items may be extended as more capabilities are added to Webex Calling.",
RunE: func(cmd *cobra.Command, args []string) error {
- req := client.NewRequest(config.CallingBaseURL, "GET", "/cdr_feed")
+ req := client.NewRequest(config.AnalyticsCallingBaseURL(), "GET", "/cdr_feed")
req.QueryParam("startTime", startTime)
req.QueryParam("endTime", endTime)
req.QueryParam("locations", locations)
@@ -71,7 +71,7 @@ func init() {
Short: "Get Live Stream Detailed Call History",
Long: "Provides Webex Calling Detailed Call History data for your organization.\n\nResults can be filtered with the `startTime`, `endTime` and `locations` request parameters. The `startTime` and `endTime` parameters specify the time window during which Detailed Call History data was inserted into the Webex Calling cloud. The API will return all reports whose insertion time into the Webex Calling cloud falls between `startTime` and `endTime`.\n\n
\nResponse entries may be added as more information is made available for the reports.\nValues in response items may be extended as more capabilities are added to Webex Calling.",
RunE: func(cmd *cobra.Command, args []string) error {
- req := client.NewRequest(config.CallingBaseURL, "GET", "/cdr_stream")
+ req := client.NewRequest(config.AnalyticsCallingBaseURL(), "GET", "/cdr_stream")
req.QueryParam("startTime", startTime)
req.QueryParam("endTime", endTime)
req.QueryParam("locations", locations)
diff --git a/cmd/config_cmd.go b/cmd/config_cmd.go
index cfa5f21..180b3c4 100644
--- a/cmd/config_cmd.go
+++ b/cmd/config_cmd.go
@@ -5,6 +5,7 @@ import (
"strings"
"github.com/Cloverhound/webex-cli/internal/appconfig"
+ "github.com/Cloverhound/webex-cli/internal/config"
"github.com/spf13/cobra"
)
@@ -26,7 +27,7 @@ To use your own Webex Integration instead of the built-in default:
var configSetCmd = &cobra.Command{
Use: "set ",
Short: "Set a configuration value",
- Long: "Set a configuration value. Valid keys: client-id, client-secret, scopes",
+ Long: "Set a configuration value. Valid keys: client-id, client-secret, scopes, region",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
key := args[0]
@@ -44,8 +45,13 @@ var configSetCmd = &cobra.Command{
cfg.ClientSecret = value
case "scopes":
cfg.Scopes = value
+ case "region":
+ if err := setRegion(value); err != nil {
+ return err
+ }
+ cfg.Region = config.Region()
default:
- return fmt.Errorf("unknown config key: %s (valid: client-id, client-secret, scopes)", key)
+ return fmt.Errorf("unknown config key: %s (valid: client-id, client-secret, scopes, region)", key)
}
if err := cfg.Save(); err != nil {
@@ -60,7 +66,7 @@ var configSetCmd = &cobra.Command{
var configGetCmd = &cobra.Command{
Use: "get ",
Short: "Get a configuration value",
- Long: "Get a configuration value. Valid keys: client-id, client-secret, scopes",
+ Long: "Get a configuration value. Valid keys: client-id, client-secret, scopes, region",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
key := args[0]
@@ -102,8 +108,14 @@ var configGetCmd = &cobra.Command{
} else {
fmt.Printf("%s (default)\n", value)
}
+ case "region":
+ if cfg.Region != "" {
+ fmt.Printf("%s (custom)\n", cfg.Region)
+ } else {
+ fmt.Println("us (default)")
+ }
default:
- return fmt.Errorf("unknown config key: %s (valid: client-id, client-secret, scopes)", key)
+ return fmt.Errorf("unknown config key: %s (valid: client-id, client-secret, scopes, region)", key)
}
return nil
diff --git a/cmd/root.go b/cmd/root.go
index f1cbc70..c17091e 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -4,6 +4,8 @@ import (
"errors"
"fmt"
"os"
+ "slices"
+ "strings"
"github.com/Cloverhound/webex-cli/internal/appconfig"
"github.com/Cloverhound/webex-cli/internal/auth"
@@ -48,6 +50,19 @@ var rootCmd = &cobra.Command{
return fmt.Errorf("loading config: %w", err)
}
+ // Data region for region-specific hosts (Detailed Call History):
+ // --region flag > WEBEX_REGION env > config file.
+ regionFlag, _ := cmd.Flags().GetString("region")
+ if regionFlag == "" {
+ regionFlag = os.Getenv("WEBEX_REGION")
+ }
+ if regionFlag == "" {
+ regionFlag = cfg.Region
+ }
+ if err := setRegion(regionFlag); err != nil {
+ return err
+ }
+
// Skip auth for certain commands
if skipAuth(cmd) {
return nil
@@ -126,6 +141,20 @@ var rootCmd = &cobra.Command{
},
}
+// setRegion validates a region value and stores it for region-specific hosts.
+func setRegion(region string) error {
+ if region == "" {
+ config.SetRegion("")
+ return nil
+ }
+ normalized := strings.ToLower(strings.TrimSpace(region))
+ if !slices.Contains(config.Regions, normalized) {
+ return fmt.Errorf("unknown region %q (valid: %s)", region, strings.Join(config.Regions, ", "))
+ }
+ config.SetRegion(normalized)
+ return nil
+}
+
// RootCommand returns the root cobra command for external introspection.
func RootCommand() *cobra.Command { return rootCmd }
@@ -178,6 +207,7 @@ func init() {
rootCmd.PersistentFlags().Bool("dry-run", false, "Print write requests without executing them")
rootCmd.PersistentFlags().String("user", "", "Use a specific authenticated user (email)")
rootCmd.PersistentFlags().String("organization", "", "Override organization ID for this command")
+ rootCmd.PersistentFlags().String("region", "", "Data region for region-specific APIs: "+strings.Join(config.Regions, ", ")+" (default us)")
rootCmd.PersistentFlags().Int("max-retry", 3, "Max number of 429 retries before giving up (0 = no retries)")
rootCmd.PersistentFlags().Int("max-retry-timer", 60, "Max total seconds to wait across all 429 retries (0 = unlimited)")
diff --git a/codegen/generate_cli.py b/codegen/generate_cli.py
index 70eb8ad..d4b30e6 100644
--- a/codegen/generate_cli.py
+++ b/codegen/generate_cli.py
@@ -26,6 +26,34 @@
}
+# Endpoints served from a different host than the rest of their collection.
+# The Postman collections use "{{baseUrl}}" for every request, so the real host
+# is not in the source data and has to be recorded here.
+# Collection name → URL path prefix → Go expression for the base URL.
+BASE_URL_OVERRIDES = {
+ "Webex Cloud Calling": {
+ # Detailed Call History (CDR) — region-specific analytics FQDN.
+ "/cdr_feed": "config.AnalyticsCallingBaseURL()",
+ "/cdr_stream": "config.AnalyticsCallingBaseURL()",
+ },
+ "Webex Admin": {
+ # Analytics reports. These paths already include /v1, so the base URL
+ # deliberately has no version segment.
+ "/v1/analytics/": "config.AnalyticsBaseURL",
+ },
+}
+
+
+def resolve_base_url(collection_name, path, default):
+ """Return the base URL expression for an endpoint (longest prefix wins)."""
+ overrides = BASE_URL_OVERRIDES.get(collection_name, {})
+ match = ""
+ for prefix in overrides:
+ if path.startswith(prefix) and len(prefix) > len(match):
+ match = prefix
+ return overrides[match] if match else default
+
+
# Path params that are always the same value — hardcode instead of generating a flag.
HARDCODED_PATH_PARAMS = {
"projectId": "5e5c9ad6d61f870d6d778c1b",
@@ -196,7 +224,8 @@ def generate_group_file(group, endpoints, pkg, parent_var, base_url_const, is_ca
if ep['command'] in skip_cmds:
print(f" Skipping command '{ep['command']}' (custom override)")
continue
- lines.extend(generate_command(ep, group_var, base_url_const, is_calling))
+ ep_base_url = resolve_base_url(collection_name, ep['path'], base_url_const)
+ lines.extend(generate_command(ep, group_var, ep_base_url, is_calling))
lines.append('')
lines.append('}')
@@ -204,7 +233,7 @@ def generate_group_file(group, endpoints, pkg, parent_var, base_url_const, is_ca
return '\n'.join(lines) + '\n'
-def generate_command(ep, group_var, base_url_const, is_calling):
+def generate_command(ep, group_var, base_url_expr, is_calling):
"""Generate Go source for one command within a group's init()."""
lines = []
indent = '\t'
@@ -329,7 +358,7 @@ def generate_command(ep, group_var, base_url_const, is_calling):
lines.append(f'{indent2}\tRunE: func(cmd *cobra.Command, args []string) error {{')
indent3 = indent2 + '\t\t'
- lines.append(f'{indent3}req := client.NewRequest({base_url_const}, "{method}", {escape_go_double_quoted(path)})')
+ lines.append(f'{indent3}req := client.NewRequest({base_url_expr}, "{method}", {escape_go_double_quoted(path)})')
# --last → from/to conversion
if has_from:
diff --git a/internal/appconfig/appconfig.go b/internal/appconfig/appconfig.go
index 88ee903..75b3342 100644
--- a/internal/appconfig/appconfig.go
+++ b/internal/appconfig/appconfig.go
@@ -28,6 +28,7 @@ type Config struct {
Scopes string `json:"scopes,omitempty"`
DefaultOrgID string `json:"default_org_id,omitempty"`
DefaultOrgName string `json:"default_org_name,omitempty"`
+ Region string `json:"region,omitempty"`
path string // file path, not serialized
}
diff --git a/internal/client/client.go b/internal/client/client.go
index 205a875..f7b2a6b 100644
--- a/internal/client/client.go
+++ b/internal/client/client.go
@@ -7,6 +7,7 @@ import (
"net/http"
urlpkg "net/url"
"os"
+ "regexp"
"strconv"
"strings"
"time"
@@ -51,9 +52,34 @@ func Do(req *Request) ([]byte, int, error) {
body, status, headers, err = doOnce(req)
}
+ if status == 451 {
+ return body, status, wrongRegionError(body)
+ }
+
return body, status, err
}
+// endpointURLPattern finds the regional endpoint Webex returns in a 451 body.
+var endpointURLPattern = regexp.MustCompile(`https?://[a-zA-Z0-9.\-]+`)
+
+// wrongRegionError turns an HTTP 451 (org data lives in another region) into an
+// actionable message naming the endpoint the API pointed us at.
+func wrongRegionError(body []byte) error {
+ msg := fmt.Sprintf("wrong data region (451): this organization's data is not hosted in region %q", regionLabel())
+ if m := endpointURLPattern.Find(body); m != nil {
+ msg += fmt.Sprintf("; Webex says to use %s", m)
+ }
+ return fmt.Errorf("%s — rerun with --region <%s> (or: webex config set region )",
+ msg, strings.Join(config.Regions, "|"))
+}
+
+func regionLabel() string {
+ if r := config.Region(); r != "" {
+ return r
+ }
+ return "us (default)"
+}
+
// retryAfterDuration parses the Retry-After header value (seconds integer) and
// returns the duration to wait. Defaults to 5 seconds if the header is absent
// or unparseable.
diff --git a/internal/config/config.go b/internal/config/config.go
index 3a6aff8..b93cda4 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -55,8 +55,44 @@ func OrgIDBase64() string { return orgIDBase64 }
const (
CallingBaseURL = "https://webexapis.com/v1"
CcBaseURL = "https://api.wxcc-us1.cisco.com"
+
+ // AnalyticsBaseURL serves the Admin analytics reports. Paths for those
+ // endpoints already carry their own /v1 segment.
+ AnalyticsBaseURL = "https://analytics.webexapis.com"
)
+// analyticsCallingHosts maps a data region to the Detailed Call History (CDR) FQDN.
+// Calls to the default host are routed to the nearest region; if that region does
+// not hold the org's data the API returns HTTP 451 with the correct endpoint.
+var analyticsCallingHosts = map[string]string{
+ "us": "https://analytics-calling.webexapis.com",
+ "ca": "https://analytics-calling.webexapis.com",
+ "eu": "https://analytics-calling-eu.webexapis.com",
+ "eun": "https://analytics-calling-eu.webexapis.com",
+ "in": "https://analytics-calling-in.webexapis.com",
+ "au": "https://analytics-calling-au.webexapis.com",
+}
+
+// Regions lists the supported --region values.
+var Regions = []string{"us", "ca", "eu", "eun", "in", "au"}
+
+var region string
+
+// SetRegion stores the org's data region (us, ca, eu, eun, in, au).
+// Empty means "use the default host and let Webex route by geography".
+func SetRegion(r string) { region = strings.ToLower(strings.TrimSpace(r)) }
+func Region() string { return region }
+
+// AnalyticsCallingBaseURL returns the Detailed Call History base URL for the
+// configured region, falling back to the US/Canada host.
+func AnalyticsCallingBaseURL() string {
+ host, ok := analyticsCallingHosts[region]
+ if !ok {
+ host = analyticsCallingHosts["us"]
+ }
+ return host + "/v1"
+}
+
// DecodeOrgID converts a base64-encoded Webex org ID (ciscospark://us/ORGANIZATION/)
// to the raw UUID. If the input is already a UUID or unrecognized, it is returned as-is.
func DecodeOrgID(id string) string {
diff --git a/skill/SKILL.md b/skill/SKILL.md
index e8868dd..218b992 100644
--- a/skill/SKILL.md
+++ b/skill/SKILL.md
@@ -62,6 +62,7 @@ Aliases: `devices` for `device`, `meeting` for `meetings`, `msg` for `messaging`
- `--debug` — Show HTTP request/response details
- `--paginate` — Auto-paginate list results
- `--dry-run` — Print write requests (POST/PUT/DELETE/PATCH) without executing them; read operations still run normally
+- `--region us|ca|eu|eun|in|au` — Data region for region-specific hosts, used by `calling reports-detailed-call-history` (default: `us`). Also settable with `webex config set region ` or `WEBEX_REGION`. A `451` error means the org's data lives in another region — the error names the correct one.
## Contact Center `--orgid` Handling