Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9e298b9
feat(targeting): manage rules and guard activation
joshdholtz Sep 25, 2026
d59f790
fix(targeting): show rule impact before activation
joshdholtz Sep 25, 2026
f3a8a56
fix(targeting): show resulting audience on updates
joshdholtz Sep 25, 2026
ea3fb0b
fix(targeting): show rule details in human output
joshdholtz Sep 25, 2026
cd2b531
feat(targeting): expose rule config fields in schema
joshdholtz Sep 25, 2026
56c7ac8
test(targeting): exercise approved rule reorder
joshdholtz Sep 25, 2026
ca76e4a
fix(targeting): make activation previews readable
joshdholtz Sep 28, 2026
d0c02d6
fix(targeting): validate audience transitions and document nested fields
joshdholtz Sep 28, 2026
a36968a
docs(targeting): explain audience transitions in command schema
joshdholtz Sep 28, 2026
cd533b7
fix(targeting): require force to delete live rules
joshdholtz Sep 28, 2026
86c6fdc
docs(targeting): explain scheduled offering rules
joshdholtz Sep 29, 2026
18c26be
test(targeting): snapshot scheduled rule preview
joshdholtz Sep 29, 2026
16ee1e3
chore(targeting): integrate experiment review fixes
joshdholtz Oct 6, 2026
d02f717
fix(targeting): expose ordering contract and verify scheduled updates
joshdholtz Oct 6, 2026
ff46780
fix(targeting): support checkpoint rule updates
joshdholtz Oct 6, 2026
0c94120
fix(targeting): preserve preview state and document null values
joshdholtz Oct 6, 2026
1c7ba3e
chore(targeting): integrate lifecycle review followups
joshdholtz Oct 6, 2026
7846ec1
chore(targeting): integrate condition schema fix
joshdholtz Oct 6, 2026
85548b0
fix(targeting): include every page in rule pickers
joshdholtz Oct 6, 2026
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
16 changes: 16 additions & 0 deletions docs/command-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,22 @@ rc experiments pause <id> # pauses enrollment; conf
rc experiments resume <id> # resumes enrollment; configuration review + confirmation/--yes
rc experiments stop <id> # permanent; confirmation/--yes

# Targeting rules — development v2 endpoints
rc targeting list [--state <state>]
rc targeting show <id> # human detail view shows audience, placements, schedule, and checkpoints
rc targeting create (--name <name> --offering <offering-id> [--state inactive|active] | --config <json-file>) # initially inactive
rc targeting update <id> --config <json-file> # partial update of an Offering or checkpoint rule
rc targeting delete <id> [--force] # active or scheduled rules require --force; confirmation/--yes

Legacy Offering rules use `state: "active"` with a future `schedule.start_date` to
start serving later. The API reports their state as `active` before that date,
but targeting skips them until the scheduled window. The `scheduled` state is
only for checkpoint rules. Checkpoint updates accept state, display_name,
audience_id, flow_id, checkpoints (exactly one checkpoint_id), and schedule.
Updates to active or scheduled rules, or transitions into those states, require
confirmation/--yes. Checkpoint updates cannot clear audience_id or set Offering
fields or position.

# Rico (AI assistant)
rc rico [message] # streaming chat window in a TTY (--plain for a line loop)
rc rico --continue # continue the most recent conversation
Expand Down
43 changes: 43 additions & 0 deletions docs/previews/targeting-create-active.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
48 changes: 48 additions & 0 deletions docs/previews/targeting-create-scheduled.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 19 additions & 0 deletions docs/previews/targeting-list.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
32 changes: 32 additions & 0 deletions docs/previews/targeting-show.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
45 changes: 45 additions & 0 deletions docs/previews/targeting-update-checkpoint.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions docs/specs/cli-coverage.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,18 @@ endpoints:
- method: GET
path: /projects/{project_id}/experiments/{experiment_id}/results

