From b8fc1198340e673ebbee62f8583538428e10195e Mon Sep 17 00:00:00 2001 From: "Customer.io Open Source Bot" Date: Thu, 14 May 2026 11:01:00 +0100 Subject: [PATCH] Add Customer.io CLI source CioCliPublicExport-RevId: 6bf239427b56db7c5c7bf65a0df923684d8835cc --- cmd/auth.go | 28 +++++-- cmd/auth_test.go | 70 ++++++++++++++++ cmd/root.go | 2 + cmd/root_test.go | 25 ++++++ internal/client/auth.go | 33 ++++++++ internal/client/auth_test.go | 45 ++++++++++ internal/client/client.go | 32 ++++++- internal/client/client_test.go | 51 ++++++++++++ internal/client/track.go | 1 + internal/routes/cache.go | 2 + internal/routes/cache_test.go | 36 ++++++++ internal/skills/skills.go | 2 + internal/skills/skills_test.go | 27 ++++++ internal/useragent/useragent.go | 19 +++++ internal/useragent/useragent_test.go | 26 ++++++ skills/cio/integration.md | 119 ++++++++++++++++----------- skills/cio/onboarding.md | 2 + 17 files changed, 460 insertions(+), 60 deletions(-) create mode 100644 cmd/root_test.go create mode 100644 internal/useragent/useragent.go create mode 100644 internal/useragent/useragent_test.go diff --git a/cmd/auth.go b/cmd/auth.go index f4aef98..dacd04a 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -220,17 +220,19 @@ Token resolution order: "token": client.MaskToken(token), } - // Show stored region/URL and account ID. - if creds, err := client.ReadCredentials(); err == nil { - if creds.Region != "" { - statusResult["region"] = creds.Region - } - if creds.AccountID != "" { - statusResult["account_id"] = creds.AccountID + // Stored metadata is only authoritative when the stored token is active. + if tokenSource == "config_file" { + if creds, err := client.ReadCredentials(); err == nil { + if creds.Region != "" { + statusResult["region"] = creds.Region + } + if creds.AccountID != "" { + statusResult["account_id"] = creds.AccountID + } } } - // Verify by exchanging the token. + // Verify the active token and derive account metadata from that session. c := clientFromCmd(cmd) if c != nil { statusResult["base_url"] = c.BaseURL() @@ -241,6 +243,16 @@ Token resolution order: statusResult["verify_error"] = err.Error() } else { statusResult["verified"] = true + if info, err := c.CurrentAccountInfo(cmd.Context()); err == nil { + if info.Region != "" { + statusResult["region"] = info.Region + } + if info.AccountID != "" { + statusResult["account_id"] = info.AccountID + } + } else if tokenSource != "config_file" { + statusResult["account_info_error"] = err.Error() + } } } diff --git a/cmd/auth_test.go b/cmd/auth_test.go index c79c730..dfaefde 100644 --- a/cmd/auth_test.go +++ b/cmd/auth_test.go @@ -11,6 +11,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/customerio/cli/internal/client" ) @@ -551,6 +552,75 @@ func TestAuthStatus_WithEnvToken(t *testing.T) { } } +func TestAuthStatus_WithEnvTokenReportsEnvAccountID(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + t.Setenv("CIO_TOKEN", "sa_live_envtoken") + t.Setenv("CIO_ACCESS_TOKEN", "") + + if err := client.WriteCredentials(&client.Credentials{ + ServiceAccountToken: "sa_live_filetoken", + AccountID: "1", + Region: "us", + AccessToken: "jwt-file", + AccessTokenExpiresAt: time.Now().Add(time.Hour), + }); err != nil { + t.Fatalf("seed credentials: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/service_accounts/oauth/token": + if err := r.ParseForm(); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + switch r.PostFormValue("client_secret") { + case "sa_live_envtoken": + _, _ = w.Write([]byte(`{"access_token":"jwt-env","token_type":"Bearer","expires_in":3600}`)) + case "sa_live_filetoken": + _, _ = w.Write([]byte(`{"access_token":"jwt-file","token_type":"Bearer","expires_in":3600}`)) + default: + w.WriteHeader(http.StatusUnauthorized) + } + case "/v1/accounts/current": + switch r.Header.Get("Authorization") { + case "Bearer jwt-env": + _, _ = w.Write([]byte(`{"account":{"id":2,"name":"Env Account","data_center":"eu"}}`)) + case "Bearer jwt-file": + _, _ = w.Write([]byte(`{"account":{"id":1,"name":"File Account","data_center":"us"}}`)) + default: + w.WriteHeader(http.StatusUnauthorized) + } + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + stdout, _, err := executeCommand("auth", "status", "--api-url", server.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var result map[string]any + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("invalid JSON: %v\nstdout: %s", err, stdout) + } + if result["token_source"] != "environment" { + t.Errorf("expected token_source 'environment', got %v", result["token_source"]) + } + if result["verified"] != true { + t.Errorf("expected verified=true, got %v (error: %v)", result["verified"], result["verify_error"]) + } + if result["account_id"] != "2" { + t.Errorf("expected account_id from environment token, got %v", result["account_id"]) + } + if result["region"] != "eu" { + t.Errorf("expected region from environment token, got %v", result["region"]) + } +} + func TestAuthStatus_InvalidToken(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) diff --git a/cmd/root.go b/cmd/root.go index 6bd7f7c..d75cdcf 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,6 +9,7 @@ import ( "github.com/customerio/cli/internal/client" "github.com/customerio/cli/internal/output" + "github.com/customerio/cli/internal/useragent" "github.com/customerio/cli/internal/validate" "github.com/spf13/cobra" ) @@ -219,6 +220,7 @@ func GetJSONBody(cmd *cobra.Command) ([]byte, error) { // SetVersion sets the CLI version string (called from main with ldflags value). func SetVersion(v string) { if v != "" { + useragent.SetVersion(v) rootCmd.Version = v } } diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..b13d9a3 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,25 @@ +package cmd + +import ( + "testing" + + "github.com/customerio/cli/internal/useragent" +) + +func TestSetVersionIgnoresEmptyVersion(t *testing.T) { + oldRootVersion := rootCmd.Version + t.Cleanup(func() { + rootCmd.Version = oldRootVersion + useragent.SetVersion("dev") + }) + + SetVersion("v1.2.3") + SetVersion("") + + if got := rootCmd.Version; got != "v1.2.3" { + t.Fatalf("rootCmd.Version = %q, want %q", got, "v1.2.3") + } + if got, want := useragent.Get(), "Customer.io-CLI/v1.2.3 (+https://github.com/customerio/cli)"; got != want { + t.Fatalf("useragent.Get() = %q, want %q", got, want) + } +} diff --git a/internal/client/auth.go b/internal/client/auth.go index 2764563..4818b9e 100644 --- a/internal/client/auth.go +++ b/internal/client/auth.go @@ -147,10 +147,26 @@ func ResolveRegion(apiURL string, apiURLChanged bool) string { // accidentally using a restricted token for a full-access session). // The scopes parameter must match the cached scopes exactly for reuse. func CachedAccessToken(readOnly bool, scopes []string) string { + return cachedAccessToken("", readOnly, scopes) +} + +// CachedAccessTokenForServiceAccount returns a cached JWT only when it belongs +// to the same service-account token that is active for this invocation. +func CachedAccessTokenForServiceAccount(serviceAccountToken string, readOnly bool, scopes []string) string { + if serviceAccountToken == "" { + return "" + } + return cachedAccessToken(serviceAccountToken, readOnly, scopes) +} + +func cachedAccessToken(serviceAccountToken string, readOnly bool, scopes []string) string { creds, err := ReadCredentials() if err != nil { return "" } + if serviceAccountToken != "" && creds.ServiceAccountToken != serviceAccountToken { + return "" + } if creds.AccessToken == "" { return "" } @@ -190,6 +206,19 @@ func stringsEqual(a, b []string) bool { // Holds an exclusive lock across the read-modify-write sequence so two // concurrent invocations don't lose each other's update. func CacheAccessToken(accessToken string, expiresIn int, readOnly bool, scopes []string) error { + return cacheAccessToken("", accessToken, expiresIn, readOnly, scopes) +} + +// CacheAccessTokenForServiceAccount stores a JWT only when the stored config +// still belongs to the same service-account token that minted the JWT. +func CacheAccessTokenForServiceAccount(serviceAccountToken, accessToken string, expiresIn int, readOnly bool, scopes []string) error { + if serviceAccountToken == "" { + return nil + } + return cacheAccessToken(serviceAccountToken, accessToken, expiresIn, readOnly, scopes) +} + +func cacheAccessToken(serviceAccountToken, accessToken string, expiresIn int, readOnly bool, scopes []string) error { unlock, err := lockConfigDir() if err != nil { // Can't cache without a lock — don't fail the caller's request. @@ -202,6 +231,10 @@ func CacheAccessToken(accessToken string, expiresIn int, readOnly bool, scopes [ // No existing config — can't cache without stored credentials. return nil } + if serviceAccountToken != "" && creds.ServiceAccountToken != serviceAccountToken { + // Env/flag overrides should not rewrite the cache for the stored token. + return nil + } creds.AccessToken = accessToken creds.AccessTokenExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second) creds.ReadOnly = readOnly diff --git a/internal/client/auth_test.go b/internal/client/auth_test.go index 86eb478..a2703ca 100644 --- a/internal/client/auth_test.go +++ b/internal/client/auth_test.go @@ -130,6 +130,51 @@ func TestCacheAccessToken_ConcurrentWritesProduceValidConfig(t *testing.T) { } } +func TestCachedAccessTokenRequiresMatchingServiceAccountToken(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + + if err := WriteCredentials(&Credentials{ + ServiceAccountToken: "sa_live_file", + AccessToken: "jwt-file", + AccessTokenExpiresAt: time.Now().Add(time.Hour), + }); err != nil { + t.Fatalf("seed write: %v", err) + } + + if got := CachedAccessTokenForServiceAccount("sa_live_env", false, nil); got != "" { + t.Fatalf("expected mismatched token cache miss, got %q", got) + } + if got := CachedAccessTokenForServiceAccount("sa_live_file", false, nil); got != "jwt-file" { + t.Fatalf("expected matching token cache hit, got %q", got) + } +} + +func TestCacheAccessTokenSkipsMismatchedServiceAccountToken(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + + if err := WriteCredentials(&Credentials{ + ServiceAccountToken: "sa_live_file", + AccessToken: "jwt-file", + AccessTokenExpiresAt: time.Now().Add(time.Hour), + }); err != nil { + t.Fatalf("seed write: %v", err) + } + + if err := CacheAccessTokenForServiceAccount("sa_live_env", "jwt-env", 3600, false, nil); err != nil { + t.Fatalf("cache write: %v", err) + } + + got, err := ReadCredentials() + if err != nil { + t.Fatalf("read credentials: %v", err) + } + if got.AccessToken != "jwt-file" { + t.Fatalf("expected cached access token to stay unchanged, got %q", got.AccessToken) + } +} + func TestWriteCredentials_AtomicNoPartialFile(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) diff --git a/internal/client/client.go b/internal/client/client.go index 3099352..6fdd47f 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -12,11 +12,14 @@ import ( "os" "strings" "time" + + "github.com/customerio/cli/internal/useragent" ) // setStandardHeaders stamps headers that every outgoing CLI request should // carry: // +// - User-Agent — identifies this CLI and its release version in API logs. // - X-Validate: strict — opts into strict server-side JSON validation so // that unknown/typo'd body fields produce a 400 instead of a silent 200. // Harmless on GETs and form-encoded bodies (the server only consults it @@ -25,6 +28,7 @@ import ( // sandbox that runs the CLI on behalf of an AI agent sets this so // downstream metrics can attribute traffic to the agent. func setStandardHeaders(req *http.Request) { + req.Header.Set("User-Agent", useragent.Get()) req.Header.Set("X-Validate", "strict") if os.Getenv("CIO_AGENT") == "1" { req.Header.Set("X-CIO-Agent", "1") @@ -155,7 +159,7 @@ func (c *Client) EnsureAccessToken(ctx context.Context) (string, error) { } // Check the file cache. - if cached := CachedAccessToken(c.readOnly, c.scopes); cached != "" { + if cached := CachedAccessTokenForServiceAccount(c.serviceAccountToken, c.readOnly, c.scopes); cached != "" { c.accessToken = cached // File cache already applies 60s buffer, so set a conservative in-memory expiry. c.accessTokenExpiresAt = time.Now().Add(55 * time.Minute) @@ -180,7 +184,7 @@ func (c *Client) EnsureAccessToken(ctx context.Context) (string, error) { } // Cache for future invocations. - _ = CacheAccessToken(token, expiresIn, c.readOnly, c.scopes) + _ = CacheAccessTokenForServiceAccount(c.serviceAccountToken, token, expiresIn, c.readOnly, c.scopes) return token, nil } @@ -280,6 +284,30 @@ type DiscoverRegionResult struct { AccountID string } +// AccountInfo describes the account represented by the active access token. +type AccountInfo struct { + Region string + AccountID string +} + +// CurrentAccountInfo returns account metadata for the active token. +func (c *Client) CurrentAccountInfo(ctx context.Context) (*AccountInfo, error) { + accessToken, err := c.EnsureAccessToken(ctx) + if err != nil { + return nil, err + } + + region, accountID, err := fetchAccountInfo(ctx, c.httpClient, c.baseURL, accessToken) + if err != nil { + return nil, err + } + + return &AccountInfo{ + Region: region, + AccountID: accountID, + }, nil +} + // DiscoverRegion exchanges the sa_live_ token against the default US endpoint, // then calls GET /v1/accounts/current to read the account's data_center field. // diff --git a/internal/client/client_test.go b/internal/client/client_test.go index a1b4007..095f6c7 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -10,6 +10,8 @@ import ( "strings" "testing" "time" + + "github.com/customerio/cli/internal/useragent" ) func TestClient_Do_BearerToken(t *testing.T) { @@ -701,6 +703,55 @@ func TestClient_Do_ValidateHeader(t *testing.T) { } } +func TestClient_Do_UserAgentHeader(t *testing.T) { + var got string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Get("User-Agent") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + c := New(Config{ + BaseURL: server.URL, + AccessToken: "test-jwt", + RetryConfig: &RetryConfig{MaxRetries: 0, SleepFn: ContextSleep}, + }) + if _, err := c.Do(context.Background(), "GET", "/test", nil, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != useragent.Get() { + t.Errorf("User-Agent: got %q, want %q", got, useragent.Get()) + } +} + +func TestDoTrack_StandardHeaders(t *testing.T) { + var gotUserAgent string + var gotValidate string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUserAgent = r.Header.Get("User-Agent") + gotValidate = r.Header.Get("X-Validate") + _, _ = w.Write([]byte(`{"delivery_id":"abc"}`)) + })) + defer server.Close() + + if _, err := DoTrack(context.Background(), TrackRequest{ + TrackBaseURL: server.URL, + Path: "/v1/send/email", + ServiceAccountToken: "sa_live_test", + WorkspaceID: "123", + Body: json.RawMessage(`{"to":"test@example.com"}`), + Timeout: time.Second, + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotUserAgent != useragent.Get() { + t.Errorf("User-Agent: got %q, want %q", gotUserAgent, useragent.Get()) + } + if gotValidate != "strict" { + t.Errorf("X-Validate: got %q, want %q", gotValidate, "strict") + } +} + func TestClient_Do_AgentHeader(t *testing.T) { cases := []struct { name string diff --git a/internal/client/track.go b/internal/client/track.go index 0eefebb..3ca5cd8 100644 --- a/internal/client/track.go +++ b/internal/client/track.go @@ -57,6 +57,7 @@ func DoTrack(ctx context.Context, req TrackRequest) (json.RawMessage, error) { httpReq.Header.Set("Accept", "application/json") httpReq.Header.Set("Authorization", "Bearer "+req.ServiceAccountToken) httpReq.Header.Set(WorkspaceIDHeader, req.WorkspaceID) + setStandardHeaders(httpReq) resp, err := httpClient.Do(httpReq) if err != nil { diff --git a/internal/routes/cache.go b/internal/routes/cache.go index c6285a0..d70c3a1 100644 --- a/internal/routes/cache.go +++ b/internal/routes/cache.go @@ -14,6 +14,7 @@ import ( "time" "github.com/customerio/cli/internal/filelock" + "github.com/customerio/cli/internal/useragent" ) const ( @@ -277,6 +278,7 @@ func downloadSpec(ctx context.Context, httpClient *http.Client, url, etag, acces return nil, "", err } req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", useragent.Get()) if accessToken != "" { req.Header.Set("Authorization", "Bearer "+accessToken) } diff --git a/internal/routes/cache_test.go b/internal/routes/cache_test.go index d09c970..7afd5e9 100644 --- a/internal/routes/cache_test.go +++ b/internal/routes/cache_test.go @@ -9,6 +9,8 @@ import ( "sync/atomic" "testing" "time" + + "github.com/customerio/cli/internal/useragent" ) func testSpec() string { @@ -512,6 +514,40 @@ func TestEnsureSpecs_UnauthenticatedNoAuthHeader(t *testing.T) { } } +func TestEnsureSpecs_UserAgentHeader(t *testing.T) { + var received []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + received = append(received, r.Header.Get("User-Agent")) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v1/openapi.json": + w.Write([]byte(testSpec())) + case "/cdp/api/openapi.json": + w.Write([]byte(testCDPSpec())) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + _, _, err := EnsureSpecs(context.Background(), LoadRegistryOptions{ + BaseURL: server.URL, + CacheDir: t.TempDir(), + }) + if err != nil { + t.Fatal(err) + } + + if len(received) != len(defaultSpecSources) { + t.Fatalf("got %d requests, want %d", len(received), len(defaultSpecSources)) + } + for i, got := range received { + if got != useragent.Get() { + t.Errorf("request %d User-Agent: got %q, want %q", i, got, useragent.Get()) + } + } +} + func TestEnsureSpecs_ConcurrentAccess(t *testing.T) { var requests atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/skills/skills.go b/internal/skills/skills.go index 60c442e..b46cb5c 100644 --- a/internal/skills/skills.go +++ b/internal/skills/skills.go @@ -13,6 +13,7 @@ import ( "time" "github.com/customerio/cli/internal/filelock" + "github.com/customerio/cli/internal/useragent" ) const ( @@ -211,6 +212,7 @@ func downloadSkills(ctx context.Context, httpClient *http.Client, url, etag stri return nil, "", err } req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", useragent.Get()) if etag != "" { req.Header.Set("If-None-Match", etag) } diff --git a/internal/skills/skills_test.go b/internal/skills/skills_test.go index 18768d5..b0c3144 100644 --- a/internal/skills/skills_test.go +++ b/internal/skills/skills_test.go @@ -9,6 +9,8 @@ import ( "path/filepath" "testing" "time" + + "github.com/customerio/cli/internal/useragent" ) func testResponse() *SkillsResponse { @@ -217,6 +219,31 @@ func TestEnsureSkills_ForceRefresh(t *testing.T) { } } +func TestEnsureSkills_UserAgentHeader(t *testing.T) { + resp := testResponse() + data, _ := json.Marshal(resp) + + var got string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Get("User-Agent") + w.Header().Set("Content-Type", "application/json") + w.Write(data) + })) + defer srv.Close() + + _, err := EnsureSkills(context.Background(), LoadOptions{ + BaseURL: srv.URL, + CacheDir: t.TempDir(), + }) + if err != nil { + t.Fatal(err) + } + + if got != useragent.Get() { + t.Errorf("User-Agent: got %q, want %q", got, useragent.Get()) + } +} + func TestEnsureSkills_StaleOnFailure(t *testing.T) { resp := testResponse() data, _ := json.Marshal(resp) diff --git a/internal/useragent/useragent.go b/internal/useragent/useragent.go new file mode 100644 index 0000000..b668548 --- /dev/null +++ b/internal/useragent/useragent.go @@ -0,0 +1,19 @@ +package useragent + +const ( + productName = "Customer.io-CLI" + defaultVersion = "dev" + repositoryURL = "https://github.com/customerio/cli" +) + +var version = defaultVersion + +// SetVersion sets the CLI version included in outgoing User-Agent headers. +func SetVersion(v string) { + version = v +} + +// Get returns the User-Agent value used for outgoing CLI requests. +func Get() string { + return productName + "/" + version + " (+" + repositoryURL + ")" +} diff --git a/internal/useragent/useragent_test.go b/internal/useragent/useragent_test.go new file mode 100644 index 0000000..8881bf5 --- /dev/null +++ b/internal/useragent/useragent_test.go @@ -0,0 +1,26 @@ +package useragent + +import "testing" + +func TestGetDefault(t *testing.T) { + old := version + version = defaultVersion + t.Cleanup(func() { version = old }) + + want := "Customer.io-CLI/dev (+https://github.com/customerio/cli)" + if got := Get(); got != want { + t.Fatalf("Get() = %q, want %q", got, want) + } +} + +func TestGetUsesVersionAsProvided(t *testing.T) { + old := version + version = "" + t.Cleanup(func() { version = old }) + + SetVersion("v1.2.3") + want := "Customer.io-CLI/v1.2.3 (+https://github.com/customerio/cli)" + if got := Get(); got != want { + t.Fatalf("Get() = %q, want %q", got, want) + } +} diff --git a/skills/cio/integration.md b/skills/cio/integration.md index e46e967..585600d 100644 --- a/skills/cio/integration.md +++ b/skills/cio/integration.md @@ -208,73 +208,92 @@ cio transactional list --environment-id ### From code (HTTP API) -The Track API endpoint varies by region: +For production backend code, **issue a workspace-scoped App API key** — do not embed the `sa_live_` SA token. SA tokens are account-level (full access, all workspaces); App API keys are scoped to a single workspace and are the right fit for backend integrations. -| Region | Track API base URL | -|--------|-------------------| -| US | `https://track.customer.io` | -| EU | `https://track-eu.customer.io` | - -Auth uses the `sa_live_` token directly as a Bearer token (no OAuth exchange required). Include the `X-Workspace-Id` header to select the workspace. +Create one via the CLI (one-time setup): ```bash -AUTH_HEADER='Authorization: Bearer ' -curl --request POST \ - --url https://track.customer.io/v1/send/email \ - --header "$AUTH_HEADER" \ - --header "X-Workspace-Id: " \ - --header "Content-Type: application/json" \ - -d '{ - "transactional_message_id": "1", - "to": "user@example.com", - "identifiers": {"email": "user@example.com"}, - "message_data": { - "name": "Alice", - "order_id": "123" - } - }' +cio api /v1/environments/{environment_id}/ext_api_keys -X POST \ + --json '{"ext_api_key":{"name":"backend-prod"}}' ``` -For one-off emails without a template, include the content inline: +The response includes the full Bearer value on creation — capture it and copy into the backend's env var (e.g. `CIO_APP_API_KEY`). Subsequent `GET .../ext_api_keys` calls return only a hint (last few characters), not the full key. Lost keys can't be recovered — issue a new one. -```bash -AUTH_HEADER='Authorization: Bearer ' -curl --request POST \ - --url https://track.customer.io/v1/send/email \ - --header "$AUTH_HEADER" \ - --header "X-Workspace-Id: " \ - --header "Content-Type: application/json" \ - -d '{ - "to": "user@example.com", - "identifiers": {"email": "user@example.com"}, - "from": "Acme ", - "subject": "Your order shipped", - "body": "

