Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,9 +298,10 @@ toolkit discovers every command with `rc commands --json` and

## Custom request headers

`RC_HEADERS` sends extra HTTP headers on every RevenueCat request (v2 API, Rico,
and the Paywall AI editor), as newline-separated `Name: Value` pairs. Use it to
route or tag traffic without baking anything into the binary:
`RC_HEADERS` sends extra HTTP headers on every CLI-originated RevenueCat request
(v2 API, signup/OAuth, v1 SDK requests, Rico, and the Paywall AI editor), as
newline-separated `Name: Value` pairs. Use it to route or tag traffic without
baking anything into the binary:

```bash
RC_HEADERS=$'X-Some-Header: value' rc offerings list
Expand All @@ -310,16 +311,21 @@ One header per line; supplied headers override the CLI's defaults, except `Autho

## Usage analytics

Every authenticated v2 API request carries a few headers so RevenueCat can see
which commands are used and whether the CLI is driven by a human, an agent, or
CI — derived from normal request logs, with no separate telemetry and no
identifier of any kind:
Every CLI-originated RevenueCat API request carries a few headers, including
signup, OAuth token exchange/refresh, v1 SDK requests, Rico, and Paywall AI.
Comment thread
joshdholtz marked this conversation as resolved.
RevenueCat can see which commands are used and the CLI invocation mode from
normal request logs, with no separate telemetry or user/account identifiers:

- `User-Agent` — `revenuecat-cli/<version> (<os> <arch>; go<goversion>)`.
- `X-RC-CLI-Command` — the command path only (e.g. `paywalls.generate`), never
arguments, flag values, IDs, emails, or prompts.
- `X-RC-CLI-Mode` — `interactive`, `agent` (`--json` or `--no-input`), or `ci`
(when `$CI` is set).
(when `$CI` is nonempty, taking precedence over `agent`).

`agent` describes the flags used, not a verified AI caller or a specific AI tool.
Browser authorization requests use the browser's headers. Third-party requests
(such as Apple and Google APIs or store screenshot uploads) do not receive these
RevenueCat analytics headers.