# Targeting rules (development-status; provided by the beta overlay)
- method: GET
path: /projects/{project_id}/targeting_rules
- method: POST
path: /projects/{project_id}/targeting_rules
- method: GET
path: /projects/{project_id}/targeting_rules/{targeting_rule_id}
- method: POST
path: /projects/{project_id}/targeting_rules/{targeting_rule_id}
- method: DELETE
path: /projects/{project_id}/targeting_rules/{targeting_rule_id}

# Audit
- method: GET
path: /projects/{project_id}/audit_logs
Expand Down
69 changes: 69 additions & 0 deletions docs/specs/v2-beta-overlay.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1098,6 +1098,75 @@ paths:
responses:
'200':
description: OK
/projects/{project_id}/targeting_rules:
parameters:
- name: project_id
in: path
required: true
schema:
type: string
get:
summary: Get a list of targeting rules
operationId: list-targeting-rules
x-release-status: development
x-revenuecat-release-gatekeeping: false
x-scopes:
- project_configuration:targeting_rules:read
responses:
'200':
description: OK
post:
summary: Create a targeting rule
operationId: create-targeting-rule
x-release-status: development
x-revenuecat-release-gatekeeping: false
x-scopes:
- project_configuration:targeting_rules:read_write
responses:
'200':
description: OK
/projects/{project_id}/targeting_rules/{targeting_rule_id}:
parameters:
- name: project_id
in: path
required: true
schema:
type: string
- name: targeting_rule_id
in: path
required: true
schema:
type: string
delete:
summary: Delete a targeting rule
operationId: delete-targeting-rule
x-release-status: development
x-revenuecat-release-gatekeeping: false
x-scopes:
- project_configuration:targeting_rules:read_write
responses:
'200':
description: OK
get:
summary: Get a targeting rule
operationId: get-targeting-rule
x-release-status: development
x-revenuecat-release-gatekeeping: false
x-scopes:
- project_configuration:targeting_rules:read
responses:
'200':
description: OK
post:
summary: Update a targeting rule
operationId: update-targeting-rule
x-release-status: development
x-revenuecat-release-gatekeeping: false
x-scopes:
- project_configuration:targeting_rules:read_write
responses:
'200':
description: OK
components:
schemas:
Currency:
Expand Down
2 changes: 2 additions & 0 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ type Client struct {
Entitlements *EntitlementsService
Offerings *OfferingsService
Experiments *ExperimentsService
TargetingRules *TargetingRulesService
Packages *PackagesService
Products *ProductsService
Subscriptions *SubscriptionsService
Expand Down Expand Up @@ -99,6 +100,7 @@ func NewClient(opts Options) *Client {
c.Entitlements = &EntitlementsService{c: c}
c.Offerings = &OfferingsService{c: c}
c.Experiments = &ExperimentsService{c: c}
c.TargetingRules = &TargetingRulesService{c: c}
c.Packages = &PackagesService{c: c}
c.Products = &ProductsService{c: c}
c.Subscriptions = &SubscriptionsService{c: c}
Expand Down
8 changes: 8 additions & 0 deletions internal/api/paths_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

91 changes: 91 additions & 0 deletions internal/api/targeting_rules.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package api

import (
"context"
"encoding/json"
"net/http"
"net/url"
"strconv"
)

type TargetingRulesService struct{ c *Client }

type TargetingRule struct {
Object string `json:"object"`
ID string `json:"id"`
RuleType string `json:"rule_type"`
State string `json:"state"`
DisplayName string `json:"display_name"`
OfferingID string `json:"offering_id,omitempty"`
AudienceID *string `json:"audience_id,omitempty"`
Conditions []any `json:"conditions,omitempty"`
Schedule any `json:"schedule,omitempty"`
Placements any `json:"placements,omitempty"`
FlowID string `json:"flow_id,omitempty"`
Checkpoints []any `json:"checkpoints,omitempty"`
}
Comment thread
cursor[bot] marked this conversation as resolved.

type ListTargetingRulesOptions struct {
State string
Limit int
StartingAfter string
}

func (s *TargetingRulesService) List(ctx context.Context, projectID string, opts ListTargetingRulesOptions) (*Page[TargetingRule], error) {
q := url.Values{}
if opts.State != "" {
q.Set("state", opts.State)
}
if opts.Limit > 0 {
q.Set("limit", strconv.Itoa(opts.Limit))
}
if opts.StartingAfter != "" {
q.Set("starting_after", opts.StartingAfter)
}
path := pathTargetingRules(projectID)
if len(q) > 0 {
path += "?" + q.Encode()
}
var out Page[TargetingRule]
err := s.c.do(ctx, http.MethodGet, path, nil, &out)
return &out, err
}

func (s *TargetingRulesService) Get(ctx context.Context, projectID, id string) (*TargetingRule, error) {
var out TargetingRule
err := s.c.do(ctx, http.MethodGet, pathTargetingRule(projectID, id), nil, &out)
return &out, err
}

type TargetingRuleCreate struct {
RuleType string `json:"rule_type,omitempty"`
Position *int `json:"position,omitempty"`
ID string `json:"id,omitempty"`
State string `json:"state,omitempty"`
DisplayName string `json:"display_name"`
OfferingID string `json:"offering_id,omitempty"`
AudienceID string `json:"audience_id,omitempty"`
Conditions json.RawMessage `json:"conditions,omitempty"`
Schedule json.RawMessage `json:"schedule,omitempty"`
Placements json.RawMessage `json:"placements,omitempty"`
FlowID string `json:"flow_id,omitempty"`
Checkpoints json.RawMessage `json:"checkpoints,omitempty"`
}

type TargetingRuleUpdate map[string]json.RawMessage

func (s *TargetingRulesService) Create(ctx context.Context, projectID string, body TargetingRuleCreate) (*TargetingRule, error) {
var out TargetingRule
err := s.c.do(ctx, http.MethodPost, pathTargetingRules(projectID), body, &out)
return &out, err
}

func (s *TargetingRulesService) Update(ctx context.Context, projectID, id string, body TargetingRuleUpdate) (*TargetingRule, error) {
var out TargetingRule
err := s.c.do(ctx, http.MethodPost, pathTargetingRule(projectID, id), body, &out)
return &out, err
}

func (s *TargetingRulesService) Delete(ctx context.Context, projectID, id string) error {
return s.c.do(ctx, http.MethodDelete, pathTargetingRule(projectID, id), nil, nil)
}
67 changes: 67 additions & 0 deletions internal/api/targeting_rules_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package api_test

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"

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

func TestTargetingRuleRoutes(t *testing.T) {
requests := []string{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests = append(requests, r.Method+" "+r.URL.RequestURI())
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodPost && r.URL.Path == "/projects/proj/targeting_rules" {
var body api.TargetingRuleCreate
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.DisplayName != "US paywall" || body.OfferingID != "ofrng_us" || body.State != "inactive" {
t.Fatalf("unexpected create body: %+v", body)
}
}
if r.Method == http.MethodDelete {
fmt.Fprint(w, `{"object":"deleted_object","id":"trle1"}`)
return
}
if r.URL.Path == "/projects/proj/targeting_rules" && r.Method == http.MethodGet {
fmt.Fprint(w, `{"object":"list","items":[{"object":"targeting_rule","id":"trle1","rule_type":"legacy","state":"inactive","display_name":"US paywall","offering_id":"ofrng_us"}],"next_page":null,"url":"/projects/proj/targeting_rules"}`)
return
}
fmt.Fprint(w, `{"object":"targeting_rule","id":"trle1","rule_type":"legacy","state":"inactive","display_name":"US paywall","offering_id":"ofrng_us"}`)
}))
t.Cleanup(srv.Close)
client := api.NewClient(api.Options{APIKey: "sk_test", BaseURL: srv.URL})
ctx := context.Background()
page, err := client.TargetingRules.List(ctx, "proj", api.ListTargetingRulesOptions{State: "inactive", Limit: 5, StartingAfter: "trle0"})
if err != nil || len(page.Items) != 1 || page.Items[0].OfferingID != "ofrng_us" {
t.Fatalf("page=%+v err=%v", page, err)
}
if _, err := client.TargetingRules.Get(ctx, "proj", "trle1"); err != nil {
t.Fatal(err)
}
if _, err := client.TargetingRules.Create(ctx, "proj", api.TargetingRuleCreate{DisplayName: "US paywall", OfferingID: "ofrng_us", State: "inactive"}); err != nil {
t.Fatal(err)
}
if _, err := client.TargetingRules.Update(ctx, "proj", "trle1", api.TargetingRuleUpdate{"state": json.RawMessage(`"active"`)}); err != nil {
t.Fatal(err)
}
if err := client.TargetingRules.Delete(ctx, "proj", "trle1"); err != nil {
t.Fatal(err)
}
want := []string{
"GET /projects/proj/targeting_rules?limit=5&starting_after=trle0&state=inactive",
"GET /projects/proj/targeting_rules/trle1",
"POST /projects/proj/targeting_rules",
"POST /projects/proj/targeting_rules/trle1",
"DELETE /projects/proj/targeting_rules/trle1",
}
if fmt.Sprint(requests) != fmt.Sprint(want) {
t.Fatalf("requests = %v, want %v", requests, want)
}
}
65 changes: 65 additions & 0 deletions internal/cli/config_schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,67 @@ func configFieldsFor(cmd *cobra.Command) map[string]any {
return experimentConfigFields(true)
case "rc experiments update":
return experimentConfigFields(false)
case "rc targeting create":
return targetingConfigFields(true)
case "rc targeting update":
return targetingConfigFields(false)
}
return nil
}

