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
7 changes: 7 additions & 0 deletions .changeset/remove-authoritative-lifecycle-billing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@outlit/browser": major
"@outlit/core": major
"@outlit/node": major
---

Remove the manual `user.activate()`, `user.engaged()`, `user.inactive()`, `customer.trialing()`, `customer.paid()`, and `customer.churned()` APIs together with the `StageEvent`, `BillingEvent`, `ExplicitJourneyStage`, `BillingStatus`, and `CustomerIdentifier` ingest types. Send ordinary identity and product events with `identify()` and `track()`; Outlit Core derives activation from the customer-selected ordinary activation event, derives engagement and inactivity from activity, and receives billing status from verified integrations.
27 changes: 12 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,17 +89,15 @@ outlit.track('button_clicked', {
page: '/homepage',
})

// Mark billing status on a customer
outlit.customer.trialing({
customerId: 'cust_123', // Your app's account/workspace/customer ID
properties: { plan: 'pro' },
})
// Track meaningful product activity. Core derives lifecycle stages from
// ordinary events, including your selected activation event.
outlit.track('onboarding_completed', { flow: 'self_serve' })
```

#### Using the singleton API

```typescript
import { init, track, user, customer } from '@outlit/browser'
import { init, track, user } from '@outlit/browser'

// Initialize once at app startup
init({ publicKey: 'pk_xxx' })
Expand All @@ -110,10 +108,7 @@ user().identify({
email: 'user@example.com',
customerId: 'cust_123', // Your app's account/workspace/customer ID
})
customer().paid({
customerId: 'cust_123', // Your app's account/workspace/customer ID
properties: { plan: 'pro' },
})
track('subscription_upgraded', { plan: 'pro' })
```

#### Using with React
Expand All @@ -132,10 +127,10 @@ function App() {

// Use in components
function MyComponent() {
const { track, user } = useOutlit()
const { track } = useOutlit()

return (
<button onClick={() => user.activate({ milestone: 'onboarding' })}>
<button onClick={() => track('onboarding_completed')}>
Click me
</button>
)
Expand Down Expand Up @@ -173,9 +168,11 @@ outlit.user.identify({
customerTraits: { plan: 'pro' },
})

// Mark customer billing status
outlit.customer.paid({
customerId: 'cust_123', // Your app's account/workspace/customer ID
// Track ordinary product events. Billing status comes from verified
// integrations such as Stripe, not authoritative SDK commands.
outlit.track({
customerId: 'cust_123',
eventName: 'subscription_upgraded',
properties: { plan: 'pro' },
})

Expand Down
4 changes: 4 additions & 0 deletions crates/outlit/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Removed

- Remove manual lifecycle and billing builders and their `stage`/`billing` payload variants. Use ordinary `track()` events for product activity and verified integrations for billing state.

## [0.2.2](https://github.com/OutlitAI/outlit-sdk/compare/outlit-v0.2.1...outlit-v0.2.2) - 2026-04-14

### Other
Expand Down
41 changes: 9 additions & 32 deletions crates/outlit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,10 @@ async fn main() -> Result<(), outlit::Error> {
.send()
.await?;

// User journey stages
client.user().activate(email("user@example.com"))
.property("onboarding_completed", true)
.send()
.await?;

// Customer billing
client.customer().paid("acme.com")
.customer_id("cust_123")
.stripe_customer_id("cus_xxx")
// Track the ordinary event selected as your activation signal.
// Outlit Core derives activation from this event.
client.track("onboarding_completed", email("user@example.com"))
.property("flow", "self_serve")
.send()
.await?;

Expand Down Expand Up @@ -119,30 +113,13 @@ client.identify(email("user@example.com"))
.await?;
```

### Activation

```rust
client.user().activate(email("...")).send().await?;
```

Outlit handles engagement and inactivity automatically from tracked product activity.
### Lifecycle and billing

### Customer Billing

```rust
client.customer().trialing("domain.com").send().await?;
client.customer().paid("domain.com")
.customer_id("cust_123")
.stripe_customer_id("cus_xxx")
.send()
.await?;
client.customer().churned("domain.com")
.property("reason", "pricing")
.send()
.await?;
```
Use `track()` for product activity. Outlit Core derives activation from the customer-selected
ordinary activation event and derives engagement and inactivity from activity. Billing status
comes from verified integrations such as Stripe.

### Lifecycle
### Client lifecycle