Set `DO_NOT_TRACK=1` (per [consoledonottrack.com](https://consoledonottrack.com))
to drop the `X-RC-CLI-*` headers. Requests still go through, just unlabeled.
Expand Down
41 changes: 32 additions & 9 deletions internal/api/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,25 +12,38 @@ import (
"net/url"
"strings"
"time"

"github.com/revenuecat/cli/internal/httpx"
)

const DefaultOAuthBaseURL = "https://api.revenuecat.com"
const DefaultOAuthClientID = "cmV2ZW51ZWNhdC1jbGk="
const DefaultOAuthScope = "*:*:read_write"

// OAuthService handles the token endpoint only — the browser redirect and
// callback server live in the CLI layer since they have user-facing side effects.
type OAuthOptions struct {
BaseURL string
ClientID string
UserAgent string
ExtraHeaders http.Header
}

// Browser redirects and the callback server live in the CLI layer because
// they have user-facing side effects.
type OAuthService struct {
baseURL string
clientID string
httpClient *http.Client
baseURL string
clientID string
httpClient *http.Client
userAgent string
extraHeaders http.Header
}

func NewOAuthService(baseURL, clientID string) *OAuthService {
func NewOAuthService(opts OAuthOptions) *OAuthService {
return &OAuthService{
baseURL: strings.TrimRight(baseURL, "/"),
clientID: clientID,
httpClient: &http.Client{Timeout: 30 * time.Second},
baseURL: strings.TrimRight(opts.BaseURL, "/"),
clientID: opts.ClientID,
userAgent: opts.UserAgent,
extraHeaders: opts.ExtraHeaders,
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}

Expand Down Expand Up @@ -171,6 +184,11 @@ func (s *OAuthService) postToken(ctx context.Context, body url.Values) (*TokenRe
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")

if s.userAgent != "" {
req.Header.Set("User-Agent", s.userAgent)
}
httpx.Apply(req, s.extraHeaders)

resp, err := s.httpClient.Do(req)
if err != nil {
return nil, err
Expand Down Expand Up @@ -212,6 +230,11 @@ func (s *OAuthService) postJSON(ctx context.Context, path, bearerToken string, b
req.Header.Set("Authorization", "Bearer "+bearerToken)
}

if s.userAgent != "" {
req.Header.Set("User-Agent", s.userAgent)
}
httpx.Apply(req, s.extraHeaders)

resp, err := s.httpClient.Do(req)
if err != nil {
return err
Expand Down
40 changes: 37 additions & 3 deletions internal/api/oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,20 @@ func TestOAuthSignupEndpoints(t *testing.T) {
var calls []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, r.Method+" "+r.URL.Path)
if got := r.Header.Get("User-Agent"); got != "rc-test/1" {
t.Errorf("%s User-Agent = %q", r.URL.Path, got)
}
if got := r.Header.Get("X-Trace"); got != "test-trace" {
t.Errorf("%s X-Trace = %q", r.URL.Path, got)
}
if r.URL.Path == "/v1/developers/provision-account" || r.URL.Path == "/v1/developers/login" || r.URL.Path == "/oauth2/token" {
if got := r.Header.Get("Authorization"); got != "" {
t.Errorf("%s unexpected Authorization = %q", r.URL.Path, got)
}
}

w.Header().Set("Content-Type", "application/json")
if r.Header.Get("X-Requested-With") != "XMLHttpRequest" {
if r.URL.Path != "/oauth2/token" && r.Header.Get("X-Requested-With") != "XMLHttpRequest" {
t.Errorf("%s missing required X-Requested-With header", r.URL.Path)
}
switch r.URL.Path {
Expand Down Expand Up @@ -44,6 +56,14 @@ func TestOAuthSignupEndpoints(t *testing.T) {
query.Set("state", r.URL.Query().Get("state"))
redirect.RawQuery = query.Encode()
_, _ = fmt.Fprintf(w, `{"redirect_uri":%q}`, redirect.String())
case "/oauth2/token":
if err := r.ParseForm(); err != nil {
t.Error(err)
}
if r.Form.Get("client_id") != "cli-client" {
t.Errorf("unexpected client_id: %q", r.Form.Get("client_id"))
}
_, _ = fmt.Fprint(w, `{"access_token":"access","refresh_token":"refresh","expires_in":3600}`)
case "/v1/developers/logout":
w.WriteHeader(http.StatusOK)
default:
Expand All @@ -52,7 +72,12 @@ func TestOAuthSignupEndpoints(t *testing.T) {
}))
t.Cleanup(server.Close)

service := NewOAuthService(server.URL, "cli-client")
service := NewOAuthService(OAuthOptions{
BaseURL: server.URL,
ClientID: "cli-client",
UserAgent: "rc-test/1",
ExtraHeaders: http.Header{"X-Trace": {"test-trace"}, "Authorization": {"Bearer override"}},
})
ctx := context.Background()
if err := service.ProvisionAccount(ctx, ProvisionAccountRequest{
Email: "dev@example.com", Name: "Developer", Password: "generated", MarketingEmailEnabled: true,
Expand All @@ -74,11 +99,20 @@ func TestOAuthSignupEndpoints(t *testing.T) {
t.Fatal(err)
}

if _, err := service.ExchangeCode(ctx, code, "http://localhost:49152/callback", "verifier"); err != nil {
t.Fatal(err)
}
if _, err := service.Refresh(ctx, "refresh-token"); err != nil {
t.Fatal(err)
}

want := []string{
"POST /v1/developers/provision-account",
"POST /v1/developers/login",
"POST /v1/developers/me/oauth-authorize",
"POST /v1/developers/logout",
"POST /oauth2/token",
"POST /oauth2/token",
}
if fmt.Sprint(calls) != fmt.Sprint(want) {
t.Fatalf("calls = %v, want %v", calls, want)
Expand All @@ -92,7 +126,7 @@ func TestAuthorizeWithLoginTokenRejectsMismatchedRedirect(t *testing.T) {
}))
t.Cleanup(server.Close)

service := NewOAuthService(server.URL, "cli-client")
service := NewOAuthService(OAuthOptions{BaseURL: server.URL, ClientID: "cli-client"})
_, err := service.AuthorizeWithLoginToken(context.Background(), "token", "http://localhost:49152/callback", "challenge", "state")
if err == nil {
t.Fatal("expected mismatched redirect error")
Expand Down
26 changes: 20 additions & 6 deletions internal/api/sdk.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,24 @@ import (
"net/url"
"strings"
"time"

"github.com/revenuecat/cli/internal/httpx"
)

const DefaultSDKBaseURL = "https://api.revenuecat.com/v1"

type SDKOptions struct {
BaseURL string
HTTPClient *http.Client
UserAgent string
ExtraHeaders http.Header
}

type SDKService struct {
baseURL *url.URL
http *http.Client
userAgent string
baseURL *url.URL
http *http.Client
userAgent string
extraHeaders http.Header
}

type SimulatedPurchase struct {
Expand All @@ -27,8 +37,10 @@ type SimulatedPurchase struct {
SDKOriginated bool `json:"sdk_originated"`
}

func NewSDKService(v2BaseURL string, httpClient *http.Client, userAgent string) *SDKService {
base := v2BaseURL
func NewSDKService(opts SDKOptions) *SDKService {
httpClient := opts.HTTPClient
userAgent := opts.UserAgent
base := opts.BaseURL
if base == "" || base == DefaultBaseURL {
base = DefaultSDKBaseURL
} else {
Expand All @@ -47,7 +59,7 @@ func NewSDKService(v2BaseURL string, httpClient *http.Client, userAgent string)
if userAgent == "" {
userAgent = "revenuecat-cli/dev"
}
return &SDKService{baseURL: u, http: httpClient, userAgent: userAgent}
return &SDKService{baseURL: u, http: httpClient, userAgent: userAgent, extraHeaders: opts.ExtraHeaders}
}

func (s *SDKService) Offerings(ctx context.Context, publicAPIKey, appUserID string) (json.RawMessage, error) {
Expand All @@ -63,6 +75,7 @@ func (s *SDKService) Offerings(ctx context.Context, publicAPIKey, appUserID stri
req.Header.Set("Authorization", "Bearer "+publicAPIKey)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", s.userAgent)
httpx.Apply(req, s.extraHeaders)
resp, err := s.http.Do(req)
if err != nil {
return nil, err
Expand Down Expand Up @@ -93,6 +106,7 @@ func (s *SDKService) SimulatePurchase(ctx context.Context, publicAPIKey string,
req.Header.Set("X-Platform", "iOS")
req.Header.Set("X-Version", "rc-cli")
req.Header.Set("X-Client-Bundle-Id", "com.revenuecat.cli")
httpx.Apply(req, s.extraHeaders)
resp, err := s.http.Do(req)
if err != nil {
return nil, err
Expand Down
24 changes: 22 additions & 2 deletions internal/api/sdk_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,22 @@ func TestSDKOfferingsUsesPublicKeyAndV1Path(t *testing.T) {
if r.Header.Get("Authorization") != "Bearer test_public" {
t.Fatalf("authorization = %q", r.Header.Get("Authorization"))
}
if got := r.Header.Get("User-Agent"); got != "override-ua" {
t.Errorf("User-Agent = %q", got)
}
if got := r.Header.Get("X-Trace"); got != "test-trace" {
t.Errorf("X-Trace = %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"current_offering_id":"default","offerings":[]}`))
}))
t.Cleanup(srv.Close)

service := api.NewSDKService(srv.URL+"/v2", nil, "test")
service := api.NewSDKService(api.SDKOptions{
BaseURL: srv.URL + "/v2",
UserAgent: "test",
ExtraHeaders: http.Header{"X-Trace": {"test-trace"}, "User-Agent": {"override-ua"}, "Authorization": {"Bearer override"}},
})
result, err := service.Offerings(context.Background(), "test_public", "user/one")
if err != nil {
t.Fatal(err)
Expand All @@ -48,12 +58,22 @@ func TestSDKSimulatePurchasePostsReceipt(t *testing.T) {
if body.FetchToken != "TEST_token" || body.AppUserID != "user" || body.ProductID != "monthly" || !body.SDKOriginated {
t.Fatalf("unexpected body: %+v", body)
}
if got := r.Header.Get("User-Agent"); got != "override-ua" {
t.Errorf("User-Agent = %q", got)
}
if got := r.Header.Get("X-Trace"); got != "test-trace" {
t.Errorf("X-Trace = %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"subscriber":{"entitlements":{"premium":{"expires_date":null}}}}`))
}))
t.Cleanup(srv.Close)

service := api.NewSDKService(srv.URL+"/v2", nil, "test")
service := api.NewSDKService(api.SDKOptions{
BaseURL: srv.URL + "/v2",
UserAgent: "test",
ExtraHeaders: http.Header{"X-Trace": {"test-trace"}, "User-Agent": {"override-ua"}, "Authorization": {"Bearer override"}},
})
result, err := service.SimulatePurchase(context.Background(), "test_public", api.SimulatedPurchase{
FetchToken: "TEST_token", AppUserID: "user", ProductID: "monthly", InitiationSource: "purchase", SDKOriginated: true,
})
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/analytics.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func doNotTrack() bool {
return false
}

// requestHeaders assembles the extra headers sent on every v2 API request: the
// requestHeaders assembles the extra headers sent on every RevenueCat API request: the
// usage-analytics headers (dropped when DO_NOT_TRACK is set — the request still
// goes through, just unlabeled) plus any user-supplied RC_HEADERS, which
// override so operators keep the final say.
Expand Down
Loading
Loading