func targetingConfigFields(create bool) map[string]any {
schedule := map[string]any{"type": "object", "description": "UTC timestamps ending in Z. Legacy Offering rules require start_date; set state to active with a future start_date to serve later.", "required_for_legacy": []string{"start_date"}, "properties": map[string]any{
"start_date": map[string]any{"type": []string{"string", "null"}, "description": "Start time in UTC, for example 2026-05-25T10:00:00Z. Null means no start date for a checkpoint rule; Offering schedules require a start date."},
"end_date": map[string]any{"type": []string{"string", "null"}, "description": "End time in UTC; omit or set null for no end date."},
}}
placements := map[string]any{"type": []string{"object", "null"}, "description": "Legacy rules only. On update, omit to keep overrides or set null to remove them; both fields are required when providing an object.", "required": []string{"fallback_offering_id", "placement_offerings"}, "properties": map[string]any{
"fallback_offering_id": map[string]any{"type": []string{"string", "null"}, "description": "Fallback Offering ID; null means no fallback."},
"placement_offerings": map[string]any{"type": "array", "items": map[string]any{
"type": "object", "required": []string{"placement_identifier", "offering_id"}, "properties": map[string]any{
"placement_identifier": configField("string", "Placement identifier, such as onboarding"),
"offering_id": map[string]any{"type": []string{"string", "null"}, "description": "Offering ID for this placement; null means no Offering override."},
},
}},
}}
fields := map[string]any{
"position": map[string]any{"type": "integer", "minimum": 1, "description": "One-based priority among rules in the same state; legacy rules only. List preserves evaluation order, but the API does not return absolute positions."},
"state": configEnum("Legacy Offering rules use active or inactive; use active with a future schedule.start_date to serve later. The scheduled state is checkpoint-only.", "active", "inactive", "scheduled"),
"display_name": configField("string", "Rule name"),
"offering_id": configField("string", "Offering ID served by a legacy rule"),
"audience_id": map[string]any{"type": []string{"string", "null"}, "description": "Audience ID; mutually exclusive with conditions. Set null when switching to conditions; set conditions to [] when switching to an audience. Checkpoint rules require a non-null audience ID."},
"conditions": targetingConditionsSchema(),
"schedule": schedule,
"placements": placements,
}
if create {
fields["rule_type"] = configEnum("Rule type; defaults to legacy", "legacy", "checkpoint")
fields["id"] = configField("string", "Optional custom ID for a legacy rule")
fields["flow_id"] = configField("string", "Flow ID served by a checkpoint rule")
fields["checkpoints"] = map[string]any{"type": "array", "description": "Exactly one checkpoint for a checkpoint rule", "items": map[string]any{
"type": "object", "required": []string{"checkpoint_id"}, "properties": map[string]any{
"checkpoint_id": configField("string", "Checkpoint ID"),
"position": map[string]any{"type": "integer", "minimum": 0, "description": "Priority within the checkpoint"},
},
}}
return map[string]any{"type": "object", "properties": fields, "description": "Legacy: display_name and offering_id required. Checkpoint: rule_type, display_name, audience_id, flow_id, checkpoints required."}
}
schedule["type"] = []string{"object", "null"}
schedule["description"] = "Omit to keep the current schedule. Set null to remove it, or provide UTC timestamps. Offering rules require start_date and state active to serve later; checkpoint rules allow an omitted start_date unless state is scheduled."
fields["flow_id"] = configField("string", "Flow ID served by a checkpoint rule; checkpoint-only")
fields["checkpoints"] = map[string]any{
"type": "array", "minItems": 1, "maxItems": 1,
"description": "Move a checkpoint rule to exactly one checkpoint; appends after that checkpoint's existing rules. Position cannot be set here.",
"items": map[string]any{
"type": "object", "required": []string{"checkpoint_id"}, "additionalProperties": false,
"properties": map[string]any{"checkpoint_id": configField("string", "Checkpoint ID")},
},
}
return map[string]any{
"type": "object", "properties": fields,
"description": "Partial update. Active or scheduled rules require confirmation. Checkpoint rules accept state, display_name, audience_id (non-null), flow_id, checkpoints, and schedule; Offering fields and position are legacy-only.",
}
}