Order #123 is on its way

" - }' +Endpoints — one per channel, all share a common base shape: + +| Channel | Path | +|---------|------| +| Email | `POST /v1/send/email` | +| Push | `POST /v1/send/push` | +| SMS | `POST /v1/send/sms` | +| In-app | `POST /v1/send/in_app` | +| Inbox | `POST /v1/send/inbox_message` | + +App API base URL varies by region: + +| Region | App API base URL | +|--------|------------------| +| US | `https://api.customer.io` | +| EU | `https://api-eu.customer.io` | + +The HTTP shape is the API contract — produce the equivalent in whatever language the user is using. + +```http +POST https://api.customer.io/v1/send/email +Authorization: Bearer +Content-Type: application/json + +{ + "transactional_message_id": "order_confirmation", + "auto_create": true, + "identifiers": { "id": "user-123" }, + "to": "user@example.com", + "message_data": { "name": "Alice", "order_id": "123" } +} ``` -To call from code, translate the cURL pattern above into the user's language -- it's a plain `POST` with JSON body, Bearer auth, and the `X-Workspace-Id` header. No SDK needed. +For one-off emails without a template, include the content inline (no `transactional_message_id` needed): -### Other transactional message types +```http +POST https://api.customer.io/v1/send/email +Authorization: Bearer +Content-Type: application/json -The same pattern works for push, SMS, and in-app -- just change the endpoint path: +{ + "to": "user@example.com", + "identifiers": { "email": "user@example.com" }, + "from": "Acme ", + "subject": "Your order shipped", + "body": "

