This commit is contained in:
2026-04-12 01:06:31 +07:00
commit 10d660cbcb
1066 changed files with 228596 additions and 0 deletions

View File

@@ -0,0 +1,396 @@
# Polar Benefits
Automated benefit delivery system for digital products.
## Philosophy
Configure once, automatic delivery. Polar handles granting and revoking based on subscription state.
## Benefit Types
### 1. License Keys
**Auto-generate unique keys with customizable branding.**
**Create:**
```typescript
const benefit = await polar.benefits.create({
type: "license_keys",
organization_id: "org_xxx",
description: "Software License",
properties: {
prefix: "MYAPP",
expires: false,
activations: 1,
limit_usage: false
}
});
```
**Validation API (unauthenticated):**
```typescript
const validation = await polar.licenses.validate({
key: "MYAPP-XXXX-XXXX-XXXX",
organization_id: "org_xxx"
});
if (validation.valid) {
// Grant access
}
```
**Activation/Deactivation:**
```typescript
await polar.licenses.activate(licenseKey, {
label: "User's MacBook Pro"
});
await polar.licenses.deactivate(activationId);
```
**Auto-revoke:** On subscription cancellation or refund
### 2. GitHub Repository Access
**Auto-invite to private repos with permission management.**
**Create:**
```typescript
const benefit = await polar.benefits.create({
type: "github_repository",
organization_id: "org_xxx",
description: "Access to private repo",
properties: {
repository_owner: "myorg",
repository_name: "private-repo",
permission: "pull" // or "push", "admin"
}
});
```
**Multiple Repos:**
```typescript
{
properties: {
repositories: [
{ owner: "myorg", name: "repo1", permission: "pull" },
{ owner: "myorg", name: "repo2", permission: "push" }
]
}
}
```
**Behavior:**
- Auto-invite on subscription activation
- Permission managed by Polar
- Auto-revoke on cancellation
### 3. Discord Access
**Server invites and role assignment.**
**Create:**
```typescript
const benefit = await polar.benefits.create({
type: "discord",
organization_id: "org_xxx",
description: "Premium Discord role",
properties: {
guild_id: "123456789",
role_id: "987654321"
}
});
```
**Multiple Roles:**
```typescript
{
properties: {
guild_id: "123456789",
roles: [
{ role_id: "role1", name: "Premium" },
{ role_id: "role2", name: "Supporter" }
]
}
}
```
**Requirements:**
- Polar Discord app must be added to server
- Configure in Polar dashboard
**Behavior:**
- Auto-invite to server
- Assign roles automatically
- Remove roles on cancellation
### 4. Downloadable Files
**Secure file delivery up to 10GB each.**
**Create:**
```typescript
const benefit = await polar.benefits.create({
type: "downloadable",
organization_id: "org_xxx",
description: "Premium templates",
properties: {
files: [
{ name: "template1.zip", size: 5000000 },
{ name: "template2.psd", size: 10000000 }
]
}
});
```
**Upload Files:**
- Via Polar dashboard
- Secure storage
- Access control
**Customer Access:**
- Download links in customer portal
- Secure, time-limited URLs
- Multiple files supported
### 5. Meter Credits
**Pre-purchased usage for usage-based billing.**
**Create:**
```typescript
const benefit = await polar.benefits.create({
type: "custom",
organization_id: "org_xxx",
description: "10,000 API credits",
properties: {
meter_id: "meter_xxx",
credits: 10000
}
});
```
**Automatic Application:**
- Credits added on subscription start
- Balance tracked via API
- Depletes with usage
**Balance Check:**
```typescript
const balance = await polar.meters.getBalance({
customer_id: "cust_xxx",
meter_id: "meter_xxx"
});
```
### 6. Custom Benefits
**Flexible placeholder for manual fulfillment.**
**Create:**
```typescript
const benefit = await polar.benefits.create({
type: "custom",
organization_id: "org_xxx",
description: "Priority support via email",
properties: {
note: "Email support@example.com with your order ID for priority support"
}
});
```
**Use Cases:**
- Cal.com booking links
- Email support access
- Community forum access
- Manual onboarding
## Benefit Grants
**Link between customer and benefit.**
### States
- `created` - Grant created
- `active` - Benefit delivered
- `revoked` - Access removed
### Webhooks
- `benefit_grant.created` - Grant created
- `benefit_grant.updated` - Status changed
- `benefit_grant.revoked` - Access revoked
### Auto-revoke Triggers
- Subscription canceled
- Subscription revoked
- Refund processed
- Product changed (if benefit not on new product)
### Querying Grants
```typescript
const grants = await polar.benefitGrants.list({
customer_id: "cust_xxx",
benefit_id: "benefit_xxx",
is_granted: true
});
```
## Attaching Benefits to Products
### Via API
```typescript
await polar.products.updateBenefits(productId, {
benefits: [benefitId1, benefitId2, benefitId3]
});
```
### Via Dashboard
1. Navigate to product
2. Benefits tab
3. Select benefits to attach
4. Save
### Order
- Benefits granted in order attached
- Customers see in that order
- Reorder via dashboard or API
## Customer Experience
### Viewing Benefits
- Customer portal shows all active benefits
- Clear instructions for each type
- Download links for files
- License keys displayed
### Accessing Benefits
```typescript
// Generate customer portal link
const session = await polar.customerSessions.create({
external_customer_id: userId
});
// Customer sees:
// - Active subscriptions
// - Granted benefits
// - Download links
// - License keys
// - Instructions
```
## Implementation Patterns
### License Key Validation
```typescript
// In your application
async function validateLicense(key) {
try {
const result = await polar.licenses.validate({
key: key,
organization_id: process.env.POLAR_ORG_ID
});
if (!result.valid) {
return { valid: false, reason: 'Invalid license' };
}
if (result.limit_usage && result.usage >= result.limit_usage) {
return { valid: false, reason: 'Usage limit exceeded' };
}
return { valid: true, customer: result.customer };
} catch (error) {
console.error('License validation failed:', error);
return { valid: false, reason: 'Validation error' };
}
}
```
### GitHub Access Check
```typescript
// Listen to benefit grant webhook
app.post('/webhook/polar', async (req, res) => {
const event = validateEvent(req.body, req.headers, secret);
if (event.type === 'benefit_grant.created') {
const grant = event.data;
if (grant.benefit.type === 'github_repository') {
// Update user's GitHub access in your system
await updateGitHubAccess(grant.customer.external_id, true);
}
}
res.json({ received: true });
});
```
### Discord Role Sync
```typescript
// Monitor benefit grants
if (event.type === 'benefit_grant.created') {
const grant = event.data;
if (grant.benefit.type === 'discord') {
// Notify user to connect Discord
await sendDiscordInvite(grant.customer.email);
}
}
if (event.type === 'benefit_grant.revoked') {
const grant = event.data;
if (grant.benefit.type === 'discord') {
// Roles removed automatically by Polar
await notifyRoleRemoval(grant.customer.external_id);
}
}
```
## Best Practices
1. **Benefit Selection:**
- Choose appropriate benefit types
- Consider automation capabilities
- Plan for revocation scenarios
2. **License Keys:**
- Set appropriate activation limits
- Monitor usage patterns
- Provide clear validation errors
- Allow customers to manage activations
3. **GitHub Access:**
- Set minimum required permissions
- Use separate repos for different tiers
- Monitor repository access
- Communicate access removal
4. **Discord Roles:**
- Clear role hierarchy
- Meaningful role names
- Separate roles per product tier
- Welcome messages for new members
5. **Files:**
- Organize files clearly
- Provide README/instructions
- Keep files updated
- Version control important files
6. **Credits:**
- Clear credit value communication
- Usage tracking and display
- Alerts near depletion
- Easy credit top-up
7. **Custom Benefits:**
- Clear, actionable instructions
- Provide contact information
- Set expectations for timing
- Track manual fulfillment
8. **Customer Communication:**
- Welcome email with benefit access info
- Instructions for each benefit type
- Support contact for issues
- Revocation warnings before cancellation

View File