```rust
// Force flush pending events
Expand Down
200 changes: 1 addition & 199 deletions crates/outlit/src/builders.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
//! Event builders for fluent API.

use crate::types::{
BillingEventData, BillingStatus, CustomEventData, IdentifyEventData, JourneyStage,
StageEventData, TrackerEvent,
};
use crate::types::{CustomEventData, IdentifyEventData, TrackerEvent};
use crate::{Email, Fingerprint, UserId};
use serde_json::{json, Value};
use std::collections::HashMap;
Expand Down Expand Up @@ -248,157 +245,6 @@ impl IdentifyBuilder {
}
}

// ============================================
// STAGE BUILDER
// ============================================

/// Builder for stage events.
#[derive(Debug)]
pub struct StageBuilder {
stage: JourneyStage,
identity: Identity,
additional_email: Option<String>,
additional_user_id: Option<String>,
additional_fingerprint: Option<String>,
properties: HashMap<String, Value>,
}

impl StageBuilder {
pub(crate) fn new(stage: JourneyStage, identity: impl Into<Identity>) -> Self {
Self {
stage,
identity: identity.into(),
additional_email: None,
additional_user_id: None,
additional_fingerprint: None,
properties: HashMap::new(),
}
}

/// Add email (if identity was user_id or fingerprint).
pub fn email(mut self, email: impl Into<String>) -> Self {
self.additional_email = Some(email.into());
self
}

/// Add user_id (if identity was email or fingerprint).
pub fn user_id(mut self, user_id: impl Into<String>) -> Self {
self.additional_user_id = Some(user_id.into());
self
}

/// Add fingerprint (device identifier) to link this event to a device.
pub fn fingerprint(mut self, fingerprint: impl Into<String>) -> Self {
self.additional_fingerprint = Some(fingerprint.into());
self
}

/// Add a property.
pub fn property(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
self.properties.insert(key.into(), value.into());
self
}

/// Build the event.
pub(crate) fn build(self) -> TrackerEvent {
let email = self
.identity
.email()
.map(String::from)
.or(self.additional_email);
let user_id = self
.identity
.user_id()
.map(String::from)
.or(self.additional_user_id);
let fingerprint = self
.identity
.fingerprint()
.map(String::from)
.or(self.additional_fingerprint);

let mut properties = self.properties;
// Include identity in properties for server-side resolution
properties.insert("__email".into(), json!(email));
properties.insert("__userId".into(), json!(user_id));
properties.insert("__fingerprint".into(), json!(fingerprint));

TrackerEvent::Stage(StageEventData {
timestamp: now_ms(),
url: server_url(email.as_deref(), user_id.as_deref(), fingerprint.as_deref()),
path: "/".into(),
stage: self.stage,
properties: if properties.is_empty() {
None
} else {
Some(properties)
},
})
}
}

// ============================================
// BILLING BUILDER
// ============================================

/// Builder for billing events.
#[derive(Debug)]
pub struct BillingBuilder {
status: BillingStatus,
domain: String,
customer_id: Option<String>,
stripe_customer_id: Option<String>,
properties: HashMap<String, Value>,
}

impl BillingBuilder {
pub(crate) fn new(status: BillingStatus, domain: impl Into<String>) -> Self {
Self {
status,
domain: domain.into(),
customer_id: None,
stripe_customer_id: None,
properties: HashMap::new(),
}
}

/// Set customer ID.
pub fn customer_id(mut self, id: impl Into<String>) -> Self {
self.customer_id = Some(id.into());
self
}

/// Set Stripe customer ID.
pub fn stripe_customer_id(mut self, id: impl Into<String>) -> Self {
self.stripe_customer_id = Some(id.into());
self
}

/// Add a property.
pub fn property(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
self.properties.insert(key.into(), value.into());
self
}

/// Build the event.
pub(crate) fn build(self) -> TrackerEvent {
TrackerEvent::Billing(BillingEventData {
timestamp: now_ms(),
url: format!("server://{}", self.domain),
path: "/".into(),
status: self.status,
customer_id: self.customer_id,
stripe_customer_id: self.stripe_customer_id,
domain: Some(self.domain),
properties: if self.properties.is_empty() {
None
} else {
Some(self.properties)
},
})
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -518,48 +364,4 @@ mod tests {
panic!("Expected identify event");
}
}

#[test]
fn test_stage_builder() {
let event = StageBuilder::new(JourneyStage::Activated, email("user@example.com"))
.property("source", "onboarding")
.build();

if let TrackerEvent::Stage(data) = event {
assert!(matches!(data.stage, JourneyStage::Activated));
} else {
panic!("Expected stage event");
}
}

#[test]
fn test_stage_builder_with_fingerprint_identity() {
let event =
StageBuilder::new(JourneyStage::Activated, fingerprint("device_abc123")).build();

if let TrackerEvent::Stage(data) = event {
assert!(matches!(data.stage, JourneyStage::Activated));
let props = data.properties.unwrap();
assert_eq!(props.get("__fingerprint").unwrap(), "device_abc123");
} else {
panic!("Expected stage event");
}
}

#[test]
fn test_billing_builder() {
let event = BillingBuilder::new(BillingStatus::Paid, "acme.com")
.customer_id("cust_123")
.stripe_customer_id("cus_xxx")
.property("plan", "enterprise")
.build();

if let TrackerEvent::Billing(data) = event {
assert!(matches!(data.status, BillingStatus::Paid));
assert_eq!(data.domain, Some("acme.com".into()));
assert_eq!(data.customer_id, Some("cust_123".into()));
} else {
panic!("Expected billing event");
}
}
}
Loading
Loading