func configField(kind, description string) map[string]any {
return map[string]any{"type": kind, "description": description}
}
Expand Down Expand Up @@ -99,6 +156,14 @@ func targetingConditionsSchema() map[string]any {
{"field": "platform", "operator": "in", "value": []string{"ios"}},
{"field": "app_version", "operator": ">=", "value": "1.2.0", "context": "app1a2b3c4"},
},
"field_rules": map[string]any{
"app_config": map[string]any{"operators": []string{"in", "not in"}, "value": "array of app IDs"},
"country": map[string]any{"operators": []string{"in", "not in"}, "value": "array of uppercase two-letter country codes"},
"platform": map[string]any{"operators": []string{"in", "not in"}, "value": "array of lowercase platforms", "values": []string{"amazon", "android", "ios", "macos", "roku", "tvos", "visionos", "watchos", "web"}},
"custom_attribute": map[string]any{"operators": []string{"in", "not in"}, "value": "array of strings or integers", "context": "required attribute key"},
"app_version": map[string]any{"operators": []string{"=", "!=", ">", ">=", "<", "<="}, "value": "semantic version string", "context": "required app ID"},
"sdk_version": map[string]any{"operators": []string{"=", "!=", ">", ">=", "<", "<="}, "value": "semantic version string", "context": "required SDK flavor, such as ios, android, flutter, or react-native"},
},
},
}
}
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ Agent-friendly entrypoints:
{newEntitlementsCmd(), "catalog"},
{newOfferingsCmd(), "catalog"},
{newExperimentsCmd(), "revenue"},
{newTargetingCmd(), "catalog"},
{newProductsCmd(), "catalog"},
{newSubscriptionsCmd(), "revenue"},
{newPurchasesCmd(), "revenue"},
Expand Down
Loading
Loading