@@ -0,0 +1,902 @@
# Polar Best Practices
Production-proven patterns from real SaaS implementations covering SDK initialization, checkout flows, webhooks, discounts, fee calculations, and error handling.
## Environment Configuration
### Required Environment Variables
```bash
# Core API
POLAR_API_KEY=polar_at_xxx # Access token from Polar Dashboard
POLAR_ORGANIZATION_ID=org_xxx # Your organization ID
POLAR_WEBHOOK_SECRET=whsec_xxx # Webhook signature verification
# Product IDs (one per product)
POLAR_PRODUCT_ENGINEER_ID=prod_xxx
POLAR_PRODUCT_MARKETING_ID=prod_xxx
POLAR_PRODUCT_COMBO_ID=prod_xxx
# Environment (optional, defaults to production)
POLAR_ENV=production # 'production' or 'sandbox'
```
### Lazy Initialization Pattern
```typescript
// lib/polar.ts - Defer validation until first access
import { Polar } from '@polar-sh/sdk';
import { z } from 'zod';
const polarEnvSchema = z.object({
POLAR_API_KEY: z.string().min(1),
POLAR_ORGANIZATION_ID: z.string().min(1),
POLAR_WEBHOOK_SECRET: z.string().min(1),
});
let _polar: Polar | null = null;
let _env: z.infer<typeof polarEnvSchema> | null = null;
export function getPolarEnv() {
if (!_env) {
_env = polarEnvSchema.parse({
POLAR_API_KEY: process.env.POLAR_API_KEY,
POLAR_ORGANIZATION_ID: process.env.POLAR_ORGANIZATION_ID,
POLAR_WEBHOOK_SECRET: process.env.POLAR_WEBHOOK_SECRET,
});
}
return _env;
}
export function getPolar() {
if (!_polar) {
const env = getPolarEnv();
const polarEnv = process.env.POLAR_ENV || 'production';
_polar = new Polar({
accessToken: env.POLAR_API_KEY,
server: polarEnv as 'production' | 'sandbox',
});
}
return _polar;
}
```
**Key Benefit:** Module imports succeed at build time; validation deferred until runtime when env vars are available.
## Checkout Flow Implementation
### Standard Checkout API
```typescript
// app/api/checkout/polar/route.ts
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { getPolar, getPolarEnv } from '@/lib/polar';
const checkoutSchema = z.object({
email: z.string().email(),
name: z.string().optional(),
productType: z.enum(['engineer_kit', 'marketing_kit', 'combo']),
githubUsername: z.string().min(1),
referralCode: z.string().regex(/^[A-Z0-9]{8}$/).optional(),
couponCode: z.string().optional(),
});
// Pricing in cents
const PRODUCT_PRICES = {
engineer_kit: 9900, // $99
marketing_kit: 9900, // $99
combo: 14900, // $149
} as const;
export async function POST(request: Request) {
try {
const body = await request.json();
const data = checkoutSchema.parse(body);
const polar = getPolar();
const env = getPolarEnv();
// 1. Normalize email
const normalizedEmail = data.email.toLowerCase().trim();
// 2. Validate GitHub username against GitHub API
const githubValid = await validateGitHubUsername(data.githubUsername);
if (!githubValid) {
return NextResponse.json(
{ error: 'Invalid GitHub username' },
{ status: 400 }
);
}
// 3. Get product ID and base price
const productId = getProductId(data.productType);
const originalAmount = PRODUCT_PRICES[data.productType];
// 4. Apply discount hierarchy (order matters!)
let finalAmount = originalAmount;
let polarDiscountId: string | undefined;
let discountMetadata: Record<string, any> = {};
// Step A: Apply coupon FIRST (if provided)
if (data.couponCode) {
const couponResult = await validateAndApplyCoupon(
data.couponCode,
productId,
originalAmount
);
if (couponResult.valid) {
finalAmount = originalAmount - couponResult.discountAmount;
discountMetadata.couponCode = data.couponCode;
discountMetadata.couponDiscountAmount = couponResult.discountAmount;
}
}
// Step B: Apply referral discount SECOND (on post-coupon price)
if (data.referralCode) {
const referralResult = await calculateReferralDiscount(
data.referralCode,
finalAmount, // Applied to post-coupon amount
normalizedEmail
);
if (referralResult.valid && referralResult.discountAmount > 0) {
// Validate discount calculation
if (referralResult.discountAmount <= 0) {
return NextResponse.json(
{ error: 'Invalid discount calculation - contact support' },
{ status: 400 }
);
}
finalAmount -= referralResult.discountAmount;
discountMetadata.referralCode = data.referralCode;
discountMetadata.referralDiscountAmount = referralResult.discountAmount;
discountMetadata.referrerId = referralResult.referrerId;
}
}
// 5. Create order record BEFORE Polar checkout
const order = await db.insert(orders).values({
id: crypto.randomUUID(),
email: normalizedEmail,
productType: data.productType,
amount: finalAmount,
originalAmount,
currency: 'USD',
status: 'pending',
paymentProvider: 'polar',
referredBy: discountMetadata.referrerId,
discountAmount: originalAmount - finalAmount,
metadata: JSON.stringify({
...discountMetadata,
githubUsername: data.githubUsername,
}),
}).returning();
// 6. Create dynamic Polar discount (if referral applied)
if (discountMetadata.referrerId && discountMetadata.referralDiscountAmount > 0) {
try {
const discount = await polar.discounts.create({
type: 'fixed',
name: `referral-${order[0].id.slice(0, 8)}`,
amount: discountMetadata.referralDiscountAmount,
currency: 'usd',
duration: 'once',
maxRedemptions: 1,
products: [productId],
metadata: {
orderId: order[0].id,
type: 'referral',
referrerId: discountMetadata.referrerId,
},
});
polarDiscountId = discount.id;
} catch (error) {
// FAIL-OPEN: Proceed with full price, flag for manual refund
console.error('⚠️ Failed to create Polar discount:', error);
}
}
// 7. Create Polar checkout session
const checkout = await polar.checkouts.create({
productPriceId: productId,
customerEmail: normalizedEmail,
successUrl: `${process.env.NEXT_PUBLIC_URL}/checkout/success?orderId=${order[0].id}`,
discountId: polarDiscountId,
allowDiscountCodes: !polarDiscountId, // Prevent stacking
metadata: {
orderId: order[0].id,
githubUsername: data.githubUsername,
referredBy: discountMetadata.referrerId,
},
});
return NextResponse.json({
checkoutUrl: checkout.url,
orderId: order[0].id,
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.errors }, { status: 400 });
}
console.error('Checkout error:', error);
return NextResponse.json(
{ error: 'Failed to create checkout' },
{ status: 500 }
);
}
}
```
### Discount Application Order (Critical)
```
1. Original price (e.g., $99)
2. Apply coupon discount FIRST → post-coupon price (e.g., $79)
3. Apply referral discount SECOND → final price (e.g., $63.20)
Never apply referral to original price if coupon was used!
```
## Webhook Handling
### Signature Verification
```typescript
// app/api/webhooks/polar/route.ts
import { validateEvent } from '@polar-sh/sdk/webhooks';
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const payload = await request.text();
const headers = Object.fromEntries(request.headers);
const secret = process.env.POLAR_WEBHOOK_SECRET!;
let webhookEvent;
try {
webhookEvent = validateEvent(payload, headers, secret);
} catch (error) {
console.error('Invalid webhook signature:', error);
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
// Extract event ID for idempotency
const parsedPayload = JSON.parse(payload);
const eventId = parsedPayload.id || `${parsedPayload.type}-${Date.now()}`;
// Check for duplicate processing
const existingEvent = await db.select()
.from(webhookEvents)
.where(eq(webhookEvents.eventId, eventId))
.limit(1);
if (existingEvent.length > 0) {
console.log(`Duplicate webhook ignored: ${eventId}`);
return NextResponse.json({ received: true });
}
// Record event BEFORE processing (idempotency)
await db.insert(webhookEvents).values({
id: crypto.randomUUID(),
provider: 'polar',
eventType: webhookEvent.type,
eventId,
payload,
processed: false,
});
try {
await handleWebhookEvent(webhookEvent);
// Mark as processed
await db.update(webhookEvents)
.set({ processed: true, processedAt: new Date() })
.where(eq(webhookEvents.eventId, eventId));
} catch (error) {
// Log error but don't fail the webhook
await db.update(webhookEvents)
.set({
processed: true,
processedAt: new Date(),
error: error instanceof Error ? error.message : 'Unknown error',
})
.where(eq(webhookEvents.eventId, eventId));
}
return NextResponse.json({ received: true });
}
```
### Event Handlers
```typescript
async function handleWebhookEvent(event: WebhookEvent) {
switch (event.type) {
case 'checkout.created':
// Order already exists from API - just log
console.log(`Checkout created: ${event.data.id}`);
break;
case 'checkout.updated':
await handleCheckoutUpdated(event.data);
break;
case 'order.created':
await handleOrderCreated(event.data);
break;
case 'order.refunded':
await handleOrderRefunded(event.data);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
}
async function handleOrderCreated(order: PolarOrder) {
const orderId = order.metadata?.orderId;
if (!orderId) {
console.error('Order missing orderId in metadata');
return;
}
const dbOrder = await db.select()
.from(orders)
.where(eq(orders.id, orderId))
.limit(1);
if (!dbOrder[0]) {
console.error(`Order not found: ${orderId}`);
return;
}
// 1. Update order status
await db.update(orders)
.set({
status: 'completed',
paymentId: order.id,
updatedAt: new Date(),
})
.where(eq(orders.id, orderId));
// 2. Create license (non-blocking)
try {
await createLicense(dbOrder[0]);
} catch (error) {
console.error('Failed to create license:', error);
}
// 3. Send confirmation email (non-blocking)
try {
await sendOrderConfirmation(dbOrder[0], order);
} catch (error) {
console.error('Failed to send confirmation:', error);
}
// 4. Create referral commission (non-blocking)
if (dbOrder[0].referredBy) {
try {
await createCommission(dbOrder[0]);
} catch (error) {
console.error('Failed to create commission:', error);
}
}
// 5. Grant GitHub access (non-blocking)
try {
const metadata = JSON.parse(dbOrder[0].metadata || '{}');
await inviteToGitHub(metadata.githubUsername, dbOrder[0].productType);
} catch (error) {
console.error('Failed to invite to GitHub:', error);
}
// 6. Send Discord notification (non-blocking)
try {
await sendSalesNotification(dbOrder[0]);
} catch (error) {
console.error('Failed to send Discord notification:', error);
}
}
```
### Status Mapping
```typescript
function mapPolarStatusToAppStatus(polarStatus: string): string | null {
switch (polarStatus) {
case 'succeeded':
return 'completed';
case 'failed':
case 'expired':
return 'failed';
case 'open':
case 'confirmed':
return null; // Don't update - still pending
default:
return null;
}
}
```
## Fee Calculation
### Platform Fee Structure (Dec 2025)
```typescript
// lib/polar-fees.ts
interface PolarFeeConfig {
basePercentage: number; // 4%
baseFlatCents: number; // $0.40 per transaction
internationalSurcharge: number; // +1.5% for non-US cards
subscriptionSurcharge: number; // +0.5% (not for one-time)
}
const POLAR_FEES: PolarFeeConfig = {
basePercentage: 0.04,
baseFlatCents: 40,
internationalSurcharge: 0.015,
subscriptionSurcharge: 0.005,
};
export function calculatePolarFees(
amountCents: number,
isInternational: boolean = true, // Conservative default
isSubscription: boolean = false
): {
baseFee: number;
internationalFee: number;
subscriptionFee: number;
totalFee: number;
netRevenue: number;
} {
// Handle zero/negative
if (amountCents <= 0) {
return { baseFee: 0, internationalFee: 0, subscriptionFee: 0, totalFee: 0, netRevenue: 0 };
}
const baseFee = Math.round(amountCents * POLAR_FEES.basePercentage + POLAR_FEES.baseFlatCents);
const internationalFee = isInternational
? Math.round(amountCents * POLAR_FEES.internationalSurcharge)
: 0;
const subscriptionFee = isSubscription
? Math.round(amountCents * POLAR_FEES.subscriptionSurcharge)
: 0;
const totalFee = baseFee + internationalFee + subscriptionFee;
const netRevenue = amountCents - totalFee;
return { baseFee, internationalFee, subscriptionFee, totalFee, netRevenue };
}
// Aggregate fees preserve per-transaction flat fees
export function calculateAggregatePolarFees(transactionAmounts: number[]): {
totalFees: number;
totalNetRevenue: number;
} {
let totalFees = 0;
let totalNetRevenue = 0;
for (const amount of transactionAmounts) {
const { totalFee, netRevenue } = calculatePolarFees(amount);
totalFees += totalFee;
totalNetRevenue += netRevenue;
}
return { totalFees, totalNetRevenue };
}
```
## Discount Management
### Discount Validation with Timeout
```typescript
// lib/polar-discounts.ts
const VALIDATION_TIMEOUT_MS = 15000;
export async function validateDiscount(
code: string,
productId: string
): Promise<{ valid: boolean; discount?: PolarDiscount; reason?: string }> {
const sanitizedCode = code.trim().toUpperCase();
if (!sanitizedCode) {
return { valid: false, reason: 'Code cannot be empty' };
}
const polar = getPolar();
const env = getPolarEnv();
try {
// Race against timeout
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('Validation timeout')), VALIDATION_TIMEOUT_MS);
});
const searchPromise = polar.discounts.list({
organizationId: env.POLAR_ORGANIZATION_ID,
query: sanitizedCode,
limit: 100,
});
const result = await Promise.race([searchPromise, timeoutPromise]);
// Find exact match
const discount = result.items.find(d =>
d.code?.toUpperCase() === sanitizedCode
);
if (!discount) {
return { valid: false, reason: 'Code not found' };
}
// Check eligibility
const now = new Date();
if (discount.startsAt && now < new Date(discount.startsAt)) {
return { valid: false, reason: `Code starts on ${discount.startsAt}` };
}
if (discount.endsAt && now > new Date(discount.endsAt)) {
return { valid: false, reason: 'Code has expired' };
}
if (discount.maxRedemptions && discount.redemptionsCount >= discount.maxRedemptions) {
return { valid: false, reason: 'Code redemption limit reached' };
}
if (!discount.products?.some(p => p.id === productId)) {
return { valid: false, reason: 'Code not valid for this product' };
}
return { valid: true, discount };
} catch (error) {
console.error('Discount validation error:', error);
return { valid: false, reason: 'Validation failed - please try again' };
}
}
```
### VND Conversion for Discounts
```typescript
const VND_TO_USD_RATE = 25000; // 1 USD = 25,000 VND
export function convertDiscountToVND(discount: PolarDiscount, amountVND: number): number {
if (discount.type === 'percentage') {
// Basis points: 1000 = 10%, 10000 = 100%
const percentage = discount.basisPoints / 10000;
return Math.round(amountVND * percentage);
} else {
// Fixed amount in USD cents → VND
const amountUSD = discount.amount / 100;
return Math.round(amountUSD * VND_TO_USD_RATE);
}
}
```
### Syncing SePay Redemptions to Polar
```typescript
// lib/polar-discount-sync.ts
// When SePay payment completes, decrement Polar discount redemptions
export async function syncPolarDiscountRedemption(
orderId: string,
discountId: string,
discountCode: string
): Promise<{ success: boolean; action: string }> {
const order = await db.select().from(orders).where(eq(orders.id, orderId)).limit(1);
if (!order[0]) {
return { success: false, action: 'order_not_found' };
}
// Idempotency check
const metadata = order[0].metadata ? JSON.parse(order[0].metadata) : {};
if (metadata.polarDiscountSynced) {
return { success: true, action: 'already_synced' };
}
const polar = getPolar();
try {
const discount = await polar.discounts.get({ id: discountId });
if (discount.maxRedemptions === null || discount.maxRedemptions === undefined) {
return { success: true, action: 'skipped_unlimited' };
}
const currentMax = discount.maxRedemptions;
if (currentMax <= 1) {
await polar.discounts.delete({ id: discountId });
await markOrderSynced(orderId, 'deleted');
} else {
await polar.discounts.update({
id: discountId,
discountUpdate: { maxRedemptions: currentMax - 1 },
});
await markOrderSynced(orderId, 'decremented');
}
return { success: true, action: currentMax <= 1 ? 'deleted' : 'decremented' };
} catch (error: any) {
if (error.statusCode === 404) {
// Already deleted - treat as success
await markOrderSynced(orderId, 'already_deleted');
return { success: true, action: 'already_deleted' };
}
throw error;
}
}
async function markOrderSynced(orderId: string, action: string) {
const order = await db.select().from(orders).where(eq(orders.id, orderId)).limit(1);
const metadata = order[0].metadata ? JSON.parse(order[0].metadata) : {};
metadata.polarDiscountSynced = true;
metadata.polarDiscountSyncAction = action;
metadata.polarDiscountSyncedAt = new Date().toISOString();
await db.update(orders)
.set({ metadata: JSON.stringify(metadata) })
.where(eq(orders.id, orderId));
}
```
## Revenue Tracking with Caching
```typescript
// lib/polar.ts
const REVENUE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
let revenueCache: {
data: { totalRevenueCents: number; orderCount: number } | null;
timestamp: number;
} = { data: null, timestamp: 0 };
export async function getPolarApiRevenue(): Promise<{
totalRevenueCents: number;
orderCount: number;
fromCache: boolean;
}> {
const now = Date.now();
// Return cache if valid
if (revenueCache.data && now - revenueCache.timestamp < REVENUE_CACHE_TTL_MS) {
return { ...revenueCache.data, fromCache: true };
}
const polar = getPolar();
const env = getPolarEnv();
try {
let totalRevenueCents = 0;
let orderCount = 0;
let page = 1;
const maxPages = 100; // Safety limit
while (page <= maxPages) {
const response = await polar.orders.list({
organizationId: env.POLAR_ORGANIZATION_ID,
page,
limit: 100,
});
for (const order of response.items) {
if (order.status === 'succeeded') {
totalRevenueCents += order.netAmount; // After discounts, before tax
orderCount++;
}
}
if (!response.pagination.hasMore) break;
page++;
}
revenueCache = { data: { totalRevenueCents, orderCount }, timestamp: now };
return { totalRevenueCents, orderCount, fromCache: false };
} catch (error) {
// Return stale cache on error
if (revenueCache.data) {
console.warn('Using stale revenue cache due to API error');
return { ...revenueCache.data, fromCache: true };
}
throw error;
}
}
```
## Error Handling Patterns
### Fail-Open for Non-Critical Operations
```typescript
// Discount creation fails → proceed with full price
try {
const discount = await createReferralDiscount(productId, amount, referralCode);
polarDiscountId = discount.id;
} catch (error) {
console.error('⚠️ Discount creation failed - proceeding with full price:', error);
// Flag for manual refund investigation
await flagOrderForReview(orderId, 'discount_creation_failed');
}
```
### Graceful Degradation in Webhooks
```typescript
// Non-critical operations don't block order completion
const operations = [
{ name: 'GitHub invite', fn: () => inviteToGitHub(username, productType) },
{ name: 'Welcome email', fn: () => sendWelcomeEmail(order) },
{ name: 'Discord notification', fn: () => sendSalesNotification(order) },
{ name: 'Tier update', fn: () => updateReferrerTier(referrerId, revenueUsd) },
];
for (const op of operations) {
try {
await op.fn();
} catch (error) {
console.error(`❌ ${op.name} failed:`, error);
// Continue processing - don't block order
}
}
```
### Rate Limit Handling with Exponential Backoff
```typescript
async function callWithRetry<T>(
fn: () => Promise<T>,
maxRetries: number = 3
): Promise<T> {
let attempt = 0;
while (attempt < maxRetries) {
try {
return await fn();
} catch (error: any) {
if (error.statusCode === 429) {
const retryAfter = parseInt(error.headers?.['retry-after'] || '1', 10);
const delay = retryAfter * 1000 * Math.pow(2, attempt);
console.log(`Rate limited, retrying in ${delay}ms...`);
await sleep(delay);
attempt++;
} else {
throw error;
}
}
}
throw new Error('Max retries exceeded');
}
```
## Database Schema
### Orders Table
```typescript
// db/schema/orders.ts
export const orders = pgTable('orders', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id),
email: text('email').notNull(),
productType: text('product_type').notNull(),
amount: integer('amount').notNull(), // Final amount in cents
originalAmount: integer('original_amount'), // Before discounts
currency: text('currency').default('USD'),
status: text('status').default('pending'), // pending, completed, failed, refunded
paymentProvider: text('payment_provider').notNull(), // 'polar' or 'sepay'
paymentId: text('payment_id'), // External payment ID
referredBy: uuid('referred_by').references(() => users.id),
discountAmount: integer('discount_amount').default(0),
discountRate: numeric('discount_rate', { precision: 5, scale: 2 }),
metadata: text('metadata'), // JSON with audit trail
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
});
```
### Webhook Events Table (Idempotency)
```typescript
export const webhookEvents = pgTable('webhook_events', {
id: uuid('id').primaryKey().defaultRandom(),
provider: text('provider').notNull(), // 'polar' or 'sepay'
eventType: text('event_type').notNull(),
eventId: text('event_id').notNull().unique(), // Idempotency key
payload: text('payload').notNull(),
processed: boolean('processed').default(false),
processedAt: timestamp('processed_at'),
error: text('error'),
createdAt: timestamp('created_at').defaultNow(),
});
```
## Metadata Best Practices
### Comprehensive Audit Trail
```typescript
// Store everything needed for debugging and reconciliation
metadata: JSON.stringify({
// Pricing history
originalAmount: 9900,
// Coupon tracking
couponCode: 'LAUNCH20',
couponDiscountAmount: 1980,
// Referral tracking
referralCode: 'ABC12345',
referralDiscountAmount: 1584,
referrerId: 'user-uuid',
// Customer context
githubUsername: 'customer',
// Polar integration
polarDiscountId: 'disc_xxx',
polarDiscountSynced: true,
polarDiscountSyncAction: 'decremented',
polarDiscountSyncedAt: '2025-01-15T10:30:00Z',
// Team context (if applicable)
isTeamPurchase: false,
teamId: null,
quantity: 1,
})
```
## Testing
### Unit Tests for Fee Calculation
```typescript
// __tests__/lib/polar-fees.test.ts
describe('calculatePolarFees', () => {
it('handles zero amount', () => {
const result = calculatePolarFees(0);
expect(result.totalFee).toBe(0);
expect(result.netRevenue).toBe(0);
});
it('calculates international one-time correctly', () => {
// $100 transaction
const result = calculatePolarFees(10000, true, false);
expect(result.baseFee).toBe(440); // 4% + $0.40
expect(result.internationalFee).toBe(150); // 1.5%
expect(result.totalFee).toBe(590);
expect(result.netRevenue).toBe(9410); // $94.10
});
it('preserves per-transaction flat fees in aggregate', () => {
// Two $100 transactions should each have $0.40 flat fee
const aggregate = calculateAggregatePolarFees([10000, 10000]);
const single = calculatePolarFees(20000);
expect(aggregate.totalFees).toBeGreaterThan(single.totalFee);
// Difference should be one extra flat fee ($0.40)
expect(aggregate.totalFees - single.totalFee).toBe(40);
});
});
```
## Production Checklist
- [ ] Environment variables configured in all environments
- [ ] Sandbox testing completed for all checkout flows
- [ ] Production API key obtained and secured
- [ ] Webhook endpoint deployed and reachable
- [ ] Webhook signature verification implemented
- [ ] Idempotency handling tested with duplicate webhooks
- [ ] Fee calculations verified against Polar dashboard
- [ ] Discount validation timeout configured
- [ ] Error monitoring enabled (Sentry, etc.)
- [ ] Structured logging in place
- [ ] Database indexes on orders.status, orders.paymentProvider
- [ ] Revenue caching configured
- [ ] Rate limit handling implemented
- [ ] Fail-open patterns for non-critical operations
- [ ] Customer email notifications working
- [ ] Refund flow tested end-to-end
- [ ] GitHub access grant/revoke tested
- [ ] Discord sales notifications configured
## Common Pitfalls
1. **Applying discounts in wrong order** - Always coupon first, then referral
2. **Trusting success redirect without verification** - Always verify via API or webhook
3. **Not handling duplicate webhooks** - Use eventId for idempotency
4. **Blocking webhook on non-critical failures** - Wrap in try-catch, log, continue
5. **Hardcoding Polar customer IDs** - Use external_id (your user ID) for lookups
6. **Not setting timeout on discount validation** - API can be slow
7. **Calculating aggregate fees as single transaction** - Each transaction has flat fee
8. **Exposing API keys client-side** - Always server-side
9. **Not preserving original amount in metadata** - Need for audit/debugging
10. **Syncing discount redemptions synchronously** - Can fail; use retry with backoff

