@pulsora/revenue
The Pulsora revenue package enables server-side revenue tracking with full attribution to visitor journeys. Track purchases, subscriptions, and other monetary events while maintaining user privacy.
When to Use Revenue Package
Use @pulsora/revenue to:
- Attribute sales to campaigns - Know which marketing channels drive revenue
- Track MRR and churn - Monitor subscription metrics over time
- Calculate CAC and LTV - Understand customer acquisition costs and lifetime value
- Server-side tracking - Keep API tokens secure, never expose in browser
- Multi-touch attribution - See the entire visitor journey before purchase
- Payment processor integration - Works with Stripe, Paddle, and custom solutions
Important: This is a server-side package. Never use it in client-side code.
Key Features
💰 Complete Revenue Tracking
- One-time purchases - Track individual transactions
- Subscriptions - Monitor MRR, upgrades, and churn
- Refunds - Handle negative revenue events
- Multi-currency - Support for all major currencies
🎯 Full Attribution
- First-touch attribution - Original traffic source
- Last-touch attribution - Final conversion source
- Multi-touch journey - Complete visitor path
- UTM parameters - Campaign tracking
🔒 Privacy-First
- Server-side only - API tokens stay secure
- Hashed customer IDs - No PII storage
- GDPR compliant - Privacy by design
- Anonymous tracking - No personal data required
🚀 Easy Integration
- Webhook ready - Stripe, Paddle, and more
- TypeScript support - Full type safety
- Automatic retries - Reliable data delivery
- Small bundle - ~1.3KB gzipped
How It Works
1. Visitor Journey Tracking
When a visitor browses your site, Pulsora tracks:
- Traffic sources (Google, Facebook, etc.)
- Campaign parameters (UTM tags)
- Page views and events
- Session information
2. Payment Attribution
During checkout, you pass the visitor's fingerprint and session ID to your payment processor as metadata.
3. Revenue Recording
When payment webhooks fire, you use the metadata to attribute revenue back to the original visitor journey.
Installation
npm install @pulsora/revenue
⚠️ Important: This package is for server-side use only. Never expose your API token in client-side code.
Quick Start
Basic Setup
import { Revenue } from '@pulsora/revenue';
const revenue = new Revenue({
apiToken: process.env.PULSORA_API_TOKEN,
});
Track a Purchase
await revenue.track({
visitor_fingerprint: 'fp_from_checkout',
session_id: 'session_from_checkout',
customer_id: 'cus_123',
amount: 99.99,
currency: 'USD',
transaction_id: 'tx_abc123',
payment_processor: 'stripe',
event_type: 'purchase',
});
Track a Subscription
await revenue.track({
visitor_fingerprint: 'fp_from_checkout',
session_id: 'session_from_checkout',
customer_id: 'cus_123',
amount: 29.99,
currency: 'USD',
transaction_id: 'sub_def456',
payment_processor: 'stripe',
event_type: 'subscription',
customer_subscription_id: 'sub_123',
mrr_impact: 29.99,
is_recurring: true,
metadata: {
plan: 'pro',
interval: 'monthly',
},
});
Event Types
purchase
One-time payment for a product or service.
{
event_type: 'purchase',
amount: 99.99,
currency: 'USD'
}
subscription
New recurring subscription.
{
event_type: 'subscription',
amount: 29.99,
mrr_impact: 29.99,
is_recurring: true
}
renewal
Subscription renewal payment.
{
event_type: 'renewal',
amount: 29.99,
is_recurring: true
}
upgrade
Customer upgraded their plan.
{
event_type: 'upgrade',
amount: 49.99,
mrr_impact: 20.00, // Increase from previous plan
metadata: {
old_plan: 'basic',
new_plan: 'pro'
}
}
downgrade
Customer downgraded their plan.
{
event_type: 'downgrade',
amount: 9.99,
mrr_impact: -20.00, // Decrease from previous plan
metadata: {
old_plan: 'pro',
new_plan: 'basic'
}
}
churn
Customer cancelled their subscription.
{
event_type: 'churn',
amount: 0,
mrr_impact: -29.99,
metadata: {
reason: 'too_expensive',
feedback: 'Great product but outside our budget'
}
}
refund
Full or partial refund issued.
{
event_type: 'refund',
amount: -99.99, // Negative amount
transaction_id: 'refund_123',
metadata: {
original_transaction: 'tx_abc123',
reason: 'customer_request'
}
}
Attribution Flow
Step 1: Collect Tracking Data
In your frontend during checkout:
import { Pulsora } from '@pulsora/core';
const pulsora = new Pulsora();
pulsora.init({ apiToken: 'public-token' });
// During checkout
const fingerprint = await pulsora.getVisitorFingerprint();
const sessionId = pulsora.getSessionId();
// Send to your backend
await createCheckout({
product_id: 'pro-plan',
visitor_fingerprint: fingerprint,
session_id: sessionId,
});
Step 2: Pass to Payment Processor
Include tracking data as metadata:
// Stripe example
const session = await stripe.checkout.sessions.create({
// ... other options
metadata: {
visitor_fingerprint: fingerprint,
session_id: sessionId,
},
});
Step 3: Track in Webhook
When payment completes:
// In your webhook handler
const revenue = new Revenue({ apiToken: process.env.PULSORA_API_TOKEN });
await revenue.track({
visitor_fingerprint: session.metadata.visitor_fingerprint,
session_id: session.metadata.session_id,
customer_id: session.customer,
amount: session.amount_total / 100,
currency: session.currency.toUpperCase(),
transaction_id: session.id,
payment_processor: 'stripe',
event_type: 'purchase',
});
Security Best Practices
1. Environment Variables
Always store your API token in environment variables:
const revenue = new Revenue({
apiToken: process.env.PULSORA_API_TOKEN,
});
2. Server-Side Only
Never use this package in the browser:
// ❌ BAD - Exposes your API token
const revenue = new Revenue({ apiToken: 'sec_...' });
// ✅ GOOD - Server-side only
app.post('/webhook', async (req, res) => {
const revenue = new Revenue({ apiToken: process.env.PULSORA_API_TOKEN });
// ...
});
3. Validate Webhooks
Always validate webhook signatures:
// Stripe example
const sig = req.headers['stripe-signature'];
const event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET,
);
Error Handling
The package includes automatic retry logic:
try {
await revenue.track({
// ... revenue data
});
} catch (error) {
console.error('Revenue tracking failed:', error);
// Log to your error service
errorReporter.log(error, {
context: 'revenue_tracking',
customer_id: customerId,
});
}
Configuration Options
const revenue = new Revenue({
apiToken: 'your-token', // Required
endpoint: 'https://custom.api', // Optional custom endpoint
debug: true, // Enable debug logging
maxRetries: 5, // Max retry attempts (default: 3)
retryBackoff: 2000, // Initial retry delay in ms (default: 1000)
});
Real-World Integration Examples
Complete Stripe Integration
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
import { Revenue } from '@pulsora/revenue';
import { headers } from 'next/headers';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const revenue = new Revenue({ apiToken: process.env.PULSORA_API_TOKEN! });
export async function POST(req: Request) {
const body = await req.text();
const signature = headers().get('stripe-signature')!;
let event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return Response.json({ error: 'Webhook signature verification failed' }, { status: 400 });
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
// Track purchase
await revenue.track({
visitor_fingerprint: session.metadata?.visitor_fingerprint,
session_id: session.metadata?.session_id,
customer_id: session.customer as string,
amount: session.amount_total! / 100,
currency: session.currency!.toUpperCase(),
transaction_id: session.id,
payment_processor: 'stripe',
event_type: 'purchase',
metadata: {
product: session.metadata?.product_name,
mode: session.mode
}
});
break;
}
case 'customer.subscription.created': {
const subscription = event.data.object;
const amount = subscription.items.data[0].price.unit_amount! / 100;
await revenue.track({
visitor_fingerprint: subscription.metadata.visitor_fingerprint,
session_id: subscription.metadata.session_id,
customer_id: subscription.customer as string,
customer_subscription_id: subscription.id,
amount,
currency: subscription.currency.toUpperCase(),
transaction_id: subscription.id,
payment_processor: 'stripe',
event_type: 'subscription',
mrr_impact: amount,
is_recurring: true,
metadata: {
plan: subscription.items.data[0].price.id,
interval: subscription.items.data[0].price.recurring?.interval
}
});
break;
}
case 'invoice.paid': {
const invoice = event.data.object;
// Skip first invoice (already tracked as subscription)
if (invoice.billing_reason === 'subscription_create') break;
await revenue.track({
customer_id: invoice.customer as string,
customer_subscription_id: invoice.subscription as string,
amount: invoice.amount_paid / 100,
currency: invoice.currency.toUpperCase(),
transaction_id: invoice.id,
payment_processor: 'stripe',
event_type: 'renewal',
is_recurring: true
});
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object;
const previousAttributes = event.data.previous_attributes;
// Check if plan changed
if (previousAttributes?.items) {
const oldAmount = previousAttributes.items.data[0].price.unit_amount / 100;
const newAmount = subscription.items.data[0].price.unit_amount / 100;
const mrrImpact = newAmount - oldAmount;
await revenue.track({
customer_id: subscription.customer as string,
customer_subscription_id: subscription.id,
amount: newAmount,
currency: subscription.currency.toUpperCase(),
transaction_id: subscription.id,
payment_processor: 'stripe',
event_type: mrrImpact > 0 ? 'upgrade' : 'downgrade',
mrr_impact: mrrImpact,
is_recurring: true,
metadata: {
old_plan: previousAttributes.items.data[0].price.id,
new_plan: subscription.items.data[0].price.id
}
});
}
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object;
const amount = subscription.items.data[0].price.unit_amount! / 100;
await revenue.track({
customer_id: subscription.customer as string,
customer_subscription_id: subscription.id,
amount: 0,
currency: subscription.currency.toUpperCase(),
transaction_id: subscription.id,
payment_processor: 'stripe',
event_type: 'churn',
mrr_impact: -amount,
metadata: {
cancellation_reason: subscription.cancellation_details?.reason,
feedback: subscription.cancellation_details?.feedback
}
});
break;
}
case 'charge.refunded': {
const charge = event.data.object;
await revenue.track({
customer_id: charge.customer as string,
amount: -(charge.amount_refunded / 100),
currency: charge.currency.toUpperCase(),
transaction_id: `refund_${charge.id}`,
payment_processor: 'stripe',
event_type: 'refund',
metadata: {
original_charge: charge.id,
reason: charge.refunds?.data[0]?.reason
}
});
break;
}
}
return Response.json({ received: true });
}
Paddle Webhook Integration
// app/api/webhooks/paddle/route.ts
import { Revenue } from '@pulsora/revenue';
import crypto from 'crypto';
const revenue = new Revenue({ apiToken: process.env.PULSORA_API_TOKEN! });
function verifyPaddleWebhook(body: string, signature: string): boolean {
const hash = crypto
.createHmac('sha256', process.env.PADDLE_PUBLIC_KEY!)
.update(body)
.digest('hex');
return hash === signature;
}
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get('paddle-signature')!;
if (!verifyPaddleWebhook(body, signature)) {
return Response.json({ error: 'Invalid signature' }, { status: 401 });
}
const event = JSON.parse(body);
switch (event.event_type) {
case 'subscription_payment_succeeded': {
const isFirstPayment = event.subscription_payment_id === event.subscription_id;
await revenue.track({
visitor_fingerprint: event.passthrough?.visitor_fingerprint,
session_id: event.passthrough?.session_id,
customer_id: event.user_id,
customer_subscription_id: event.subscription_id,
amount: parseFloat(event.sale_gross),
currency: event.currency,
transaction_id: event.subscription_payment_id,
payment_processor: 'paddle',
event_type: isFirstPayment ? 'subscription' : 'renewal',
mrr_impact: isFirstPayment ? parseFloat(event.sale_gross) : 0,
is_recurring: true,
metadata: {
plan_id: event.subscription_plan_id,
next_payment: event.next_payment_date
}
});
break;
}
case 'subscription_cancelled': {
await revenue.track({
customer_id: event.user_id,
customer_subscription_id: event.subscription_id,
amount: 0,
currency: event.currency,
transaction_id: event.subscription_id,
payment_processor: 'paddle',
event_type: 'churn',
mrr_impact: -parseFloat(event.cancellation_effective_date_mrr),
metadata: {
cancellation_reason: event.cancellation_reason
}
});
break;
}
}
return Response.json({ success: true });
}
Custom Payment Solution
// For custom payment processors or manual invoicing
import { Revenue } from '@pulsora/revenue';
const revenue = new Revenue({ apiToken: process.env.PULSORA_API_TOKEN! });
// Track manual payment
async function recordManualPayment(payment) {
await revenue.track({
visitor_fingerprint: payment.visitor_fingerprint,
session_id: payment.session_id,
customer_id: payment.customer_id,
amount: payment.amount,
currency: payment.currency || 'USD',
transaction_id: payment.invoice_number,
payment_processor: 'manual',
event_type: 'purchase',
metadata: {
invoice_number: payment.invoice_number,
payment_method: payment.payment_method,
notes: payment.notes
}
});
}
// Track enterprise deal
async function recordEnterpriseDeal(deal) {
await revenue.track({
customer_id: deal.company_id,
amount: deal.annual_contract_value / 12,
currency: 'USD',
transaction_id: deal.contract_id,
payment_processor: 'enterprise',
event_type: 'subscription',
customer_subscription_id: deal.contract_id,
mrr_impact: deal.annual_contract_value / 12,
is_recurring: true,
metadata: {
contract_term: '12 months',
seats: deal.seats,
acv: deal.annual_contract_value
}
});
}
Attribution Models Explained
First-Touch Attribution
Credits revenue to the first marketing touchpoint (e.g., the original Google ad).
Use case: Understanding which channels drive new customer acquisition.
Example: A user clicks a Facebook ad, browses your site, leaves, returns via Google search 3 days later, and purchases. First-touch attributes to Facebook.
Last-Touch Attribution
Credits revenue to the last touchpoint before conversion (e.g., the final email click).
Use case: Understanding which channels close deals.
Example: Same journey as above, last-touch attributes to Google organic search.
Multi-Touch Attribution
Credits revenue across all touchpoints in the journey.
Use case: Understanding the complete customer journey and channel interactions.
Example: Facebook ad gets 40% credit, email gets 20%, Google gets 40%.
Pulsora tracks all touchpoints automatically, allowing you to analyze revenue with any attribution model in the dashboard.
Troubleshooting
Missing Attribution Data
Symptoms:
- Revenue tracked successfully but shows no attribution
- "Unknown source" in dashboard reports
Cause: Visitor fingerprint or session ID not passed from frontend to webhook
Solutions:
- Verify fingerprint collection:
// Frontend checkout page
const fingerprint = await pulsora.getVisitorFingerprint();
const sessionId = pulsora.getSessionId();
console.log('Fingerprint:', fingerprint); // Should be ~40 char string
console.log('Session:', sessionId); // Should be UUID format
if (!fingerprint || !sessionId) {
console.error('Missing tracking data!');
}
- Verify metadata is passed to payment processor:
// Stripe checkout - check metadata is set
const session = await stripe.checkout.sessions.create({
// ...
metadata: {
visitor_fingerprint: fingerprint,
session_id: sessionId,
},
});
console.log('Metadata:', session.metadata);
- Verify metadata reaches webhook:
// In webhook handler
console.log('Received metadata:', event.data.object.metadata);
if (!event.data.object.metadata?.visitor_fingerprint) {
console.error('Missing fingerprint in webhook!');
}
Revenue Not Appearing in Dashboard
Symptoms:
- Webhook fires successfully (200 response)
- No errors in logs
- Revenue not visible in Pulsora dashboard
Debugging checklist:
- Verify API token:
// ❌ Wrong - using public token (pub_)
const revenue = new Revenue({ apiToken: 'pub_xxx' });
// ✅ Correct - using secret token (sec_)
const revenue = new Revenue({ apiToken: 'sec_xxx' });
- Check customer_id format:
// Ensure customer_id is a string
await revenue.track({
customer_id: String(customerId), // Convert to string
// ...
});
- Enable debug mode:
const revenue = new Revenue({
apiToken: process.env.PULSORA_API_TOKEN,
debug: true, // See detailed logs
});
Duplicate Revenue Events
Cause: Webhook fired multiple times or manual tracking duplicates
Prevention:
- Use idempotency via transaction_id:
// Pulsora automatically deduplicates based on transaction_id
await revenue.track({
transaction_id: uniqueTransactionId, // Must be unique per event
// ...
});
- Check webhook signature:
// Always verify webhooks are legitimate
try {
const event = stripe.webhooks.constructEvent(body, sig, secret);
} catch (err) {
return Response.json({ error: 'Invalid signature' }, { status: 400 });
}
TypeScript Type Errors
Error: Property 'visitor_fingerprint' does not exist
Solution:
import type { RevenueEvent } from '@pulsora/revenue';
const event: RevenueEvent = {
visitor_fingerprint: fingerprint,
session_id: sessionId,
customer_id: 'cus_123',
amount: 99.99,
currency: 'USD',
transaction_id: 'tx_123',
payment_processor: 'stripe',
event_type: 'purchase',
};
await revenue.track(event);
MRR Calculation Issues
Problem: MRR doesn't match expectations
Common mistakes:
- Wrong mrr_impact for renewals:
// ❌ Wrong - renewals shouldn't affect MRR
await revenue.track({
event_type: 'renewal',
mrr_impact: 29.99, // Don't set for renewals!
});
// ✅ Correct - only set mrr_impact for new subs, upgrades, downgrades, churn
await revenue.track({
event_type: 'renewal',
// No mrr_impact
});
- Annual subscriptions:
// For annual plans, divide by 12 for MRR
await revenue.track({
amount: 299.99, // Annual payment
mrr_impact: 299.99 / 12, // Monthly recurring
event_type: 'subscription',
metadata: { interval: 'yearly' },
});
Next Steps
- Stripe Integration Guide - Complete Stripe webhook setup
- Paddle Integration Guide - Complete Paddle webhook setup
- Custom Integration Guide - Build custom payment integrations
- API Reference - Complete method documentation
- Revenue Attribution - Configure revenue reports and attribution
- Debugging Guide - Common issues and solutions