npm package: @stackbe/sdk
Description: Official JavaScript/TypeScript SDK for StackBE - the billing backend for your side project
Author: StackBE
Latest version: 0.15.4

# @stackbe/sdk

Official JavaScript/TypeScript SDK for [StackBE](https://stackbe.io) - the billing backend for your side project.

## Installation

```bash
npm install @stackbe/sdk
```

## Quick Start

```typescript
import { StackBE } from '@stackbe/sdk';

const stackbe = new StackBE({
  apiKey: process.env.STACKBE_API_KEY!,
  appId: process.env.STACKBE_APP_ID!,
});

// Track usage
await stackbe.usage.track('customer_123', 'api_calls');

// Check entitlements
const { hasAccess } = await stackbe.entitlements.check('customer_123', 'premium_export');

// Create checkout session
const { url } = await stackbe.checkout.createSession({
  customer: 'cust_123',
  planId: 'plan_pro',
  successUrl: 'https://myapp.com/success',
});

// Get subscription
const subscription = await stackbe.subscriptions.get('cust_123');

// Send magic link
await stackbe.auth.sendMagicLink('user@example.com');
```

## Modules

### Usage Tracking

Track billable usage events for your customers:

```typescript
// Track a single event
await stackbe.usage.track('customer_123', 'api_calls');

// Track multiple units
await stackbe.usage.track('customer_123', 'tokens', { quantity: 1500 });

// Check if within limits
const { allowed, remaining } = await stackbe.usage.check('customer_123', 'api_calls');
if (!allowed) {
  throw new Error('Usage limit exceeded');
}

// Get full usage summary
const usage = await stackbe.usage.get('customer_123');
console.log(usage.metrics);

// Track and check in one call
const result = await stackbe.usage.trackAndCheck('customer_123', 'api_calls');
if (!result.allowed) {
  // Handle limit exceeded
}
```

### Entitlements

Check feature access based on customer's plan:

```typescript
// Check single feature
const { hasAccess } = await stackbe.entitlements.check('customer_123', 'premium_export');

// Get all entitlements
const { entitlements, planName } = await stackbe.entitlements.getAll('customer_123');
// { premium_export: true, api_access: true, max_projects: 10 }

// Check multiple features at once
const features = await stackbe.entitlements.checkMany('customer_123', [
  'premium_export',
  'advanced_analytics',
]);

// Require a feature (throws if not available)
await stackbe.entitlements.require('customer_123', 'premium_export');
```

### Checkout

Create Stripe checkout sessions:

```typescript
// With existing customer ID
const { url } = await stackbe.checkout.createSession({
  customer: 'cust_123',
  planId: 'plan_pro_monthly',
  successUrl: 'https://myapp.com/success',
  cancelUrl: 'https://myapp.com/pricing',
});

// Redirect to checkout
res.redirect(url);

// With new customer (will be created)
const { url } = await stackbe.checkout.createSession({
  customer: { email: 'user@example.com', name: 'John' },
  planId: 'plan_pro_monthly',
  successUrl: 'https://myapp.com/success',
  trialDays: 14,
});

// Get checkout URL directly
const checkoutUrl = await stackbe.checkout.getCheckoutUrl({
  customer: 'cust_123',
  planId: 'plan_pro',
  successUrl: 'https://myapp.com/success',
});
```

### Subscriptions

Manage customer subscriptions:

```typescript
// Get current subscription
const subscription = await stackbe.subscriptions.get('cust_123');
if (subscription) {
  console.log(`Plan: ${subscription.plan.name}`);
  console.log(`Status: ${subscription.status}`);
}

// Check if customer has active subscription
const isActive = await stackbe.subscriptions.isActive('cust_123');

// Cancel subscription (at end of billing period)
await stackbe.subscriptions.cancel('sub_123');

// Cancel immediately
await stackbe.subscriptions.cancel('sub_123', { immediate: true });

// Update subscription (change plan)
await stackbe.subscriptions.update('sub_123', {
  planId: 'plan_enterprise',
  prorate: true,
});

// Reactivate canceled subscription
await stackbe.subscriptions.reactivate('sub_123');

// List all subscriptions
const subscriptions = await stackbe.subscriptions.list('cust_123');
```

### Authentication

Passwordless authentication with magic links:

```typescript
// Send magic link
await stackbe.auth.sendMagicLink('user@example.com');

// With redirect URL
await stackbe.auth.sendMagicLink('user@example.com', {
  redirectUrl: 'https://myapp.com/dashboard',
});

// For localhost development
await stackbe.auth.sendMagicLink('user@example.com', {
  useDev: true,
});

// Log user directly into a specific organization
// Customer must already be a member of the org
await stackbe.auth.sendMagicLink('user@example.com', {
  organizationId: 'org_abc123',
});

// Verify magic link token (in your /verify route)
const { token } = req.query;
const result = await stackbe.auth.verifyToken(token);

// result includes tenant and org context:
// - customerId, email, sessionToken
// - tenantId (your StackBE tenant)
// - organizationId, orgRole (if in org context)
res.cookie('session', result.sessionToken, { httpOnly: true });
res.redirect('/dashboard');

// Get session from token (includes tenant context)
const session = await stackbe.auth.getSession(sessionToken);
if (session) {
  console.log(session.customerId);
  console.log(session.email);
  console.log(session.tenantId);       // Tenant context
  console.log(session.organizationId); // Org context (if applicable)
  console.log(session.subscription);
  console.log(session.entitlements);
}

// Check if authenticated
const isAuthenticated = await stackbe.auth.isAuthenticated(sessionToken);

// Switch to a different organization (for multi-org users)
const orgs = await stackbe.organizations.listByCustomer(customerId);
if (orgs.length > 1) {
  // User belongs to multiple orgs - show org picker
  const { sessionToken: newToken, organizationId, orgRole } =
    await stackbe.auth.switchOrganization(sessionToken, targetOrgId);
  // Store newToken - it's scoped to the target org
}
```

### Plans & Products

List available pricing plans for your pricing page:

```typescript
// List all plans
const plans = await stackbe.plans.list();

// List active plans sorted by price
const plans = await stackbe.plans.listByPrice();
// [Free ($0), Starter ($9), Pro ($29), Enterprise ($99)]

// Filter by product
const plans = await stackbe.plans.list({ productId: 'prod_123' });

// Get a specific plan
const plan = await stackbe.plans.get('plan_123');
console.log(plan.name, plan.priceCents, plan.entitlements);

// List products
const products = await stackbe.products.list();

// Get product details
const product = await stackbe.products.get('prod_123');
```

#### Dynamic Pricing Page Example

```typescript
// Next.js pricing page
export default async function PricingPage() {
  const plans = await stackbe.plans.listByPrice();

  return (
    <div className="grid grid-cols-3 gap-4">
      {plans.map((plan) => (
        <div key={plan.id} className="border p-4 rounded">
          <h3>{plan.name}</h3>
          <p className="text-2xl">
            ${plan.priceCents / 100}/{plan.interval}
          </p>
          <ul>
            {Object.entries(plan.entitlements).map(([key, value]) => (
              <li key={key}>
                {key}: {value === true ? '✓' : value}
              </li>
            ))}
          </ul>
          <a href={`/checkout?plan=${plan.id}`}>
            {plan.priceCents === 0 ? 'Start Free' : 'Subscribe'}
          </a>
        </div>
      ))}
    </div>
  );
}
```

### Customer Management

```typescript
// Get customer
const customer = await stackbe.customers.get('cust_123');

// Get by email
const customer = await stackbe.customers.getByEmail('user@example.com');

// Create customer
const newCustomer = await stackbe.customers.create({
  email: 'user@example.com',
  name: 'John Doe',
  metadata: { source: 'api' },
});

// Get or create (idempotent)
const customer = await stackbe.customers.getOrCreate({
  email: 'user@example.com',
  name: 'John Doe',
});

// Update customer
await stackbe.customers.update('cust_123', { name: 'Jane Doe' });
```

### Organizations (B2B Multi-User)

Manage customer organizations for B2B apps:

```typescript
// Create organization with customer as owner
const org = await stackbe.organizations.create({
  name: 'Acme Corp',
  ownerId: customer.id,
});

// List all organizations
const orgs = await stackbe.organizations.list();

// List organizations for a specific customer (for org switchers)
const customerOrgs = await stackbe.organizations.listByCustomer('cust_123');

// Get organization by ID
const org = await stackbe.organizations.get('org_123');

// Update organization
await stackbe.organizations.update('org_123', { name: 'New Name' });

// Delete organization (must have no active subscriptions)
await stackbe.organizations.delete('org_123');

// Add member to organization
await stackbe.organizations.addMember('org_123', {
  customerId: 'cust_456',
  role: 'member', // 'admin' | 'member'
});

// Remove member
await stackbe.organizations.removeMember('org_123', 'member_456');

// Update member role
await stackbe.organizations.updateMember('org_123', 'member_456', {
  role: 'admin',
});

// Invite by email
await stackbe.organizations.invite('org_123', {
  email: 'newuser@company.com',
  role: 'member',
});

// List pending invites
const invites = await stackbe.organizations.listInvites('org_123');

// Cancel invite
await stackbe.organizations.cancelInvite('org_123', 'invite_456');
```

#### B2B Signup Flow

```typescript
async function signup(email: string, orgName: string) {
  // 1. Get or create customer
  const customer = await stackbe.customers.getOrCreate({ email });

  // 2. Create organization with customer as owner
  const org = await stackbe.organizations.create({
    name: orgName,
    ownerId: customer.id,
  });

  // 3. Send magic link
  await stackbe.auth.sendMagicLink(email);

  return { customer, org };
}
```

## Express Middleware

### Track Usage Automatically

```typescript
app.use(stackbe.middleware({
  getCustomerId: (req) => req.user?.customerId,
  metric: 'api_calls',
  skip: (req) => req.path === '/health',
}));
```

### Require Feature Entitlements

```typescript
app.get('/api/export',
  stackbe.requireFeature({
    getCustomerId: (req) => req.user?.customerId,
    feature: 'premium_export',
    onDenied: (req, res) => {
      res.status(403).json({ error: 'Upgrade to Pro' });
    },
  }),
  exportHandler
);
```

### Enforce Usage Limits

```typescript
app.use('/api',
  stackbe.enforceLimit({
    getCustomerId: (req) => req.user?.customerId,
    metric: 'api_calls',
    onLimitExceeded: (req, res, { current, limit }) => {
      res.status(429).json({ error: 'Rate limit exceeded', current, limit });
    },
  })
);
```

### Authenticate Requests

```typescript
app.use('/dashboard',
  stackbe.auth.middleware({
    getToken: (req) => req.cookies.session,
    onUnauthenticated: (req, res) => res.redirect('/login'),
  })
);

app.get('/dashboard', (req, res) => {
  // req.customer, req.subscription, req.entitlements are available
  res.json({ email: req.customer.email });
});
```

## Next.js Integration

### API Routes (App Router)

```typescript
// app/api/generate/route.ts
import { StackBE } from '@stackbe/sdk';
import { NextResponse } from 'next/server';

const stackbe = new StackBE({
  apiKey: process.env.STACKBE_API_KEY!,
  appId: process.env.STACKBE_APP_ID!,
});

export async function POST(request: Request) {
  const { customerId } = await request.json();

  // Check limits
  const { allowed, remaining } = await stackbe.usage.check(customerId, 'generations');
  if (!allowed) {
    return NextResponse.json({ error: 'Limit reached' }, { status: 429 });
  }

  // Track usage
  await stackbe.usage.track(customerId, 'generations');

  // Do work...
  return NextResponse.json({ success: true, remaining: remaining! - 1 });
}
```

### Server Actions

```typescript
'use server';

import { StackBE } from '@stackbe/sdk';

const stackbe = new StackBE({
  apiKey: process.env.STACKBE_API_KEY!,
  appId: process.env.STACKBE_APP_ID!,
});

export async function exportData(customerId: string) {
  const { hasAccess } = await stackbe.entitlements.check(customerId, 'data_export');
  if (!hasAccess) {
    throw new Error('Upgrade to Pro to export data');
  }
  // Perform export...
}
```

## Error Handling

The SDK provides typed error codes for specific error handling:

```typescript
import { StackBE, StackBEError } from '@stackbe/sdk';

try {
  await stackbe.auth.verifyToken(token);
} catch (error) {
  if (error instanceof StackBEError) {
    // Handle specific error types
    switch (error.code) {
      case 'TOKEN_EXPIRED':
        return res.redirect('/login?error=expired');
      case 'TOKEN_ALREADY_USED':
        return res.redirect('/login?error=used');
      case 'SESSION_EXPIRED':
        return res.redirect('/login');
      case 'CUSTOMER_NOT_FOUND':
        return res.status(404).json({ error: 'Customer not found' });
      case 'USAGE_LIMIT_EXCEEDED':
        return res.status(429).json({ error: 'Limit exceeded' });
      default:
        return res.status(500).json({ error: 'Something went wrong' });
    }

    // Or use helper methods
    if (error.isAuthError()) {
      return res.redirect('/login');
    }
    if (error.isNotFoundError()) {
      return res.status(404).json({ error: error.message });
    }
  }
}
```

### Error Codes

| Category | Codes |
|----------|-------|
| Auth | `TOKEN_EXPIRED`, `TOKEN_ALREADY_USED`, `TOKEN_INVALID`, `SESSION_EXPIRED`, `SESSION_INVALID`, `UNAUTHORIZED` |
| Resources | `NOT_FOUND`, `CUSTOMER_NOT_FOUND`, `SUBSCRIPTION_NOT_FOUND`, `PLAN_NOT_FOUND`, `APP_NOT_FOUND` |
| Usage | `USAGE_LIMIT_EXCEEDED`, `METRIC_NOT_FOUND` |
| Entitlements | `FEATURE_NOT_AVAILABLE`, `NO_ACTIVE_SUBSCRIPTION` |
| Validation | `VALIDATION_ERROR`, `MISSING_REQUIRED_FIELD`, `INVALID_EMAIL` |
| Network | `TIMEOUT`, `NETWORK_ERROR`, `UNKNOWN_ERROR` |

## Configuration

```typescript
const stackbe = new StackBE({
  apiKey: 'sk_live_...',           // Required: Your API key
  appId: 'app_...',                // Required: Your App ID
  baseUrl: 'https://api.stackbe.io', // Optional: API base URL
  timeout: 30000,                   // Optional: Request timeout in ms

  // Session caching (reduces API calls)
  sessionCacheTTL: 120,            // Optional: Cache sessions for 2 minutes

  // Environment-aware redirects
  devCallbackUrl: 'http://localhost:3000/auth/callback', // Optional: Auto-use in dev
});
```

### Session Caching

Enable session caching to reduce API calls on every request:

```typescript
const stackbe = new StackBE({
  apiKey,
  appId,
  sessionCacheTTL: 120, // Cache for 2 minutes
});

// Cached calls won't hit the API
const session1 = await stackbe.auth.getSession(token); // API call
const session2 = await stackbe.auth.getSession(token); // From cache

// Manually invalidate cache
stackbe.auth.invalidateSession(token);
stackbe.auth.clearCache(); // Clear all
```

### Magic Link Callback URLs

**Recommended approach**: Pass the callback URL explicitly for maximum reliability:

```typescript
// Determine callback URL based on your environment
const callbackUrl = process.env.NODE_ENV === 'production'
  ? 'https://myapp.com/auth/callback'
  : 'http://localhost:3000/auth/callback';

await stackbe.auth.sendMagicLink('user@example.com', {
  redirectUrl: callbackUrl,
});
```

**Alternative**: Use `devCallbackUrl` for automatic detection (uses `devCallbackUrl` when `NODE_ENV !== 'production'`):

```typescript
const stackbe = new StackBE({
  apiKey,
  appId,
  devCallbackUrl: 'http://localhost:3000/auth/callback',
});

// In development, uses devCallbackUrl automatically
await stackbe.auth.sendMagicLink('user@example.com');

// In production, falls back to app settings callback URL
// Explicit redirectUrl always takes priority if provided
```

**Notes:**
- `devCallbackUrl` only activates when `NODE_ENV !== 'production'`
- Explicit `redirectUrl` parameter always takes priority
- For predictable behavior, use the explicit approach above

## Webhooks

Typed webhook payloads for handling StackBE events:

```typescript
import type {
  AnyWebhookEvent,
  SubscriptionCreatedEvent,
  SubscriptionCancelledEvent,
  PaymentFailedEvent,
} from '@stackbe/sdk';

// In your webhook handler
app.post('/webhooks/stackbe', (req, res) => {
  const event = req.body as AnyWebhookEvent;

  switch (event.type) {
    case 'subscription_created':
      const sub = event as SubscriptionCreatedEvent;
      console.log(`New subscription: ${sub.data.planName}`);
      break;

    case 'subscription_cancelled':
      const cancelled = event as SubscriptionCancelledEvent;
      console.log(`Cancelled: ${cancelled.data.id}`);
      break;

    case 'payment_failed':
      const payment = event as PaymentFailedEvent;
      console.log(`Payment failed: ${payment.data.failureReason}`);
      // Send dunning email
      break;
  }

  res.json({ received: true });
});
```

### Webhook Event Types

| Event | Payload |
|-------|---------|
| `subscription_created` | `SubscriptionWebhookPayload` |
| `subscription_updated` | `SubscriptionWebhookPayload` |
| `subscription_cancelled` | `SubscriptionWebhookPayload` |
| `subscription_renewed` | `SubscriptionWebhookPayload` |
| `trial_started` | `SubscriptionWebhookPayload` |
| `trial_ended` | `SubscriptionWebhookPayload` |
| `payment_succeeded` | `PaymentWebhookPayload` |
| `payment_failed` | `PaymentWebhookPayload` |
| `customer_created` | `CustomerWebhookPayload` |
| `customer_updated` | `CustomerWebhookPayload` |

## TypeScript

Full type definitions included:

```typescript
import type {
  Customer,
  Subscription,
  SubscriptionWithPlan,
  CheckoutSessionResponse,
  SessionResponse,
  TrackUsageResponse,
  CheckEntitlementResponse,
  StackBEErrorCode,
  WebhookEventType,
  AnyWebhookEvent,
} from '@stackbe/sdk';
```

## License

MIT