View File

@@ -0,0 +1,266 @@
# Polar Checkouts
Checkout flows, embedded checkout, and session management.
## Checkout Approaches
### 1. Checkout Links
- Pre-configured shareable links
- Created via dashboard or API
- For marketing campaigns
- Can pre-apply discounts
**Create via API:**
```typescript
const link = await polar.checkoutLinks.create({
product_price_id: "price_xxx",
success_url: "https://example.com/success"
});
// Returns: link.url
```
### 2. Checkout Sessions (API)
- Programmatically created
- Server-side API call
- Dynamic workflows
- Custom logic
**Create Session:**
```typescript
const session = await polar.checkouts.create({
product_price_id: "price_xxx",
success_url: "https://example.com/success?checkout_id={CHECKOUT_ID}",
customer_email: "user@example.com",
external_customer_id: "user_123",
metadata: {
user_id: "123",
source: "web"
}
});
// Redirect to: session.url
```
**Response:**
```json
{
"id": "checkout_xxx",
"url": "https://polar.sh/checkout/...",
"client_secret": "cs_xxx",
"status": "open",
"expires_at": "2025-01-15T10:00:00Z"
}
```
### 3. Embedded Checkout
- Inline checkout within your site
- Seamless purchase experience
- Theme customization
**Implementation:**
```html
<script src="https://polar.sh/embed.js"></script>
<div id="polar-checkout"></div>
<script>
const checkout = await fetch('/api/create-checkout', {
method: 'POST',
body: JSON.stringify({ productPriceId: 'price_xxx' })
}).then(r => r.json());
Polar('checkout', {
checkoutId: checkout.id,
clientSecret: checkout.client_secret,
onSuccess: () => {
window.location.href = '/success';
},
theme: 'dark' // or 'light'
});
</script>
```
**Server-side (create session):**
```typescript
app.post('/api/create-checkout', async (req, res) => {
const session = await polar.checkouts.create({
product_price_id: req.body.productPriceId,
embed_origin: "https://example.com",
external_customer_id: req.user.id
});
res.json({
id: session.id,
client_secret: session.client_secret
});
});
```
## Configuration Parameters
### Required
- `product_price_id` - Product to checkout (or `products` array for multiple)
- `success_url` - Post-payment redirect (absolute URL)
### Optional
- `external_customer_id` - Your user ID mapping
- `embed_origin` - For embedded checkouts
- `customer_email` - Pre-fill email
- `customer_name` - Pre-fill name
- `discount_id` - Pre-apply discount code
- `allow_discount_codes` - Allow customer to enter codes (default: true)
- `metadata` - Custom data (key-value)
- `custom_field_data` - Pre-fill custom fields
- `customer_billing_address` - Pre-fill billing address
### Success URL Placeholder
```typescript
{
success_url: "https://example.com/success?checkout_id={CHECKOUT_ID}"
}
// Polar replaces {CHECKOUT_ID} with actual checkout ID
```
## Multi-Product Checkout
```typescript
const session = await polar.checkouts.create({
products: [
{ product_price_id: "price_1", quantity: 1 },
{ product_price_id: "price_2", quantity: 2 }
],
success_url: "https://example.com/success"
});
```
## Discount Application
### Pre-apply Discount
```typescript
const session = await polar.checkouts.create({
product_price_id: "price_xxx",
discount_id: "discount_xxx",
success_url: "https://example.com/success"
});
```
### Allow Customer Codes
```typescript
{
allow_discount_codes: true // default
// Set to false to disable code entry
}
```
## Checkout States
- `open` - Ready for payment
- `confirmed` - Payment successful
- `expired` - Session expired (typically 24 hours)
## Events
**Webhook Events:**
- `checkout.created` - Session created
- `checkout.updated` - Session updated
- `order.created` - Order created after successful payment
- `order.paid` - Payment confirmed
**Handle Success:**
```typescript
// Listen to order.paid webhook
app.post('/webhook/polar', async (req, res) => {
const event = validateEvent(req.body, req.headers, secret);
if (event.type === 'order.paid') {
const order = event.data;
await fulfillOrder(order);
}
res.json({ received: true });
});
```
## Best Practices
1. **Success URL:**
- Must be absolute URL: `https://example.com/success`
- Use `{CHECKOUT_ID}` placeholder to retrieve checkout details
- Verify payment via webhook, not just success redirect
2. **External Customer ID:**
- Set on first checkout
- Never change once set
- Use for all customer operations
- Enables customer lookup without storing Polar IDs
3. **Pre-filling Data:**
- Pre-fill customer info when available
- Reduces friction in checkout
- Improves conversion rates
4. **Embedded Checkout:**
- Provide seamless experience
- Match your site's theme
- Handle errors gracefully
- Show loading states
5. **Metadata:**
- Store tracking info (source, campaign, etc.)
- Link to your internal systems
- Use for analytics and reporting
6. **Error Handling:**
- Handle expired sessions
- Provide clear error messages
- Offer to create new session
- Log failures for debugging
7. **Mobile Optimization:**
- Test on mobile devices
- Ensure responsive design
- Consider mobile payment methods
- Test embedded checkout on mobile
## Framework Examples
### Next.js
```typescript
// app/actions/checkout.ts
'use server'
export async function createCheckout(productPriceId: string) {
const session = await polar.checkouts.create({
product_price_id: productPriceId,
success_url: `${process.env.NEXT_PUBLIC_URL}/success?checkout_id={CHECKOUT_ID}`,
external_customer_id: await getCurrentUserId()
});
return session.url;
}
// app/product/page.tsx
export default function ProductPage() {
async function handleCheckout() {
const url = await createCheckout(productPriceId);
window.location.href = url;
}
return <button onClick={handleCheckout}>Buy Now</button>;
}
```
### Laravel
```php
Route::post('/checkout', function (Request $request) {
$polar = new Polar(config('polar.access_token'));
$session = $polar->checkouts->create([
'product_price_id' => $request->input('product_price_id'),
'success_url' => route('checkout.success'),
'external_customer_id' => auth()->id(),
]);
return redirect($session['url']);
});
```

