init
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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']);
|
||||
});
|
||||
```
|
||||
@@ -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`
|
||||
@@ -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
|
||||
436
.opencode/skills/payment-integration/references/polar/sdk.md
Normal file
436
.opencode/skills/payment-integration/references/polar/sdk.md
Normal 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
|
||||
@@ -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"
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
Reference in New Issue
Block a user