Order #123 is on its way

" +} +``` -| Type | Endpoint | -|------|----------| -| Email | `POST /v1/send/email` | -| Push | `POST /v1/send/push` | -| SMS | `POST /v1/send/sms` | -| In-app | `POST /v1/send/inbox_message` | +The `X-Workspace-Id` header is **not** needed when using an App API key — the key is already workspace-scoped. (The header is only needed for SA-token-based calls, which the CLI itself handles internally.) + +### The `auto_create` paradigm — simplest pattern for backend sends + +Pick a stable string identifier (`"order_confirmation"`, `"password_reset"`, `"shipping_update"`) and pass it as `transactional_message_id` along with `auto_create: true`. The first call creates a transactional message in the workspace with that name and the channel matching the endpoint you hit (`/v1/send/email` → email-typed message, `/v1/send/push` → push, etc.). Subsequent calls find and reuse the existing message. Keep `auto_create: true` on every send — it's idempotent. + +Name constraints: must be a non-numeric string, non-empty, ≤ 191 unicode characters. A name that parses as a number (e.g. `"42"`) is rejected; `auto_create` is text-only. + +**When NOT to use `auto_create`:** + +- **In-app and inbox messages** — auto-created templates have no `body_json`, so deliveries queue successfully but render empty. Create the message explicitly via the management API or UI first, then configure the template. +- **When the template is authored ahead of time** (e.g. the email body is built in Design Studio or the UI). Create the message explicitly and call with the resulting numeric ID or string name; omit `auto_create`. + +### Channel notes -Push, SMS, and in-app always require a `transactional_message_id` (template). Only email supports one-off sends without a template. +- **Email:** can send *with* a `transactional_message_id` (templated) or *without* (inline content via `to`, `from`, `subject`, `body`, `body_plain`). +- **Push, SMS, in-app, inbox:** always require a `transactional_message_id`. +- The full per-channel field list (push `custom_payload`, SMS `tracked`, etc.) is in the API reference: https://customer.io/docs/api/app/#tag/Transactional -Docs: https://docs.customer.io/journeys/transactional-email/ +Docs: https://docs.customer.io/journeys/transactional-email/ · https://customer.io/docs/api/app/#tag/Transactional ### Important notes -- The Track API uses the `sa_live_` token directly -- no OAuth token exchange needed +- For backend code, use a workspace-scoped App API key (Bearer auth against `api.customer.io`) — not the `sa_live_` SA token. The CLI's own `cio send` and `cio transactional send` commands use the SA token internally, which is fine for testing, but production code should use a per-workspace App API key. - Do NOT retry failed sends automatically -- retrying a POST risks duplicate deliveries -- For EU regions, use `https://track-eu.customer.io` instead +- For EU regions, use `https://api-eu.customer.io` --- diff --git a/skills/cio/onboarding.md b/skills/cio/onboarding.md index bb41835..81c8bca 100644 --- a/skills/cio/onboarding.md +++ b/skills/cio/onboarding.md @@ -74,6 +74,8 @@ Prints a URL. User opens it, logs in, copies the `sa_live_...` token, pastes it echo "sa_live_..." | cio auth login --with-token ``` +> **Note on tokens:** the `sa_live_` token authenticates the CLI itself (account-level, used internally for `cio api`, `cio send`, etc.). It should **not** be embedded in the user's production backend code. For backend integrations, issue a workspace-scoped App API key — see [`integration.md`](integration.md) Step 6. + ### Verify auth ```bash