View File

@@ -0,0 +1,184 @@
# Polar Overview
Comprehensive payment & billing platform for software monetization with Merchant of Record services.
## Core Capabilities
**Platform Features:**
- Digital product sales (one-time, recurring, usage-based)
- Merchant of Record - handles global tax compliance
- Subscription lifecycle management
- Automated benefit distribution
- Customer self-service portal
- Real-time webhook system
- Analytics dashboard
- Multi-language SDKs
**Merchant of Record Benefits:**
- Global tax compliance (VAT, GST, sales tax)
- Tax calculations for all jurisdictions
- B2B reverse charge, B2C tax collection
- Invoicing from Polar to customers
- Payout invoicing to merchants
- Transparent fees (20% discount vs other MoRs)
## Authentication
### Organization Access Tokens (OAT)
**For:** Server-side API access
**Create:**
1. Org Settings → Developers
2. Create new access token
3. Copy and store securely
**Usage:**
```bash
Authorization: Bearer polar_xxxxxxxxxxxxxxxx
```
**Security:** Never expose client-side (auto-revoked if leaked)
### OAuth 2.0
**For:** Third-party app integration
**Authorization URL:** `https://polar.sh/oauth2/authorize`
**Token URL:** `https://api.polar.sh/v1/oauth2/token`
**Flow:**
```
1. Redirect to authorize URL with scopes
2. User approves permissions
3. Receive authorization code
4. Exchange code for access_token + refresh_token
5. Use access_token for API calls
```
**Scopes:**
- `products:read/write` - Product management
- `checkouts:read/write` - Checkout operations
- `orders:read` - View orders
- `subscriptions:read/write` - Subscription management
- `benefits:read/write` - Benefit configuration
- `customers:read/write` - Customer management
- `discounts:read/write` - Discount codes
- `refunds:read/write` - Refund processing
### Customer Sessions
**For:** Customer-facing portal operations
**Create:** Server-side API call returns customer access token
**Usage:** Pre-authenticated customer portal links
**Scope:** Restricted to customer-specific operations
## Base URLs
**Production:**
- Dashboard: `https://polar.sh`
- API: `https://api.polar.sh/v1/`
**Sandbox:**
- Dashboard: `https://sandbox.polar.sh`
- API: `https://sandbox-api.polar.sh/v1/`
**SDK Configuration:**
```typescript
const polar = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN,
server: "production" // or "sandbox"
});
```
## Rate Limits
**Limits:**
- 300 requests/minute per org/customer/OAuth2 client
- 3 requests/second for unauthenticated license validation
**Response:** HTTP 429 with `Retry-After` header
**Handling:**
```javascript
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
await sleep(retryAfter * 1000);
return retry();
}
```
## Key Concepts
### External Customer ID
- Map your user IDs to Polar customers
- Set at checkout: `external_customer_id`
- Query API by external_id
- Immutable once set
- Use for all customer operations
### Metadata
- Custom key-value storage
- Available on products, customers, subscriptions, orders
- For reporting and filtering
- Not indexed, use for supplementary data
### Billing Reasons
Track order types via `billing_reason`:
- `purchase` - One-time product
- `subscription_create` - New subscription
- `subscription_cycle` - Renewal invoice
- `subscription_update` - Plan change
## Environments
**Sandbox:**
- Separate account required
- Separate organization
- Separate access tokens (production tokens don't work)
- Test with Stripe test cards
**Test Cards (Stripe):**
- Success: `4242 4242 4242 4242`
- Decline: `4000 0000 0000 0002`
- Auth Required: `4000 0025 0000 3155`
- Expiry: Any future date
- CVC: Any 3 digits
## SDKs
**Official SDKs:**
- TypeScript/JavaScript: `@polar-sh/sdk`
- Python: `polar-sdk`
- PHP: `polar-sh/sdk`
- Go: Official SDK
**Framework Adapters:**
- Next.js: `@polar-sh/nextjs` (quickstart: `npx polar-init`)
- Laravel: `polar-sh/laravel`
- Remix, Astro, Express, TanStack Start
- Elysia, Fastify, Hono, SvelteKit
**BetterAuth Integration:**
- Package: `@polar-sh/better-auth`
- Auto-create customers on signup
- External ID mapping
- User-customer sync
## Support & Resources
- Docs: https://polar.sh/docs
- API Reference: https://polar.sh/docs/api-reference
- LLMs.txt: https://polar.sh/docs/llms.txt
- GitHub: https://github.com/polarsource/polar
- Discussions: https://github.com/orgs/polarsource/discussions
## Next Steps
- **For products:** Load `products.md`
- **For checkout:** Load `checkouts.md`
- **For subscriptions:** Load `subscriptions.md`
- **For webhooks:** Load `webhooks.md`
- **For benefits:** Load `benefits.md`
- **For SDK usage:** Load `sdk.md`

View File

@@ -0,0 +1,244 @@
# Polar Products & Pricing
Product management, pricing models, and usage-based billing.
## Billing Cycles
**Options:**
- One-time: Charged once, forever access
- Monthly: Charged every month
- Yearly: Charged every year
**Important:** Cannot change after product creation
## Pricing Types
**Fixed Price:** Set amount
**Pay What You Want:** Customer decides (optional minimum)
**Free:** No charge
**Important:** Cannot change after product creation
## Advanced Pricing Models
### Seat-Based Pricing
- Team access with assignable seats
- Works for recurring or one-time
- Tiered pricing structures
- Customer manages seat assignments
**Configuration:**
```typescript
const product = await polar.products.create({
name: "Team Plan",
prices: [{
type: "recurring",
recurring_interval: "month",
price_amount: 5000, // per seat
pricing_type: "fixed"
}],
is_seat_based: true,
max_seats: 100
});
```
### Usage-Based Billing
**Architecture:** Events → Meters → Metered Prices
**1. Events:** Usage data from your application
```typescript
await polar.events.create({
external_customer_id: "user_123",
event_name: "api_call",
properties: {
tokens: 1000,
model: "gpt-4"
}
});
```
**2. Meters:** Filter & aggregate events
```typescript
const meter = await polar.meters.create({
name: "API Tokens",
slug: "api_tokens",
event_name: "api_call",
aggregation: {
type: "sum",
property: "tokens"
}
});
```
**3. Metered Prices:** Billing based on usage
```typescript
const price = await polar.products.createPrice(productId, {
type: "metered",
meter_id: meter.id,
price_per_unit: 10, // 10 cents per 1000 tokens
billing_interval: "month"
});
```
**Credits System:**
- Pre-purchased usage credits
- Credit customer's meter balance
- Use as subscription benefit
- Balance tracking API
**Ingestion Strategies:**
- LLM Strategy: AI/ML tracking
- S3 Strategy: Bulk import
- Stream Strategy: Real-time
- Delta Time Strategy: Time-based
## Product Features
### Metadata
```typescript
const product = await polar.products.create({
name: "Pro Plan",
metadata: {
feature_x: "enabled",
tier: "pro",
custom_field: "value"
}
});
```
### Custom Fields
```typescript
const product = await polar.products.create({
name: "Enterprise Plan",
custom_fields: [
{
slug: "company_name",
label: "Company Name",
type: "text",
required: true
},
{
slug: "employees",
label: "Number of Employees",
type: "number"
}
]
});
```
Data collected at checkout, accessible via Orders/Subscriptions API in `custom_field_data`.
### Trials
- Set on recurring products
- Customer not charged during trial
- Benefits granted immediately
- Configure at product or checkout level
```typescript
const product = await polar.products.create({
name: "Pro Plan",
prices: [{
type: "recurring",
recurring_interval: "month",
price_amount: 2000,
trial_period_days: 14
}]
});
```
## Product Operations
### Create Product
```typescript
const product = await polar.products.create({
organization_id: "org_xxx",
name: "Pro Plan",
description: "Professional features",
prices: [{
type: "recurring",
recurring_interval: "month",
price_amount: 2000,
pricing_type: "fixed"
}]
});
```
### List Products
```typescript
const products = await polar.products.list({
organization_id: "org_xxx",
is_archived: false
});
```
### Update Product
```typescript
const product = await polar.products.update(productId, {
name: "Pro Plan Updated",
description: "New description"
});
```
### Archive Product
```typescript
await polar.products.archive(productId);
// Products can be unarchived later
// Cannot be deleted (maintains order history)
```
### Update Benefits
```typescript
await polar.products.updateBenefits(productId, {
benefits: [benefitId1, benefitId2]
});
```
## Important Constraints
1. **Cannot change after creation:**
- Billing cycle (one-time, monthly, yearly)
- Pricing type (fixed, pay-what-you-want, free)
2. **Price changes don't affect existing subscribers:**
- Current subscribers keep their original price
- New subscribers get new price
- Use separate products for significant changes
3. **Products cannot be deleted:**
- Archive instead
- Maintains order history integrity
- Archived products not shown to new customers
4. **Metadata vs Custom Fields:**
- Metadata: For internal use, not shown to customers
- Custom Fields: Collected from customers at checkout
## Best Practices
1. **Product Strategy:**
- Plan billing cycle carefully before creation
- Use separate products for different tiers
- Archive unused products rather than delete
2. **Pricing Changes:**
- Create new product for major changes
- Grandfather existing customers
- Communicate changes clearly
3. **Usage-Based:**
- Define clear meter aggregations
- Set appropriate billing intervals
- Monitor usage patterns
- Provide usage dashboards to customers
4. **Custom Fields:**
- Collect only necessary information
- Validate on frontend before checkout
- Use for personalization and support
5. **Trials:**
- Set appropriate trial duration
- Communicate trial end clearly
- Notify before trial expires
- Easy cancellation during trial

View File

@@ -0,0 +1,436 @@
# Polar SDK Usage
Multi-language SDKs and framework adapters.
## TypeScript/JavaScript
**Installation:**
```bash
npm install @polar-sh/sdk
```
**Configuration:**
```typescript
import { Polar } from '@polar-sh/sdk';
const polar = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN,
server: "production" // or "sandbox"
});
```
**Usage:**
```typescript
// Products
const products = await polar.products.list({ organization_id: "org_xxx" });
const product = await polar.products.create({ name: "Pro Plan", ... });
// Checkouts
const checkout = await polar.checkouts.create({
product_price_id: "price_xxx",
success_url: "https://example.com/success"
});
// Subscriptions
const subs = await polar.subscriptions.list({ customer_id: "cust_xxx" });
await polar.subscriptions.update(subId, { metadata: { plan: "pro" } });
// Orders
const orders = await polar.orders.list({ organization_id: "org_xxx" });
const order = await polar.orders.get(orderId);
// Customers
const customer = await polar.customers.get({ external_id: "user_123" });
// Events (usage-based)
await polar.events.create({
external_customer_id: "user_123",
event_name: "api_call",
properties: { tokens: 1000 }
});
```
**Pagination:**
```typescript
// Automatic pagination
for await (const product of polar.products.listAutoPaging()) {
console.log(product.name);
}
// Manual pagination
let page = 1;
while (true) {
const response = await polar.products.list({ page, limit: 100 });
if (response.items.length === 0) break;
// Process items
page++;
}
```
## Python
**Installation:**
```bash
pip install polar-sdk
```
**Configuration:**
```python
from polar_sdk import Polar
polar = Polar(
access_token=os.environ["POLAR_ACCESS_TOKEN"],
server="production" # or "sandbox"
)
```
**Sync Usage:**
```python
# Products
products = polar.products.list(organization_id="org_xxx")
product = polar.products.create(name="Pro Plan", ...)
# Checkouts
checkout = polar.checkouts.create(
product_price_id="price_xxx",
success_url="https://example.com/success"
)
# Subscriptions
subs = polar.subscriptions.list(customer_id="cust_xxx")
polar.subscriptions.update(sub_id, metadata={"plan": "pro"})
# Orders
orders = polar.orders.list(organization_id="org_xxx")
order = polar.orders.get(order_id)
# Events
polar.events.create(
external_customer_id="user_123",
event_name="api_call",
properties={"tokens": 1000}
)
```
**Async Usage:**
```python
import asyncio
from polar_sdk import AsyncPolar
async def main():
polar = AsyncPolar(access_token=os.environ["POLAR_ACCESS_TOKEN"])
products = await polar.products.list(organization_id="org_xxx")
checkout = await polar.checkouts.create(...)
asyncio.run(main())
```
## PHP
**Installation:**
```bash
composer require polar-sh/sdk
```
**Configuration:**
```php
use Polar\Polar;
$polar = new Polar(
accessToken: $_ENV['POLAR_ACCESS_TOKEN'],
server: 'production' // or 'sandbox'
);
```
**Usage:**
```php
// Products
$products = $polar->products->list(['organization_id' => 'org_xxx']);
$product = $polar->products->create(['name' => 'Pro Plan', ...]);
// Checkouts
$checkout = $polar->checkouts->create([
'product_price_id' => 'price_xxx',
'success_url' => 'https://example.com/success'
]);
// Subscriptions
$subs = $polar->subscriptions->list(['customer_id' => 'cust_xxx']);
$polar->subscriptions->update($subId, ['metadata' => ['plan' => 'pro']]);
// Orders
$orders = $polar->orders->list(['organization_id' => 'org_xxx']);
$order = $polar->orders->get($orderId);
// Events
$polar->events->create([
'external_customer_id' => 'user_123',
'event_name' => 'api_call',
'properties' => ['tokens' => 1000]
]);
```
## Go
**Installation:**
```bash
go get github.com/polarsource/polar-go
```
**Usage:**
```go
import (
"github.com/polarsource/polar-go"
)
client := polar.NewClient(
polar.WithAccessToken(os.Getenv("POLAR_ACCESS_TOKEN")),
polar.WithEnvironment("production"),
)
// Products
products, err := client.Products.List(ctx, &polar.ProductListParams{
OrganizationID: "org_xxx",
})
// Checkouts
checkout, err := client.Checkouts.Create(ctx, &polar.CheckoutCreateParams{
ProductPriceID: "price_xxx",
SuccessURL: "https://example.com/success",
})
```
## Framework Adapters
### Next.js (@polar-sh/nextjs)
**Quick Start:**
```bash
npx polar-init
```
**Configuration:**
```typescript
// lib/polar.ts
import { PolarClient } from '@polar-sh/nextjs';
export const polar = new PolarClient({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
webhookSecret: process.env.POLAR_WEBHOOK_SECRET!
});
```
**Checkout Handler:**
```typescript
// app/actions/checkout.ts
'use server'
import { polar } from '@/lib/polar';
export async function createCheckout(priceId: string) {
const session = await polar.checkouts.create({
product_price_id: priceId,
success_url: `${process.env.NEXT_PUBLIC_URL}/success?checkout_id={CHECKOUT_ID}`
});
return session.url;
}
```
**Webhook Handler:**
```typescript
// app/api/webhook/polar/route.ts
import { polar } from '@/lib/polar';
export async function POST(req: Request) {
const event = await polar.webhooks.validate(req);
switch (event.type) {
case 'order.paid':
await handleOrderPaid(event.data);
break;
// ... other events
}
return Response.json({ received: true });
}
```
### Laravel (polar-sh/laravel)
**Installation:**
```bash
composer require polar-sh/laravel
php artisan vendor:publish --tag=polar-config
php artisan vendor:publish --tag=polar-migrations
php artisan migrate
```
**Configuration:**
```php
// config/polar.php
return [
'access_token' => env('POLAR_ACCESS_TOKEN'),
'webhook_secret' => env('POLAR_WEBHOOK_SECRET'),
];
```
**Checkout:**
```php
use Polar\Facades\Polar;
Route::post('/checkout', function (Request $request) {
$checkout = Polar::checkouts()->create([
'product_price_id' => $request->input('price_id'),
'success_url' => route('checkout.success'),
'external_customer_id' => auth()->id(),
]);
return redirect($checkout['url']);
});
```
**Webhook:**
```php
use Polar\Events\WebhookReceived;
// app/Listeners/PolarWebhookHandler.php
class PolarWebhookHandler
{
public function handle(WebhookReceived $event)
{
match ($event->payload['type']) {
'order.paid' => $this->handleOrderPaid($event->payload['data']),
'subscription.revoked' => $this->handleRevoked($event->payload['data']),
default => null,
};
}
}
```
### Express
```javascript
const express = require('express');
const { Polar } = require('@polar-sh/sdk');
const { validateEvent } = require('@polar-sh/sdk/webhooks');
const app = express();
const polar = new Polar({ accessToken: process.env.POLAR_ACCESS_TOKEN });
app.use(express.json());
app.post('/checkout', async (req, res) => {
const session = await polar.checkouts.create({
product_price_id: req.body.priceId,
success_url: 'https://example.com/success',
external_customer_id: req.user.id
});
res.json({ url: session.url });
});
app.post('/webhook/polar', (req, res) => {
const event = validateEvent(
req.body,
req.headers,
process.env.POLAR_WEBHOOK_SECRET
);
handleEvent(event);
res.json({ received: true });
});
```
### Remix
```typescript
import { Polar } from '@polar-sh/sdk';
const polar = new Polar({ accessToken: process.env.POLAR_ACCESS_TOKEN });
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const priceId = formData.get('priceId');
const session = await polar.checkouts.create({
product_price_id: priceId,
success_url: `${request.url}/success`
});
return redirect(session.url);
}
```
## BetterAuth Integration
**Installation:**
```bash
npm install @polar-sh/better-auth
```
**Configuration:**
```typescript
import { betterAuth } from 'better-auth';
import { polarPlugin } from '@polar-sh/better-auth';
export const auth = betterAuth({
database: db,
plugins: [
polarPlugin({
organizationId: process.env.POLAR_ORG_ID!,
accessToken: process.env.POLAR_ACCESS_TOKEN!
})
]
});
```
**Features:**
- Auto-create Polar customers on signup
- Automatic external_id mapping
- User-customer sync
- Access customer data in auth session
## Error Handling
**TypeScript:**
```typescript
try {
const product = await polar.products.get(productId);
} catch (error) {
if (error.statusCode === 404) {
console.error('Product not found');
} else if (error.statusCode === 429) {
console.error('Rate limit exceeded');
} else {
console.error('API error:', error.message);
}
}
```
**Python:**
```python
from polar_sdk.exceptions import PolarException
try:
product = polar.products.get(product_id)
except PolarException as e:
if e.status_code == 404:
print("Product not found")
elif e.status_code == 429:
print("Rate limit exceeded")
else:
print(f"API error: {e.message}")
```
## Best Practices
1. **Environment Variables:** Store credentials securely
2. **Error Handling:** Catch and handle API errors appropriately
3. **Rate Limiting:** Implement backoff for 429 responses
4. **Pagination:** Use auto-paging for large datasets
5. **Webhooks:** Always verify signatures
6. **Testing:** Use sandbox for development
7. **Logging:** Log API calls for debugging
8. **Retry Logic:** Implement for transient failures

View File

@@ -0,0 +1,340 @@
# Polar Subscriptions
Subscription lifecycle, upgrades, downgrades, and trial management.
## Lifecycle States
- `created` - New subscription, payment pending
- `active` - Payment successful, benefits granted
- `canceled` - Scheduled cancellation at period end
- `revoked` - Billing stopped, benefits revoked immediately
- `past_due` - Payment failed, in dunning period
## API Operations
### List Subscriptions
```typescript
const subscriptions = await polar.subscriptions.list({
organization_id: "org_xxx",
product_id: "prod_xxx",
customer_id: "cust_xxx",
status: "active"
});
```
### Get Subscription
```typescript
const subscription = await polar.subscriptions.get(subscriptionId);
```
### Update Subscription
```typescript
const updated = await polar.subscriptions.update(subscriptionId, {
product_price_id: "newPriceId",
discount_id: "discount_xxx",
metadata: { plan: "pro" }
});
```
## Upgrades & Downgrades
### Proration Options
**Next Invoice (default):**
- Credit/charge applied to upcoming invoice
- Subscription updates immediately
- Customer billed at next cycle
**Invoice Immediately:**
- Credit/charge processed right away
- Subscription updates immediately
- New invoice generated
```typescript
await polar.subscriptions.update(subscriptionId, {
product_price_id: "higher_tier_price",
proration: "invoice_immediately" // or "next_invoice"
});
```
### Customer-Initiated Changes
**Enable in Product Settings:**
- Toggle "Allow price change"
- Customer can upgrade/downgrade via portal
- Admin-only changes if disabled
**Implementation:**
```typescript
// Check if changes allowed
const product = await polar.products.get(productId);
if (product.allow_price_change) {
// Customer can change via portal
}
```
## Trials
### Configuration
**Product-level:**
```typescript
const product = await polar.products.create({
name: "Pro Plan",
prices: [{
trial_period_days: 14
}]
});
```
**Checkout-level:**
```typescript
const session = await polar.checkouts.create({
product_price_id: "price_xxx",
trial_period_days: 7 // Overrides product setting
});
```
### Trial Behavior
- Customer not charged during trial
- Benefits granted immediately
- Can cancel anytime during trial
- Charged at trial end if not canceled
### Trial Events
```typescript
// Listen to webhooks
subscription.created // Trial starts
subscription.active // Trial ends, first charge
subscription.canceled // Trial canceled
```
## Cancellations
### Cancel at Period End
```typescript
await polar.subscriptions.update(subscriptionId, {
cancel_at_period_end: true
});
// Subscription remains active
// Benefits continue until period end
// Webhooks: subscription.updated, subscription.canceled
```
### Immediate Revocation
```typescript
// Happens automatically at period end
// Or manually via API (future feature)
// Status changes to "revoked"
// Billing stops, benefits revoked
// Webhooks: subscription.updated, subscription.revoked
```
### Reactivate Canceled
```typescript
await polar.subscriptions.update(subscriptionId, {
cancel_at_period_end: false
});
// Removes cancellation
// Subscription continues normally
```
## Renewals
### Listening to Renewals
```typescript
app.post('/webhook/polar', async (req, res) => {
const event = validateEvent(req.body, req.headers, secret);
if (event.type === 'order.created') {
const order = event.data;
if (order.billing_reason === 'subscription_cycle') {
// This is a renewal
await handleRenewal(order.subscription_id);
}
}
res.json({ received: true });
});
```
### Failed Renewals
- `subscription.past_due` webhook fired
- Dunning process initiated
- Customer notified via email
- Multiple retry attempts
- Eventually revoked if payment fails
## Discounts
### Apply Discount
```typescript
await polar.subscriptions.update(subscriptionId, {
discount_id: "discount_xxx"
});
```
### Remove Discount
```typescript
await polar.subscriptions.update(subscriptionId, {
discount_id: null
});
```
### Discount Types
- Percentage off: 20% off
- Fixed amount: $5 off
- Duration: once, forever, repeating
## Customer Portal
### Generate Portal Access
```typescript
const session = await polar.customerSessions.create({
customer_id: "cust_xxx"
});
// Redirect to: session.url
```
### Portal Features
- View subscriptions
- Upgrade/downgrade plans
- Cancel subscriptions
- Update billing info
- View invoices
- Access benefits
### Pre-authenticated Links
```typescript
// From your app, create session and redirect
app.get('/portal', async (req, res) => {
const session = await polar.customerSessions.create({
external_customer_id: req.user.id
});
res.redirect(session.url);
});
```
## Metadata
### Update Subscription Metadata
```typescript
await polar.subscriptions.update(subscriptionId, {
metadata: {
internal_id: "sub_123",
tier: "pro",
source: "web"
}
});
```
### Query by Metadata
```typescript
const subscriptions = await polar.subscriptions.list({
organization_id: "org_xxx",
metadata: { tier: "pro" }
});
```
## Best Practices
1. **Lifecycle Management:**
- Listen to all subscription webhooks
- Handle each state appropriately
- Sync state to your database
- Grant/revoke access based on state
2. **Upgrades/Downgrades:**
- Use proration for fair billing
- Communicate changes clearly
- Preview invoice before change
- Allow customer self-service
3. **Trials:**
- Set appropriate trial duration
- Notify before trial ends
- Easy cancellation during trial
- Clear trial end date in UI
4. **Cancellations:**
- Make cancellation easy
- Offer alternatives (pause, downgrade)
- Collect feedback
- Keep benefits until period end
- Send confirmation email
5. **Failed Payments:**
- Handle `past_due` webhook
- Notify customer promptly
- Provide retry mechanism
- Grace period before revocation
- Clear reactivation path
6. **Customer Communication:**
- Renewal reminders
- Payment confirmations
- Failed payment notifications
- Upgrade/downgrade confirmations
- Cancellation confirmations
7. **Analytics:**
- Track churn reasons
- Monitor upgrade/downgrade patterns
- Analyze trial conversion
- Measure payment failure rates
- Lifetime value calculations
## Common Patterns
### Subscription Status Check
```typescript
async function hasActiveSubscription(userId) {
const subscriptions = await polar.subscriptions.list({
external_customer_id: userId,
status: "active"
});
return subscriptions.items.length > 0;
}
```
### Grace Period Handler
```typescript
app.post('/webhook/polar', async (req, res) => {
const event = validateEvent(req.body, req.headers, secret);
if (event.type === 'subscription.past_due') {
const subscription = event.data;
// Grant 3-day grace period
await grantGracePeriod(subscription.customer_id, 3);
// Notify customer
await sendPaymentFailedEmail(subscription.customer_id);
}
res.json({ received: true });
});
```
### Upgrade Path
```typescript
async function upgradeSubscription(subscriptionId, newPriceId) {
// Preview invoice
const preview = await polar.subscriptions.previewUpdate(subscriptionId, {
product_price_id: newPriceId,
proration: "invoice_immediately"
});
// Show customer preview
if (await confirmUpgrade(preview)) {
await polar.subscriptions.update(subscriptionId, {
product_price_id: newPriceId,
proration: "invoice_immediately"
});
}
}
```

View File

@@ -0,0 +1,405 @@
# Polar Webhooks
Event handling, signature verification, and monitoring.
## Setup
1. Org Settings → Webhooks
2. Enter endpoint URL (publicly accessible)
3. Receive webhook secret (base64 encoded)
4. Select event types
5. Save configuration
**Requirements:**
- HTTPS endpoint
- Respond within 20 seconds
- Return 2xx status code
## Signature Verification
### Headers
```
webhook-id: msg_xxx
webhook-signature: v1,signature_xxx
webhook-timestamp: 1642000000
```
### TypeScript Verification
```typescript
import { validateEvent, WebhookVerificationError } from '@polar-sh/sdk/webhooks';
app.post('/webhook/polar', (req, res) => {
try {
const event = validateEvent(
req.body,
req.headers,
process.env.POLAR_WEBHOOK_SECRET
);
// Event is valid, process it
await handleEvent(event);
res.json({ received: true });
} catch (error) {
if (error instanceof WebhookVerificationError) {
console.error('Invalid webhook signature');
return res.status(400).json({ error: 'Invalid signature' });
}
throw error;
}
});
```
### Python Verification
```python
from polar_sdk.webhooks import validate_event, WebhookVerificationError
@app.route('/webhook/polar', methods=['POST'])
def polar_webhook():
try:
event = validate_event(
request.get_data(),
dict(request.headers),
os.environ['POLAR_WEBHOOK_SECRET']
)
handle_event(event)
return {'received': True}
except WebhookVerificationError:
return {'error': 'Invalid signature'}, 400
```
### Manual Verification
```typescript
import crypto from 'crypto';
function verifySignature(payload, headers, secret) {
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'].split(',');
const signedPayload = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', Buffer.from(secret, 'base64'))
.update(signedPayload)
.digest('base64');
return signatures.some(sig => {
const [version, signature] = sig.split('=');
return version === 'v1' && signature === expectedSignature;
});
}
```
## Event Types
### Checkout
- `checkout.created` - Checkout session created
- `checkout.updated` - Session updated
### Order
- `order.created` - Order created (check `billing_reason`)
- `purchase` - One-time product
- `subscription_create` - New subscription
- `subscription_cycle` - Renewal
- `subscription_update` - Plan change
- `order.paid` - Payment confirmed
- `order.updated` - Order updated
- `order.refunded` - Refund processed
### Subscription
- `subscription.created` - Subscription created
- `subscription.active` - Subscription activated
- `subscription.updated` - Subscription modified
- `subscription.canceled` - Cancellation scheduled
- `subscription.revoked` - Subscription terminated
**Note:** Multiple events may fire for single action
### Customer
- `customer.created` - Customer created
- `customer.updated` - Customer modified
- `customer.deleted` - Customer deleted
- `customer.state_changed` - Benefits/subscriptions changed
### Benefit Grant
- `benefit_grant.created` - Benefit granted
- `benefit_grant.updated` - Grant modified
- `benefit_grant.revoked` - Benefit revoked
### Refund
- `refund.created` - Refund initiated
- `refund.updated` - Refund status changed
### Product
- `product.created` - Product created
- `product.updated` - Product modified
## Event Structure
```typescript
{
"type": "order.paid",
"data": {
"id": "order_xxx",
"amount": 2000,
"currency": "USD",
"billing_reason": "purchase",
"customer": { ... },
"product": { ... },
"subscription": null,
"metadata": { ... }
}
}
```
## Handler Implementation
### Basic Handler
```typescript
async function handleEvent(event) {
switch (event.type) {
case 'order.paid':
await handleOrderPaid(event.data);
break;
case 'subscription.active':
await grantAccess(event.data.customer_id);
break;
case 'subscription.revoked':
await revokeAccess(event.data.customer_id);
break;
case 'benefit_grant.created':
await notifyBenefitGranted(event.data);
break;
default:
console.log(`Unhandled event: ${event.type}`);
}
}
```
### Order Handler
```typescript
async function handleOrderPaid(order) {
// Handle different billing reasons
switch (order.billing_reason) {
case 'purchase':
await fulfillOneTimeOrder(order);
break;
case 'subscription_create':
await handleNewSubscription(order);
break;
case 'subscription_cycle':
await handleRenewal(order);
break;
case 'subscription_update':
await handleUpgrade(order);
break;
}
}
```
### Customer State Handler
```typescript
async function handleCustomerStateChanged(customer) {
// Customer state includes:
// - active_subscriptions
// - active_benefits
const hasActiveSubscription = customer.active_subscriptions.length > 0;
if (hasActiveSubscription) {
await enableFeatures(customer.external_id);
} else {
await disableFeatures(customer.external_id);
}
}
```
## Best Practices
### 1. Respond Immediately
```typescript
app.post('/webhook/polar', async (req, res) => {
// Respond quickly
res.json({ received: true });
// Queue for background processing
await webhookQueue.add('polar-webhook', req.body);
});
```
### 2. Idempotency
```typescript
async function handleEvent(event) {
// Check if already processed
const exists = await db.processedEvents.findOne({
webhook_id: event.id
});
if (exists) {
console.log('Event already processed');
return;
}
// Process event
await processEvent(event);
// Mark as processed
await db.processedEvents.insert({
webhook_id: event.id,
processed_at: new Date()
});
}
```
### 3. Retry Logic
```typescript
async function processWithRetry(event, maxRetries = 3) {
let attempt = 0;
while (attempt < maxRetries) {
try {
await handleEvent(event);
return;
} catch (error) {
attempt++;
if (attempt >= maxRetries) throw error;
await sleep(1000 * attempt);
}
}
}
```
### 4. Error Handling
```typescript
app.post('/webhook/polar', async (req, res) => {
try {
const event = validateEvent(req.body, req.headers, secret);
res.json({ received: true });
await processWithRetry(event);
} catch (error) {
console.error('Webhook processing failed:', error);
// Log to error tracking service
await logError(error, req.body);
if (error instanceof WebhookVerificationError) {
return res.status(400).json({ error: 'Invalid signature' });
}
// Return 2xx even on processing errors
// Polar will retry if non-2xx
res.json({ received: true });
}
});
```
### 5. Logging
```typescript
logger.info('Webhook received', {
event_type: event.type,
event_id: event.id,
customer_id: event.data.customer?.id,
amount: event.data.amount
});
```
## Monitoring
### Dashboard Features
- View webhook attempts
- Check response status
- Review retry history
- Manual retry option
- Filter by event type
- Search by customer
### Application Monitoring
```typescript
const metrics = {
webhooks_received: counter('polar_webhooks_received_total'),
webhooks_processed: counter('polar_webhooks_processed_total'),
webhooks_failed: counter('polar_webhooks_failed_total'),
processing_time: histogram('polar_webhook_processing_seconds')
};
app.post('/webhook/polar', async (req, res) => {
metrics.webhooks_received.inc({ type: req.body.type });
const timer = metrics.processing_time.startTimer();
try {
await handleEvent(req.body);
metrics.webhooks_processed.inc({ type: req.body.type });
} catch (error) {
metrics.webhooks_failed.inc({ type: req.body.type });
} finally {
timer();
}
res.json({ received: true });
});
```
## Framework Adapters
### Next.js
```typescript
import { validateEvent } from '@polar-sh/nextjs/webhooks';
export async function POST(req: Request) {
const event = await validateEvent(req);
await handleEvent(event);
return Response.json({ received: true });
}
```
### Laravel
```php
use Polar\Webhooks\WebhookHandler;
Route::post('/webhook/polar', function (Request $request) {
$event = WebhookHandler::validate(
$request->getContent(),
$request->headers->all(),
config('polar.webhook_secret')
);
dispatch(new ProcessPolarWebhook($event));
return response()->json(['received' => true]);
});
```
## Testing
### Manual Testing
```bash
# Use Polar dashboard to send test webhooks
# Or use webhook testing tools
curl -X POST https://your-domain.com/webhook/polar \
-H "Content-Type: application/json" \
-H "webhook-id: msg_test" \
-H "webhook-timestamp: $(date +%s)" \
-H "webhook-signature: v1,test_signature" \
-d '{"type":"order.paid","data":{...}}'
```
### Local Testing with ngrok
```bash
# Expose local server
ngrok http 3000
# Use ngrok URL in Polar webhook settings
https://abc123.ngrok.io/webhook/polar
```