
Payment Integration
- 319 installs
- 2.2k repo stars
- Updated April 3, 2026
- mrgoonie/claudekit-skills
payment-integration is a Claude Code skill (version 2.2.0) that guides developers to implement checkout, subscriptions, and webhook flows across Stripe, Polar, Paddle, SePay, and Creem.io payment providers.
About
payment-integration is a mrgoonie/claudekit-skills framework (version 2.2.0, MIT license) for production payment wiring across five providers: SePay (Vietnamese VietQR and 44+ banks), Polar (SaaS MoR subscriptions), Stripe (CheckoutSessions, Billing, Connect, Payment Element), Paddle (MoR subscriptions with tax compliance), and Creem.io (licensing and revenue splits). It bundles reference docs for Stripe server SDKs, stripe-js Payment Element, stripe-cli local testing, webhook idempotency, subscription lifecycle management, and multi-provider order orchestration. Developers reach for payment-integration when adding monetization to apps and needing provider-specific checkout patterns, verified webhooks, and regional coverage guidance rather than reading scattered provider documentation alone.
- Checkout flows
- Webhook handling
- Subscription billing
- Idempotent charges
- Provider SDK patterns
Payment Integration by the numbers
- 319 all-time installs (skills.sh)
- +3 installs in the week ending Jul 26, 2026 (Skillselion tracking)
- Ranked #1,253 of 4,348 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/mrgoonie/claudekit-skills --skill payment-integrationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 319 |
|---|---|
| repo stars | ★ 2.2k |
| Last updated | April 3, 2026 |
| Repository | mrgoonie/claudekit-skills ↗ |
How do you integrate Stripe checkout and webhooks?
Implement Stripe or similar payment flows—checkout, subscriptions, webhooks, and error handling—so monetized apps can charge users reliably in production.
Who is it for?
Full-stack developers adding monetization who need production checkout, webhook, and subscription patterns across Stripe, Polar, Paddle, SePay, or Creem.io.
Skip if: Teams with a single finalized payment provider and no need for multi-gateway comparison, VietQR, or licensing integration guidance.
When should I use this skill?
A developer asks to integrate Stripe checkout, set up payment webhooks, add subscriptions, implement VietQR via SePay, or compare Paddle vs Polar MoR flows.
What you get
Payment checkout flows, verified webhook handlers, subscription lifecycle code, and multi-provider order orchestration patterns.
- Checkout integration code patterns
- Webhook verification handlers
- Subscription lifecycle management guides
By the numbers
- Covers 5 payment providers in version 2.2.0
- SePay integration documents 44+ Vietnamese bank transfer support
Files
Payment Integration
Production-proven payment processing with SePay (Vietnamese banks), Polar (global SaaS), Stripe (global infrastructure), Paddle (MoR subscriptions), and Creem.io (MoR + licensing).
When to Use
- Payment gateway integration (checkout, processing)
- Subscription management (trials, upgrades, billing)
- Webhook handling (notifications, idempotency)
- QR code payments (VietQR, NAPAS)
- Software licensing (device activation)
- Multi-provider order management
- Revenue splits and commissions
Platform Selection
| Platform | Best For |
|---|---|
| SePay | Vietnamese market, VND, bank transfers, VietQR |
| Polar | Global SaaS, subscriptions, automated benefits (GitHub/Discord) |
| Stripe | Enterprise payments, Connect platforms, custom checkout |
| Paddle | MoR subscriptions, global tax compliance, churn prevention |
| Creem.io | MoR + licensing, revenue splits, no-code checkout |
Quick Reference
SePay
references/sepay/overview.md- Auth, supported banksreferences/sepay/api.md- Endpoints, transactionsreferences/sepay/webhooks.md- Setup, verificationreferences/sepay/sdk.md- Node.js, PHP, Laravelreferences/sepay/qr-codes.md- VietQR generationreferences/sepay/best-practices.md- Production patterns
Polar
references/polar/overview.md- Auth, MoR conceptreferences/polar/products.md- Pricing modelsreferences/polar/checkouts.md- Checkout flowsreferences/polar/subscriptions.md- Lifecycle managementreferences/polar/webhooks.md- Event handlingreferences/polar/benefits.md- Automated deliveryreferences/polar/sdk.md- Multi-language SDKsreferences/polar/best-practices.md- Production patterns
Stripe
references/stripe/stripe-best-practices.md- Integration designreferences/stripe/stripe-sdks.md- Server SDKsreferences/stripe/stripe-js.md- Payment Elementreferences/stripe/stripe-cli.md- Local testingreferences/stripe/stripe-upgrade.md- Version upgrades- External: https://docs.stripe.com/llms.txt
Paddle
references/paddle/overview.md- MoR, auth, entity IDsreferences/paddle/api.md- Products, prices, transactionsreferences/paddle/paddle-js.md- Checkout overlay/inlinereferences/paddle/subscriptions.md- Trials, upgrades, pausereferences/paddle/webhooks.md- SHA256 verificationreferences/paddle/sdk.md- Node, Python, PHP, Goreferences/paddle/best-practices.md- Production patterns- External: https://developer.paddle.com/llms.txt
Creem.io
references/creem/overview.md- MoR, auth, global supportreferences/creem/api.md- Products, checkout sessionsreferences/creem/checkouts.md- No-code links, storefrontsreferences/creem/subscriptions.md- Trials, seat-basedreferences/creem/licensing.md- Device activationreferences/creem/webhooks.md- Signature verificationreferences/creem/sdk.md- Next.js, Better Auth- External: https://docs.creem.io/llms.txt
Multi-Provider
references/multi-provider-order-management-patterns.md- Unified orders, currency conversion
Scripts
scripts/sepay-webhook-verify.js- SePay webhook verificationscripts/polar-webhook-verify.js- Polar webhook verificationscripts/checkout-helper.js- Checkout session generator
Key Capabilities
| Platform | Highlights |
|---|---|
| SePay | QR/bank/cards, 44+ VN banks, webhooks, 2 req/s |
| Polar | MoR, subscriptions, usage billing, benefits, 300 req/min |
| Stripe | CheckoutSessions, Billing, Connect, Payment Element |
| Paddle | MoR, overlay/inline checkout, Retain (churn prevention), tax |
| Creem.io | MoR, licensing, revenue splits, no-code checkout |
Implementation
See references/implementation-workflows.md for step-by-step guides per platform.
General flow: auth → products → checkout → webhooks → events
Payment Integration Skill
Comprehensive payment integration skill for SePay (Vietnamese payment gateway), Polar (global SaaS monetization platform), and Stripe (global payment infrastructure).
Features
SePay Integration
- Vietnamese payment gateway with VietQR, NAPAS, bank transfers, and cards
- 44+ supported banks
- Webhook verification with API Key/OAuth2 authentication
- QR code generation API
- Order-based virtual accounts
- SDK support for Node.js, PHP, and Laravel
Polar Integration
- Global SaaS monetization platform
- Merchant of Record (handles global tax compliance)
- Subscription management with trials, upgrades, downgrades
- Usage-based billing with events and meters
- Automated benefit delivery (GitHub repos, Discord roles, license keys, files)
- Customer self-service portal
- Multi-language SDKs (TypeScript, Python, PHP, Go)
- Framework adapters (Next.js, Laravel, Remix, etc.)
Stripe Integration
- Global payment infrastructure
- CheckoutSessions, PaymentIntents, SetupIntents APIs
- Billing and subscriptions at scale
- Connect for marketplaces and platforms
- Payment Element for custom checkout experiences
- Multi-language SDKs (Node.js, Python, Ruby, PHP, Java, Go, .NET)
- Best practices for integration design and API version upgrades
Structure
payment-integration/
├── SKILL.md # Main skill definition
├── README.md # This file
├── references/ # Progressive disclosure documentation
│ ├── sepay/ # SePay integration guides
│ │ ├── overview.md # Auth, capabilities, environments
│ │ ├── api.md # API endpoints and operations
│ │ ├── webhooks.md # Webhook setup and handling
│ │ ├── sdk.md # SDK usage (Node.js, PHP, Laravel)
│ │ ├── qr-codes.md # VietQR generation
│ │ └── best-practices.md # Security, patterns, monitoring
│ ├── polar/ # Polar integration guides
│ │ ├── overview.md # Auth, MoR concept, environments
│ │ ├── products.md # Products, pricing, usage-based billing
│ │ ├── checkouts.md # Checkout flows and embedded checkout
│ │ ├── subscriptions.md # Lifecycle, upgrades, trials
│ │ ├── webhooks.md # Event handling and verification
│ │ ├── benefits.md # Automated benefit delivery
│ │ ├── sdk.md # Multi-language SDK usage
│ │ └── best-practices.md # Security, patterns, monitoring
│ └── stripe/ # Stripe integration guides
│ ├── stripe-best-practices.md # Integration design, API selection
│ └── stripe-upgrade.md # API versions, SDK upgrades
└── scripts/ # Integration helper scripts
├── sepay-webhook-verify.js # SePay webhook verification
├── polar-webhook-verify.js # Polar webhook verification
├── checkout-helper.js # Checkout session generation
├── test-scripts.js # Test suite for all scripts
├── package.json # Node.js package configuration
└── .env.example # Environment variable templateUsage
Activate the Skill
Claude Code will automatically activate this skill when you mention payment integration, subscriptions, webhooks, or platform-specific terms (SePay, Polar).
Manual Activation
In conversations, simply reference the platforms:
- "Implement SePay payment integration"
- "Set up Polar subscriptions with usage-based billing"
- "Create webhook handler for payment notifications"
Using Scripts
SePay Webhook Verification:
cd $HOME/.claude/skills/payment-integration/scripts
node sepay-webhook-verify.js '{"id":12345,"gateway":"Vietcombank",...}'Polar Webhook Verification:
node polar-webhook-verify.js '{"type":"order.paid","data":{...}}' base64secretCheckout Helper:
# SePay
node checkout-helper.js sepay '{"orderInvoiceNumber":"ORD001","orderAmount":100000,...}'
# Polar
node checkout-helper.js polar '{"productPriceId":"price_xxx","successUrl":"https://..."}'Run Tests:
npm testEnvironment Variables
Copy .env.example to .env and configure:
# SePay
SEPAY_MERCHANT_ID=SP-TEST-XXXXXXX
SEPAY_SECRET_KEY=spsk_test_xxxxxxxxxxxxx
SEPAY_ENV=sandbox
SEPAY_WEBHOOK_API_KEY=your_key
# Polar
POLAR_ACCESS_TOKEN=polar_xxxxxxxxxxxxxxxx
POLAR_SERVER=sandbox
POLAR_WEBHOOK_SECRET=base64_secretProgressive Disclosure
The skill uses progressive disclosure to minimize context usage: 1. SKILL.md - Overview and quick reference (~99 lines) 2. references/ - Detailed guides loaded as needed (<100 lines each) 3. scripts/ - Executable helpers with embedded examples
Load only the references you need for your current task.
Platform Selection Guide
Choose SePay for:
- Vietnamese market targeting
- Bank transfer automation
- Local payment methods
- QR code payments (VietQR/NAPAS)
- Direct bank monitoring
Choose Polar for:
- Global market
- SaaS/subscription business
- Usage-based billing
- Automated benefit delivery
- Tax compliance (Merchant of Record)
- Customer self-service
Choose Stripe for:
- Global payment infrastructure
- Enterprise-grade payment processing
- Connect platforms (marketplaces)
- Billing/subscriptions at scale
- Custom checkout experiences (Payment Element)
- Maximum payment method coverage
Examples
SePay Payment Flow
1. Load references/sepay/overview.md for authentication 2. Load references/sepay/sdk.md for integration 3. Use checkout-helper.js to generate payment form 4. Load references/sepay/webhooks.md for notifications 5. Use sepay-webhook-verify.js to verify authenticity
Polar Subscription Flow
1. Load references/polar/overview.md for setup 2. Load references/polar/products.md for pricing 3. Load references/polar/checkouts.md for payment 4. Load references/polar/subscriptions.md for lifecycle 5. Load references/polar/webhooks.md for events 6. Load references/polar/benefits.md for automation
Stripe Integration Flow
1. Load references/stripe/stripe-best-practices.md for integration design 2. Choose: Checkout (hosted/embedded) or Payment Element 3. Use CheckoutSessions API for most use cases 4. Load references/stripe/stripe-upgrade.md when upgrading API versions
Testing
All scripts include comprehensive test coverage:
- SePay webhook verification (with/without authentication)
- Polar webhook signature validation
- Checkout configuration generation
- Error handling and edge cases
Run npm test in the scripts directory to verify functionality.
Support
SePay
- Docs: https://developer.sepay.vn/en
- Email: info@sepay.vn
- Hotline: 02873059589
Polar
- Docs: https://polar.sh/docs
- API Reference: https://polar.sh/docs/api-reference
- GitHub: https://github.com/polarsource/polar
Stripe
- Docs: https://docs.stripe.com
- API Reference: https://docs.stripe.com/api
- Changelog: https://docs.stripe.com/changelog
- Go Live Checklist: https://docs.stripe.com/get-started/checklist/go-live
License
MIT
Version
1.1.0
Creem.io API Reference
Checkout Sessions
Create Checkout Session
// POST /v1/checkout/sessions
const session = await creem.checkout.sessions.create({
product_id: 'prod_xxx',
success_url: 'https://example.com/success',
cancel_url: 'https://example.com/cancel',
customer_email: 'user@example.com', // Optional
metadata: { order_id: '123' } // Optional
});
// Returns: { url: 'https://checkout.creem.io/xxx', id: 'cs_xxx' }Products
Create Product
// POST /v1/products
const product = await creem.products.create({
name: 'Pro Plan',
description: 'Full access to all features',
price: 2900, // Amount in cents
currency: 'usd',
recurring: { // Optional - for subscriptions
interval: 'month',
interval_count: 1
}
});Retrieve Product
// GET /v1/products/:id
const product = await creem.products.retrieve('prod_xxx');Transactions
Retrieve Transaction
// GET /v1/transactions/:id
const transaction = await creem.transactions.retrieve('txn_xxx');List Transactions
// GET /v1/transactions
const transactions = await creem.transactions.list({
customer_id: 'cus_xxx', // Optional filter
product_id: 'prod_xxx', // Optional filter
status: 'completed', // Optional filter
limit: 25,
starting_after: 'txn_xxx' // Pagination cursor
});Customers
Retrieve Customer
// GET /v1/customers/:id
const customer = await creem.customers.retrieve('cus_xxx');
// GET /v1/customers/email/:email
const customer = await creem.customers.retrieveByEmail('user@example.com');List Customers
// GET /v1/customers
const customers = await creem.customers.list({
limit: 25,
starting_after: 'cus_xxx'
});Generate Portal Link
// POST /v1/customers/:id/portal
const portal = await creem.customers.createPortalSession('cus_xxx');
// Returns: { url: 'https://portal.creem.io/xxx' }Discount Codes
Create Discount
// POST /v1/discounts
const discount = await creem.discounts.create({
code: 'LAUNCH20',
type: 'percentage', // or 'fixed'
value: 20, // 20% or 20 cents
expires_at: '2024-12-31T23:59:59Z',
max_redemptions: 100 // Optional
});Retrieve Discount
// GET /v1/discounts/:code
const discount = await creem.discounts.retrieve('LAUNCH20');Delete Discount
// DELETE /v1/discounts/:code
await creem.discounts.delete('LAUNCH20');Error Handling
try {
const session = await creem.checkout.sessions.create({...});
} catch (error) {
if (error.type === 'invalid_request_error') {
console.error('Invalid parameters:', error.message);
} else if (error.type === 'authentication_error') {
console.error('Invalid API key');
} else if (error.type === 'rate_limit_error') {
console.error('Rate limited, retry after:', error.retry_after);
}
}Creem.io Checkouts
Checkout Options
1. Programmatic Sessions - Full API control 2. Checkout Links - No-code, shareable URLs 3. Storefronts - Hosted product pages
Create Checkout Session
const session = await creem.checkout.sessions.create({
product_id: 'prod_xxx',
success_url: 'https://example.com/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'https://example.com/cancel',
// Optional parameters
customer_email: 'user@example.com',
customer_id: 'cus_xxx', // Existing customer
quantity: 1, // For seat-based products
discount_code: 'LAUNCH20', // Pre-apply discount
metadata: {
order_id: '123',
referral_code: 'abc'
},
// Custom fields
custom_fields: [
{ key: 'company', label: 'Company Name', required: true }
]
});
// Redirect user to checkout
redirect(session.url);Checkout Customization
Configure in dashboard or via API:
- Branding: Logo, colors, themes
- Email Receipts: Custom templates
- Localization: Auto-detect or force language (42 supported)
- Custom Fields: Collect additional data
Retrieve Session
// GET /v1/checkout/sessions/:id
const session = await creem.checkout.sessions.retrieve('cs_xxx');
// Returns: { id, status, customer_id, product_id, amount, metadata, ... }Success URL Parameters
Creem replaces {CHECKOUT_SESSION_ID} in success URL:
// Frontend: parse session ID from URL
const urlParams = new URLSearchParams(window.location.search);
const sessionId = urlParams.get('session_id');
// Backend: verify and fulfill
const session = await creem.checkout.sessions.retrieve(sessionId);
if (session.status === 'complete') {
await fulfillOrder(session);
}No-Code Checkout Links
Create in dashboard - shareable URLs for any product. Good for:
- Social media links
- Email campaigns
- Quick sales without integration
Storefronts
Hosted product pages - display multiple products without custom website:
1. Configure storefront in dashboard 2. Add products to display 3. Share storefront URL 4. Customers browse and checkout
Cart Abandonment Recovery
Enable in dashboard - automatic emails sent when checkout abandoned:
- Configurable delay before sending
- Customizable email content
- Include discount code incentive
Embedding (Coming)
For embedded checkout in your site, see SDK adapters:
- Next.js Adapter
- React components
See references/creem/sdk.md for implementation details.
Creem.io Licensing
Software licensing with device activation management.
License Flow
purchase → license_key issued → activate device → validate → deactivateActivate License
Register a device against a license key:
// POST /v1/licenses/activate
const activation = await creem.licenses.activate({
license_key: 'XXXX-XXXX-XXXX-XXXX',
instance_id: 'device_fingerprint_123', // Unique device identifier
instance_name: 'MacBook Pro' // Optional friendly name
});
// Returns: {
// id: 'act_xxx',
// license_key: '...',
// instance_id: '...',
// activated_at: '...',
// valid_until: '...'
// }Validate License
Check if license is active for specific device:
// POST /v1/licenses/validate
const validation = await creem.licenses.validate({
license_key: 'XXXX-XXXX-XXXX-XXXX',
instance_id: 'device_fingerprint_123'
});
// Returns: {
// valid: true,
// license_key: '...',
// product_id: 'prod_xxx',
// customer_id: 'cus_xxx',
// expires_at: '2025-01-15T00:00:00Z',
// activations_used: 2,
// activations_limit: 5
// }Deactivate License
Remove device activation to free slot:
// POST /v1/licenses/deactivate
await creem.licenses.deactivate({
license_key: 'XXXX-XXXX-XXXX-XXXX',
instance_id: 'device_fingerprint_123'
});Client-Side Implementation
// Desktop app example (Electron, Tauri, etc.)
class LicenseManager {
constructor(apiKey) {
this.apiKey = apiKey;
this.instanceId = this.getDeviceFingerprint();
}
getDeviceFingerprint() {
// Generate unique device ID (machine ID, hardware hash, etc.)
return require('node-machine-id').machineIdSync();
}
async activate(licenseKey) {
const response = await fetch('https://api.creem.io/v1/licenses/activate', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
license_key: licenseKey,
instance_id: this.instanceId,
instance_name: os.hostname()
})
});
return response.json();
}
async validate(licenseKey) {
const response = await fetch('https://api.creem.io/v1/licenses/validate', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
license_key: licenseKey,
instance_id: this.instanceId
})
});
const data = await response.json();
return data.valid;
}
}Activation Limits
Configure per product - limits simultaneous device activations:
const product = await creem.products.create({
name: 'Desktop App License',
price: 4900,
currency: 'usd',
license_config: {
activations_limit: 3 // Max 3 devices per license
}
});License Events (Webhooks)
license.activated- Device activatedlicense.deactivated- Device deactivatedlicense.expired- License expired (subscription ended)
See references/creem/webhooks.md for webhook handling.
Creem.io Overview
Payment infrastructure platform supporting subscriptions, one-time payments, and licensing. Functions as Merchant of Record (MoR) - handles compliance, taxes, and payment processing.
Key Features
- Merchant of Record: Tax compliance, payment processing, global coverage
- Subscriptions: Recurring billing, trials, seat-based, prorations
- One-Time Payments: Single charges for products/services
- Licensing: Activation keys, device management, validation
- Checkouts: Hosted, embedded, no-code options
- Customer Portal: Self-service billing management
- Revenue Splits: Automatic payment distribution to multiple recipients
When to Choose Creem
- Global SaaS products requiring MoR
- Software licensing with activation management
- Subscription products with seat-based billing
- Digital product sales with file delivery
- Affiliate/commission programs
- Multi-recipient revenue splitting
Authentication
# API Key authentication
curl -H "Authorization: Bearer sk_live_xxx" https://api.creem.io/v1/...Environment variables:
CREEM_API_KEY=sk_live_xxx # Production
CREEM_API_KEY=sk_test_xxx # Test mode
CREEM_WEBHOOK_SECRET=whsec_xxx # Webhook verificationAPI Base URLs
- Production:
https://api.creem.io/v1 - Test Mode: Use
sk_test_prefixed API keys
Rate Limits
Standard API rate limits apply. Check response headers for limit status.
Global Support
- Customers: Hundreds of countries supported
- Merchants: Global payouts
- Languages: 42 languages for checkout localization
Related References
- API Endpoints:
references/creem/api.md - Webhooks:
references/creem/webhooks.md - Checkouts:
references/creem/checkouts.md - Subscriptions:
references/creem/subscriptions.md - Licensing:
references/creem/licensing.md - SDKs:
references/creem/sdk.md
External Resources
- Documentation: https://docs.creem.io
- LLM Docs: https://docs.creem.io/llms.txt
Creem.io SDKs
Official SDKs
Core SDK (creem)
Full API access with maximum flexibility:
npm install creem
# or
pip install creem// Node.js
import Creem from 'creem';
const creem = new Creem({
apiKey: process.env.CREEM_API_KEY
});
// Create checkout
const session = await creem.checkout.sessions.create({
product_id: 'prod_xxx',
success_url: 'https://example.com/success'
});# Python
from creem import Creem
creem = Creem(api_key=os.environ['CREEM_API_KEY'])
session = creem.checkout.sessions.create(
product_id='prod_xxx',
success_url='https://example.com/success'
)Wrapper SDK (creem_io)
Helper functions for common operations:
npm install creem_ioimport { CreemClient, verifyWebhook } from 'creem_io';
const client = new CreemClient({
apiKey: process.env.CREEM_API_KEY,
webhookSecret: process.env.CREEM_WEBHOOK_SECRET
});
// Simplified webhook verification
app.post('/webhook', async (req, res) => {
const event = client.verifyWebhook(req.body, req.headers['x-creem-signature']);
// Handle event...
});
// Access management helpers
const hasAccess = await client.checkAccess(customerId, productId);Framework Adapters
Next.js Adapter
End-to-end billing integration:
npm install @creem/nextjs// app/api/checkout/route.ts
import { createCheckout } from '@creem/nextjs';
export const POST = createCheckout({
productId: 'prod_xxx',
successUrl: '/success',
cancelUrl: '/pricing'
});
// app/api/webhooks/creem/route.ts
import { handleWebhook } from '@creem/nextjs';
export const POST = handleWebhook({
onCheckoutCompleted: async (session) => {
await grantAccess(session.customer_id);
},
onSubscriptionCancelled: async (subscription) => {
await revokeAccess(subscription.customer_id);
}
});Better Auth Integration
Combined auth + payments:
npm install @creem/better-authimport { betterAuth } from 'better-auth';
import { creemPlugin } from '@creem/better-auth';
export const auth = betterAuth({
plugins: [
creemPlugin({
apiKey: process.env.CREEM_API_KEY,
webhookSecret: process.env.CREEM_WEBHOOK_SECRET,
products: {
pro: 'prod_xxx',
enterprise: 'prod_yyy'
}
})
]
});
// Check subscription in auth session
const session = await auth.getSession();
if (session.user.subscription?.status === 'active') {
// User has active subscription
}Next.js Template
Pre-built starter with Prisma, shadcn/ui, Tailwind:
npx create-creem-app my-saas
# or
git clone https://github.com/creem-io/nextjs-templateIncludes:
- Auth (Better Auth)
- Database (Prisma)
- UI (shadcn/ui, Tailwind)
- Pricing page
- Customer portal
- Webhook handling
Environment Variables
# .env
CREEM_API_KEY=sk_live_xxx # or sk_test_xxx for test mode
CREEM_WEBHOOK_SECRET=whsec_xxxAI Tool Integration
Creem supports Claude Code, Cursor, Windsurf via official skill - this document is part of that integration.
Creem.io Subscriptions
Subscription Lifecycle
create → active → [pause] → [resume] → [upgrade] → cancelCreate Subscription
Via checkout session with recurring product:
const session = await creem.checkout.sessions.create({
product_id: 'prod_recurring_xxx',
success_url: 'https://example.com/success',
customer_email: 'user@example.com'
});Retrieve Subscription
// GET /v1/subscriptions/:id
const subscription = await creem.subscriptions.retrieve('sub_xxx');
// Returns: { id, status, product_id, current_period_end, ... }Modify Subscription
Update Seats/Units
// PATCH /v1/subscriptions/:id
const updated = await creem.subscriptions.update('sub_xxx', {
quantity: 10, // Seat count
prorate: true, // Prorate charges
billing_immediately: false
});Upgrade/Downgrade
const updated = await creem.subscriptions.update('sub_xxx', {
product_id: 'prod_higher_tier',
prorate: true
});Pause Subscription
// POST /v1/subscriptions/:id/pause
const paused = await creem.subscriptions.pause('sub_xxx', {
resume_at: '2024-02-01T00:00:00Z' // Optional auto-resume date
});Resume Subscription
// POST /v1/subscriptions/:id/resume
const resumed = await creem.subscriptions.resume('sub_xxx');Cancel Subscription
// POST /v1/subscriptions/:id/cancel
const cancelled = await creem.subscriptions.cancel('sub_xxx', {
at_period_end: true // false = immediate cancellation
});Free Trials
Configure on product level:
const product = await creem.products.create({
name: 'Pro Plan',
price: 2900,
currency: 'usd',
recurring: { interval: 'month' },
trial_period_days: 14
});Seat-Based Billing
const product = await creem.products.create({
name: 'Team Plan',
price: 1000, // Per seat price
currency: 'usd',
recurring: { interval: 'month' },
billing_scheme: 'per_unit'
});
// Checkout with quantity
const session = await creem.checkout.sessions.create({
product_id: 'prod_xxx',
quantity: 5, // 5 seats
success_url: '...'
});Product Bundles
Group related tiers for upsells:
const bundle = await creem.bundles.create({
name: 'Growth Plans',
products: ['prod_starter', 'prod_pro', 'prod_enterprise']
});Subscription Events (Webhooks)
subscription.created- New subscription startedsubscription.updated- Quantity, product, or status changedsubscription.paused- Subscription pausedsubscription.resumed- Subscription resumedsubscription.cancelled- Cancellation scheduled or completedsubscription.renewed- Successful renewal charge
See references/creem/webhooks.md for webhook handling.
Creem.io Webhooks
Webhook Setup
Configure webhook endpoint in Creem dashboard. Receive events at your endpoint URL.
Event Types
Checkout Events
checkout.completed- Payment successful, access grantedcheckout.abandoned- Cart abandoned (triggers recovery emails if enabled)
Subscription Events
subscription.created- New subscription startedsubscription.updated- Changes to quantity, product, statussubscription.paused- Subscription pausedsubscription.resumed- Subscription resumedsubscription.cancelled- Cancellation scheduled/completedsubscription.renewed- Successful renewal charge
Payment Events
payment.succeeded- Charge successfulpayment.failed- Charge failedrefund.created- Refund processedchargeback.created- Dispute opened
License Events
license.activated- Device activated against licenselicense.deactivated- Device deactivated
Webhook Payload Structure
{
"id": "evt_xxx",
"type": "checkout.completed",
"created_at": "2024-01-15T10:30:00Z",
"data": {
"object": {
"id": "cs_xxx",
"customer_id": "cus_xxx",
"product_id": "prod_xxx",
"amount": 2900,
"currency": "usd",
"metadata": { "order_id": "123" }
}
}
}Signature Verification
import crypto from 'crypto';
function verifyWebhook(payload, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
// Express handler
app.post('/webhooks/creem', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-creem-signature'];
const payload = req.body.toString();
if (!verifyWebhook(payload, signature, process.env.CREEM_WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(payload);
switch (event.type) {
case 'checkout.completed':
await handleCheckoutCompleted(event.data.object);
break;
case 'subscription.cancelled':
await handleSubscriptionCancelled(event.data.object);
break;
}
res.status(200).send('OK');
});Idempotency
Store processed event IDs to prevent duplicate processing:
async function handleWebhook(event) {
// Check if already processed
const existing = await db.webhookEvents.findOne({ eventId: event.id });
if (existing) return { status: 'already_processed' };
// Process event
await processEvent(event);
// Mark as processed
await db.webhookEvents.create({
eventId: event.id,
type: event.type,
processedAt: new Date()
});
}Retry Behavior
Creem retries failed webhooks (non-2xx responses). Implement idempotency to handle retries safely.
Testing Webhooks
Use test mode API keys (sk_test_) - events sent to same webhook endpoint with test data.
Implementation Workflows
SePay Implementation
1. Load references/sepay/overview.md for auth setup 2. Load references/sepay/api.md or references/sepay/sdk.md for integration 3. Load references/sepay/webhooks.md for payment notifications 4. Use scripts/sepay-webhook-verify.js for webhook verification 5. Load references/sepay/best-practices.md for production readiness
Polar Implementation
1. Load references/polar/overview.md for auth and concepts 2. Load references/polar/products.md for product setup 3. Load references/polar/checkouts.md for payment flows 4. Load references/polar/webhooks.md for event handling 5. Use scripts/polar-webhook-verify.js for webhook verification 6. Load references/polar/benefits.md if automating delivery 7. Load references/polar/best-practices.md for production readiness
Stripe Implementation
1. Load references/stripe/stripe-best-practices.md for integration design 2. Load references/stripe/stripe-sdks.md for server-side SDK setup 3. Load references/stripe/stripe-js.md for client-side Elements/Checkout 4. Use stripe listen via CLI for local webhook testing (references/stripe/stripe-cli.md) 5. Choose integration: Checkout (hosted/embedded) or Payment Element 6. Use CheckoutSessions API for most payment flows 7. Use Billing APIs for subscriptions (combine with Checkout) 8. Load references/stripe/stripe-upgrade.md when upgrading API versions
Creem.io Implementation
1. Load references/creem/overview.md for auth and MoR concepts 2. Load references/creem/api.md for products and checkout sessions 3. Load references/creem/checkouts.md for payment flow options 4. Load references/creem/webhooks.md for event handling 5. Load references/creem/subscriptions.md if implementing recurring billing 6. Load references/creem/licensing.md if implementing device activation 7. Load references/creem/sdk.md for framework-specific adapters
General Workflow
1. Identify platform (Vietnamese → SePay, global SaaS → Polar/Stripe/Creem.io) 2. Load relevant references progressively 3. Implement: auth → products → checkout → webhooks → events 4. Test in sandbox, then production 5. Load only needed references to maintain context efficiency
Multi-Provider Order Management Patterns
Production patterns for managing orders across multiple payment providers (Polar + SePay), currency handling, commission systems, and revenue tracking.
Order Schema Design
Unified Orders Table
// db/schema/orders.ts
import { pgTable, uuid, text, integer, numeric, timestamp, boolean } from 'drizzle-orm/pg-core';
export const orders = pgTable('orders', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id),
email: text('email').notNull(),
// Product info
productType: text('product_type').notNull(), // 'engineer_kit', 'marketing_kit', 'combo', 'team_*'
quantity: integer('quantity').default(1),
// Pricing (stored in provider's currency)
amount: integer('amount').notNull(), // Final amount after discounts
originalAmount: integer('original_amount'), // Before any discounts
currency: text('currency').default('USD'), // 'USD' or 'VND'
// Status
status: text('status').default('pending'), // pending, completed, failed, refunded
// Provider info
paymentProvider: text('payment_provider').notNull(), // 'polar' or 'sepay'
paymentId: text('payment_id'), // External payment/transaction ID
// Referral tracking
referredBy: uuid('referred_by').references(() => users.id),
discountAmount: integer('discount_amount').default(0),
discountRate: numeric('discount_rate', { precision: 5, scale: 2 }),
// Audit trail (JSON)
metadata: text('metadata'),
// Timestamps
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
});Provider-Specific Metadata
// Polar order metadata
interface PolarOrderMetadata {
originalAmount: number;
couponCode?: string;
couponDiscountAmount?: number;
referralCode?: string;
referralDiscountAmount?: number;
referrerId?: string;
githubUsername: string;
polarDiscountId?: string;
polarDiscountSynced?: boolean;
polarDiscountSyncAction?: 'decremented' | 'deleted' | 'already_deleted';
polarDiscountSyncedAt?: string;
isTeamPurchase?: boolean;
teamId?: string;
}
// SePay order metadata
interface SepayOrderMetadata {
originalAmount: number;
couponCode?: string;
couponDiscountAmount?: number;
couponId?: string; // For Polar discount sync
referralCode?: string;
referralDiscountAmount?: number;
referrerId?: string;
githubUsername: string;
vatInvoiceRequested?: boolean;
encryptedTaxId?: string;
// Added by webhook
gateway?: string;
transactionDate?: string;
transactionId?: number;
transferAmount?: number;
matchMethod?: string;
content?: string;
}Currency Conversion
Multi-Layer Fallback Architecture
// lib/currency.ts
const EXCHANGE_RATE_CACHE_TTL = 60 * 60 * 1000; // 1 hour
const FALLBACK_RATES = {
VND_TO_USD: 24500, // Conservative estimate
USD_TO_VND: 24500,
};
interface ExchangeRateCache {
rates: { VND: number; USD: number };
timestamp: number;
source: 'api' | 'cached' | 'expired' | 'fallback';
}
let rateCache: ExchangeRateCache | null = null;
export async function getExchangeRates(): Promise<ExchangeRateCache> {
const now = Date.now();
// Layer 1: Fresh cache (< 1 hour)
if (rateCache && now - rateCache.timestamp < EXCHANGE_RATE_CACHE_TTL) {
return { ...rateCache, source: 'cached' };
}
// Layer 2: Live API
try {
const response = await fetch(
'https://api.exchangerate-api.com/v4/latest/USD',
{ signal: AbortSignal.timeout(5000) }
);
const data = await response.json();
rateCache = {
rates: { VND: data.rates.VND, USD: 1 },
timestamp: now,
source: 'api',
};
return rateCache;
} catch (error) {
console.warn('Exchange rate API failed:', error);
// Layer 3: Expired cache (better than nothing)
if (rateCache) {
return { ...rateCache, source: 'expired' };
}
// Layer 4: Hardcoded fallback
return {
rates: { VND: FALLBACK_RATES.VND_TO_USD, USD: 1 },
timestamp: now,
source: 'fallback',
};
}
}
export async function convertVndToUsd(vndAmount: number): Promise<{
usdCents: number;
rate: number;
source: string;
}> {
const { rates, source } = await getExchangeRates();
const usdCents = Math.round((vndAmount / rates.VND) * 100);
return { usdCents, rate: rates.VND, source };
}
export async function convertUsdToVnd(usdCents: number): Promise<{
vndAmount: number;
rate: number;
source: string;
}> {
const { rates, source } = await getExchangeRates();
const vndAmount = Math.round((usdCents / 100) * rates.VND);
return { vndAmount, rate: rates.VND, source };
}Normalizing Revenue to USD
// For reporting/dashboard - normalize all revenue to USD cents
export async function normalizeOrderToUsd(order: Order): Promise<{
amountUsdCents: number;
originalAmountUsdCents: number;
conversionSource: string;
}> {
if (order.currency === 'USD') {
return {
amountUsdCents: order.amount,
originalAmountUsdCents: order.originalAmount || order.amount,
conversionSource: 'native',
};
}
// VND order
const conversion = await convertVndToUsd(order.amount);
const originalConversion = order.originalAmount
? await convertVndToUsd(order.originalAmount)
: conversion;
return {
amountUsdCents: conversion.usdCents,
originalAmountUsdCents: originalConversion.usdCents,
conversionSource: conversion.source,
};
}Commission System
Commission Schema
// db/schema/commissions.ts
export const commissions = pgTable('commissions', {
id: uuid('id').primaryKey().defaultRandom(),
orderId: uuid('order_id').references(() => orders.id).notNull(),
referrerId: uuid('referrer_id').references(() => users.id).notNull(),
referredUserId: uuid('referred_user_id').references(() => users.id).notNull(),
referralCodeId: uuid('referral_code_id').references(() => referralCodes.id),
// Amount in original currency
orderAmount: integer('order_amount').notNull(), // Base amount for commission
orderCurrency: text('order_currency').notNull(), // 'USD' or 'VND'
// Commission calculation
commissionRate: numeric('commission_rate', { precision: 5, scale: 4 }).default('0.20'), // 20%
commissionAmount: integer('commission_amount').notNull(),
commissionCurrency: text('commission_currency').notNull(),
// Normalized USD (for tier tracking)
orderAmountUsdCents: integer('order_amount_usd_cents'),
commissionAmountUsdCents: integer('commission_amount_usd_cents'),
exchangeRateSource: text('exchange_rate_source'),
// Status
status: text('status').default('pending'), // pending, approved, paid, cancelled
// Timestamps
createdAt: timestamp('created_at').defaultNow(),
approvedAt: timestamp('approved_at'),
paidAt: timestamp('paid_at'),
cancelledAt: timestamp('cancelled_at'),
});Creating Commission (Multi-Currency)
// lib/commissions.ts
export async function createCommission(params: {
orderId: string;
referrerId: string;
referredUserId: string;
referralCodeId: string;
orderAmount: number;
orderCurrency: 'USD' | 'VND';
commissionRate?: number;
}): Promise<Commission> {
const rate = params.commissionRate || 0.20; // Default 20%
// Calculate commission in original currency
const commissionAmount = Math.round(params.orderAmount * rate);
// Convert to USD for tier tracking
let orderAmountUsdCents: number;
let commissionAmountUsdCents: number;
let exchangeRateSource: string;
if (params.orderCurrency === 'USD') {
orderAmountUsdCents = params.orderAmount;
commissionAmountUsdCents = commissionAmount;
exchangeRateSource = 'native';
} else {
const conversion = await convertVndToUsd(params.orderAmount);
orderAmountUsdCents = conversion.usdCents;
commissionAmountUsdCents = Math.round(conversion.usdCents * rate);
exchangeRateSource = conversion.source;
}
const [commission] = await db.insert(commissions).values({
orderId: params.orderId,
referrerId: params.referrerId,
referredUserId: params.referredUserId,
referralCodeId: params.referralCodeId,
orderAmount: params.orderAmount,
orderCurrency: params.orderCurrency,
commissionRate: String(rate),
commissionAmount,
commissionCurrency: params.orderCurrency,
orderAmountUsdCents,
commissionAmountUsdCents,
exchangeRateSource,
status: 'pending',
}).returning();
// Update referrer's tier based on USD revenue
await updateReferrerTier(params.referrerId, orderAmountUsdCents);
return commission;
}Referrer Tier System
// lib/referrals.ts
const TIER_THRESHOLDS = [
{ tier: 'bronze', minRevenue: 0, commissionRate: 0.20 },
{ tier: 'silver', minRevenue: 50000, commissionRate: 0.25 }, // $500
{ tier: 'gold', minRevenue: 150000, commissionRate: 0.30 }, // $1,500
{ tier: 'platinum', minRevenue: 500000, commissionRate: 0.35 }, // $5,000
];
export async function updateReferrerTier(
referrerId: string,
newRevenueUsdCents: number
): Promise<void> {
const referrer = await db.select()
.from(users)
.where(eq(users.id, referrerId))
.limit(1);
if (!referrer[0]) return;
const currentRevenue = referrer[0].referralRevenueUsdCents || 0;
const totalRevenue = currentRevenue + newRevenueUsdCents;
// Determine new tier
let newTier = 'bronze';
let newRate = 0.20;
for (const threshold of TIER_THRESHOLDS) {
if (totalRevenue >= threshold.minRevenue) {
newTier = threshold.tier;
newRate = threshold.commissionRate;
}
}
// Update if tier changed
if (referrer[0].referralTier !== newTier) {
await db.update(users)
.set({
referralTier: newTier,
referralCommissionRate: String(newRate),
referralRevenueUsdCents: totalRevenue,
updatedAt: new Date(),
})
.where(eq(users.id, referrerId));
// Send tier upgrade notification
if (TIER_THRESHOLDS.findIndex(t => t.tier === newTier) >
TIER_THRESHOLDS.findIndex(t => t.tier === referrer[0].referralTier)) {
await sendTierUpgradeEmail(referrerId, newTier, newRate);
}
} else {
// Just update revenue
await db.update(users)
.set({
referralRevenueUsdCents: totalRevenue,
updatedAt: new Date(),
})
.where(eq(users.id, referrerId));
}
}Revenue Tracking
Combined Provider Revenue
// lib/revenue.ts
export async function getTotalRevenue(options?: {
startDate?: Date;
endDate?: Date;
}): Promise<{
totalUsdCents: number;
byProvider: { polar: number; sepay: number };
orderCount: number;
averageOrderValueCents: number;
}> {
let query = db.select()
.from(orders)
.where(eq(orders.status, 'completed'));
if (options?.startDate) {
query = query.where(gte(orders.createdAt, options.startDate));
}
if (options?.endDate) {
query = query.where(lte(orders.createdAt, options.endDate));
}
const completedOrders = await query;
let totalUsdCents = 0;
let polarUsdCents = 0;
let sepayUsdCents = 0;
for (const order of completedOrders) {
const normalized = await normalizeOrderToUsd(order);
totalUsdCents += normalized.amountUsdCents;
if (order.paymentProvider === 'polar') {
polarUsdCents += normalized.amountUsdCents;
} else {
sepayUsdCents += normalized.amountUsdCents;
}
}
return {
totalUsdCents,
byProvider: { polar: polarUsdCents, sepay: sepayUsdCents },
orderCount: completedOrders.length,
averageOrderValueCents: completedOrders.length > 0
? Math.round(totalUsdCents / completedOrders.length)
: 0,
};
}Maintainer Revenue Calculation
// lib/maintainer-revenue.ts
// Calculate actual payout after fees and costs
interface MaintainerRevenue {
grossRevenue: number; // Total received
platformFees: number; // Polar/Stripe fees
operatingCosts: number; // Proportional costs
taxDeduction: number; // 17% tax
netPayout: number; // Final amount
currency: 'USD';
}
export async function calculateMaintainerRevenue(
productIds: string[],
dateRange: { start: Date; end: Date }
): Promise<MaintainerRevenue> {
// Get orders for these products
const orders = await db.select()
.from(orders)
.where(and(
eq(orders.status, 'completed'),
inArray(orders.productType, productIds),
gte(orders.createdAt, dateRange.start),
lte(orders.createdAt, dateRange.end)
));
let grossRevenue = 0;
let platformFees = 0;
for (const order of orders) {
const normalized = await normalizeOrderToUsd(order);
grossRevenue += normalized.amountUsdCents;
if (order.paymentProvider === 'polar') {
const fees = calculatePolarFees(normalized.amountUsdCents);
platformFees += fees.totalFee;
}
// SePay has no platform fees (direct bank transfer)
}
// Proportional operating costs (hosting, services, etc.)
const monthlyOperatingCosts = 50000; // $500/month in cents
const totalMonthlyRevenue = await getTotalRevenue({
startDate: dateRange.start,
endDate: dateRange.end,
});
const costRatio = grossRevenue / (totalMonthlyRevenue.totalUsdCents || 1);
const operatingCosts = Math.round(monthlyOperatingCosts * costRatio);
// Tax deduction (17%)
const afterCosts = grossRevenue - platformFees - operatingCosts;
const taxDeduction = Math.round(afterCosts * 0.17);
const netPayout = afterCosts - taxDeduction;
return {
grossRevenue,
platformFees,
operatingCosts,
taxDeduction,
netPayout,
currency: 'USD',
};
}Refund Handling
Unified Refund Flow
// lib/refunds.ts
export async function processRefund(
orderId: string,
options: { keepAccess?: boolean; reason?: string }
): Promise<{ success: boolean; error?: string }> {
const order = await db.select()
.from(orders)
.where(eq(orders.id, orderId))
.limit(1);
if (!order[0]) {
return { success: false, error: 'Order not found' };
}
if (order[0].status !== 'completed') {
return { success: false, error: 'Order not refundable' };
}
try {
// 1. Process refund with payment provider
if (order[0].paymentProvider === 'polar') {
await polar.orders.refund({ id: order[0].paymentId! });
} else {
// SePay: Manual bank transfer refund required
// Just mark order, admin handles bank transfer
console.log(`Manual refund needed for SePay order ${orderId}`);
}
// 2. Update order status
await db.update(orders)
.set({
status: 'refunded',
metadata: JSON.stringify({
...JSON.parse(order[0].metadata || '{}'),
refundedAt: new Date().toISOString(),
refundReason: options.reason,
keepAccess: options.keepAccess,
}),
updatedAt: new Date(),
})
.where(eq(orders.id, orderId));
// 3. Cancel commission (if any)
if (order[0].referredBy) {
await db.update(commissions)
.set({
status: 'cancelled',
cancelledAt: new Date(),
})
.where(eq(commissions.orderId, orderId));
// Recalculate referrer tier
await recalculateReferrerTier(order[0].referredBy);
}
// 4. Revoke access (unless keepAccess)
if (!options.keepAccess) {
const metadata = JSON.parse(order[0].metadata || '{}');
if (metadata.githubUsername) {
await revokeGitHubAccess(metadata.githubUsername, order[0].productType);
}
await db.update(licenses)
.set({ isActive: false, revokedAt: new Date() })
.where(eq(licenses.orderId, orderId));
}
return { success: true };
} catch (error) {
console.error('Refund failed:', error);
return { success: false, error: error instanceof Error ? error.message : 'Refund failed' };
}
}Webhook Event Tracking
Unified Webhook Events Table
// db/schema/webhook-events.ts
export const webhookEvents = pgTable('webhook_events', {
id: uuid('id').primaryKey().defaultRandom(),
provider: text('provider').notNull(), // 'polar' or 'sepay'
eventType: text('event_type').notNull(), // Event type/name
eventId: text('event_id').notNull().unique(), // Idempotency key
payload: text('payload').notNull(), // Raw JSON payload
processed: boolean('processed').default(false),
processedAt: timestamp('processed_at'),
error: text('error'), // Error message if failed
createdAt: timestamp('created_at').defaultNow(),
});
// Partial index for unprocessed events
// CREATE INDEX idx_webhook_events_unprocessed ON webhook_events (created_at)
// WHERE processed = false;Idempotent Webhook Processing
// lib/webhooks.ts
export async function processWebhookIdempotently<T>(
provider: 'polar' | 'sepay',
eventId: string,
eventType: string,
payload: string,
handler: () => Promise<T>
): Promise<{ processed: boolean; result?: T; error?: string }> {
// Check for duplicate
const existing = await db.select()
.from(webhookEvents)
.where(eq(webhookEvents.eventId, eventId))
.limit(1);
if (existing.length > 0) {
return { processed: false }; // Already processed
}
// Record event BEFORE processing
await db.insert(webhookEvents).values({
id: crypto.randomUUID(),
provider,
eventType,
eventId,
payload,
processed: false,
});
try {
const result = await handler();
await db.update(webhookEvents)
.set({ processed: true, processedAt: new Date() })
.where(eq(webhookEvents.eventId, eventId));
return { processed: true, result };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
await db.update(webhookEvents)
.set({
processed: true,
processedAt: new Date(),
error: errorMessage,
})
.where(eq(webhookEvents.eventId, eventId));
return { processed: true, error: errorMessage };
}
}Discount Cross-Provider Sync
Syncing SePay Usage to Polar
// lib/polar-discount-sync.ts
// When a Polar discount is used via SePay, decrement Polar's redemption count
export async function syncDiscountRedemptionToPolar(
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' };
}
const metadata = order[0].metadata ? JSON.parse(order[0].metadata) : {};
// Idempotency check
if (metadata.polarDiscountSynced) {
return { success: true, action: 'already_synced' };
}
const polar = getPolar();
try {
const discount = await polar.discounts.get({ id: discountId });
// Skip if unlimited redemptions
if (discount.maxRedemptions === null) {
await markSynced(orderId, 'skipped_unlimited');
return { success: true, action: 'skipped_unlimited' };
}
const currentMax = discount.maxRedemptions;
if (currentMax <= 1) {
// Delete discount if this was last use
await polar.discounts.delete({ id: discountId });
await markSynced(orderId, 'deleted');
return { success: true, action: 'deleted' };
} else {
// Decrement max redemptions
await polar.discounts.update({
id: discountId,
discountUpdate: { maxRedemptions: currentMax - 1 },
});
await markSynced(orderId, 'decremented');
return { success: true, action: 'decremented' };
}
} catch (error: any) {
if (error.statusCode === 404) {
await markSynced(orderId, 'already_deleted');
return { success: true, action: 'already_deleted' };
}
throw error;
}
}
async function markSynced(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) : {};
await db.update(orders)
.set({
metadata: JSON.stringify({
...metadata,
polarDiscountSynced: true,
polarDiscountSyncAction: action,
polarDiscountSyncedAt: new Date().toISOString(),
}),
})
.where(eq(orders.id, orderId));
}
// Retry wrapper with exponential backoff
export async function syncWithRetry(
orderId: string,
discountId: string,
discountCode: string,
attempt: number = 1
): Promise<{ success: boolean; action: string }> {
const MAX_ATTEMPTS = 3;
try {
return await syncDiscountRedemptionToPolar(orderId, discountId, discountCode);
} catch (error) {
if (attempt < MAX_ATTEMPTS) {
const delay = Math.pow(2, attempt) * 1000; // 2s, 4s
await sleep(delay);
return syncWithRetry(orderId, discountId, discountCode, attempt + 1);
}
throw error;
}
}Admin Order Management API
Order Listing with Provider Info
// app/api/admin/orders/route.ts
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '50');
const provider = searchParams.get('provider'); // 'polar' | 'sepay' | null
const status = searchParams.get('status');
let query = db.select()
.from(orders)
.orderBy(desc(orders.createdAt));
if (provider) {
query = query.where(eq(orders.paymentProvider, provider));
}
if (status) {
query = query.where(eq(orders.status, status));
}
const results = await query
.limit(limit)
.offset((page - 1) * limit);
// Normalize amounts to USD for display
const ordersWithNormalized = await Promise.all(
results.map(async (order) => {
const normalized = await normalizeOrderToUsd(order);
return {
...order,
amountUsdCents: normalized.amountUsdCents,
displayAmount: order.currency === 'VND'
? formatVND(order.amount)
: formatUSD(order.amount),
};
})
);
return NextResponse.json({
orders: ordersWithNormalized,
pagination: {
page,
limit,
hasMore: results.length === limit,
},
});
}Best Practices Summary
1. Currency Handling
- Store amounts in original currency (USD or VND)
- Always store currency code with amount
- Use multi-layer fallback for exchange rates
- Convert to USD for reporting/comparison
2. Order Management
- Use unified orders table for both providers
- Store provider-specific data in metadata JSON
- Normalize to USD for tier calculations
3. Commission System
- Store original currency and USD equivalent
- Calculate tier based on USD values
- Handle currency conversion in commission creation
4. Webhook Processing
- Use idempotency keys for deduplication
- Record event before processing
- Always return 200 to prevent retry loops
- Log errors in event record for debugging
5. Cross-Provider Sync
- Sync discount redemptions from SePay to Polar
- Use retry with exponential backoff
- Mark orders as synced to prevent duplicates
6. Refund Handling
- Check order status before processing
- Cancel related commissions
- Recalculate referrer tier after cancellation
- Optionally keep access (goodwill refunds)
Paddle API Reference
Base URL: https://api.paddle.com (prod) | https://sandbox-api.paddle.com (sandbox)
Products
# Create product
POST /products
{
"name": "Pro Plan",
"tax_category": "standard",
"description": "Professional subscription"
}
# List products
GET /products?status=activePrices
# Create price
POST /prices
{
"product_id": "pro_xxx",
"description": "Monthly subscription",
"unit_price": { "amount": "1999", "currency_code": "USD" },
"billing_cycle": { "interval": "month", "frequency": 1 }
}
# One-time price
POST /prices
{
"product_id": "pro_xxx",
"unit_price": { "amount": "4999", "currency_code": "USD" }
}Transactions
# Create transaction (checkout)
POST /transactions
{
"items": [{ "price_id": "pri_xxx", "quantity": 1 }],
"customer_id": "ctm_xxx" # optional
}
# Get transaction
GET /transactions/{txn_id}Customers
# Create customer
POST /customers
{
"email": "user@example.com",
"name": "John Doe"
}
# Get customer portal session
POST /customers/{ctm_id}/portal-sessionsSubscriptions
# Get subscription
GET /subscriptions/{sub_id}
# Update subscription
PATCH /subscriptions/{sub_id}
{
"items": [{ "price_id": "pri_new", "quantity": 1 }],
"proration_billing_mode": "prorated_immediately"
}
# Cancel subscription
POST /subscriptions/{sub_id}/cancel
{
"effective_from": "next_billing_period"
}
# Pause subscription
POST /subscriptions/{sub_id}/pause
{
"effective_from": "next_billing_period"
}Response Format
{
"data": { ... },
"meta": {
"request_id": "xxx",
"pagination": { "per_page": 50, "next": "..." }
}
}Error Handling
{
"error": {
"type": "request_error",
"code": "entity_not_found",
"detail": "Product not found"
}
}Paddle Best Practices
Production patterns for reliable integration.
Webhook Handling
// 1. Verify signature first
// 2. Check idempotency
// 3. Process async
// 4. Return 200 immediately
const processedEvents = new Set(); // Use Redis in production
app.post('/webhooks/paddle', async (req, res) => {
const signature = req.headers['paddle-signature'];
// Verify
const event = paddle.webhooks.unmarshal(
req.rawBody,
process.env.PADDLE_WEBHOOK_SECRET,
signature
);
// Idempotency
if (processedEvents.has(event.eventId)) {
return res.status(200).send('Already processed');
}
// Acknowledge immediately
res.status(200).send('OK');
// Process async
await queue.add('paddle-webhook', event);
});Subscription Status Sync
// Always verify subscription status server-side
async function checkAccess(userId: string): Promise<boolean> {
const user = await db.users.findOne({ id: userId });
if (!user.paddleSubscriptionId) return false;
const sub = await paddle.subscriptions.get(user.paddleSubscriptionId);
return ['active', 'trialing'].includes(sub.status);
}Custom Data for User Linking
// Pass user_id in checkout
paddle.Checkout.open({
items: [{ priceId: 'pri_xxx', quantity: 1 }],
customData: { user_id: currentUser.id }
});
// Retrieve in webhook
app.post('/webhooks/paddle', async (req, res) => {
const event = paddle.webhooks.unmarshal(...);
if (event.eventType === 'subscription.created') {
const userId = event.data.customData?.user_id;
await db.users.update(userId, {
paddleSubscriptionId: event.data.id,
paddleCustomerId: event.data.customerId
});
}
});Error Recovery
// Handle past_due subscriptions
async function handlePastDue(subscriptionId: string) {
// Get customer portal for payment update
const sub = await paddle.subscriptions.get(subscriptionId);
const portal = await paddle.customers.createPortalSession(sub.customerId);
// Email customer with portal link
await sendEmail(sub.customer.email, {
subject: 'Update your payment method',
link: portal.urls.general.overview
});
}Testing with Sandbox
// Use sandbox environment
const paddle = new Paddle(process.env.PADDLE_API_KEY, {
environment: 'sandbox'
});
// Sandbox card: 4242 4242 4242 4242
// Any future expiry, any CVCPrice Localization
// Preview localized prices before checkout
const preview = await paddle.PricePreview({
items: [{ priceId: 'pri_xxx', quantity: 1 }],
address: { countryCode: customerCountry }
});
// Display localized price
const formattedPrice = preview.data.details.totals.total;Paddle Retain (Churn Prevention)
Features enabled in dashboard:
- Payment recovery: Automated dunning emails
- Cancellation surveys: Collect feedback + offer discounts
- Term optimization: Auto-upgrade annual suggestions
Security Checklist
- [ ] Webhook signatures verified
- [ ] API keys in env vars, not code
- [ ] Separate keys for sandbox/production
- [ ] Idempotency implemented
- [ ] Server-side status verification
- [ ] Secure customer portal sessions
Paddle Overview
Paddle Billing = merchant-of-record platform handling payments, tax compliance, localization, subscriptions globally.
Authentication
# API Key in Authorization header
curl -X GET "https://api.paddle.com/products" \
-H "Authorization: Bearer {api_key}"Environment:
- Production:
api.paddle.com - Sandbox:
sandbox-api.paddle.com
Core Concepts
| Concept | Description |
|---|---|
| Paddle ID | Unique identifier for all entities (pro_xxx, pri_xxx, txn_xxx) |
| MoR | Paddle is merchant-of-record, handles tax/compliance |
| Localization | Auto currency/language based on customer location |
SDK Installation
# Node.js
npm install @paddle/paddle-node-sdk
# Python
pip install paddle-python-sdk
# PHP
composer require paddle/paddle-php-sdk
# Go
go get github.com/PaddleHQ/paddle-go-sdkEntity Prefixes
| Entity | Prefix | Example |
|---|---|---|
| Product | pro_ | pro_01gsz4vmqbjk3x4vvtafffd540 |
| Price | pri_ | pri_01gsz8z1q1n00f12qt82y31smh |
| Customer | ctm_ | ctm_01grnn4zta5a1mf02jjze7y2ys |
| Subscription | sub_ | sub_01gv2z5ht1mk2y6bsgv2mjryyn |
| Transaction | txn_ | txn_01gv2z5ht1mk2y6bsgv2mjryyn |
Quick Links
- API Reference: https://developer.paddle.com/api-reference/overview
- Paddle.js:
references/paddle/paddle-js.md - Webhooks:
references/paddle/webhooks.md - Subscriptions:
references/paddle/subscriptions.md - External llms.txt: https://developer.paddle.com/llms.txt
Paddle.js v2
Client-side library for checkout and pricing.
Installation
<!-- CDN -->
<script src="https://cdn.paddle.com/paddle/v2/paddle.js"></script># npm
npm install @paddle/paddle-jsInitialization
import { initializePaddle } from '@paddle/paddle-js';
const paddle = await initializePaddle({
environment: 'sandbox', // 'production'
token: 'live_xxx', // client-side token
eventCallback: (event) => {
if (event.name === 'checkout.completed') {
console.log('Payment successful', event.data);
}
}
});Checkout Methods
Overlay Checkout (Modal)
paddle.Checkout.open({
items: [{ priceId: 'pri_xxx', quantity: 1 }],
customer: { email: 'user@example.com' },
customData: { user_id: '123' },
successUrl: 'https://example.com/success',
});Inline Checkout (Embedded)
<div class="paddle-checkout-container"></div>paddle.Checkout.open({
items: [{ priceId: 'pri_xxx', quantity: 1 }],
settings: {
displayMode: 'inline',
frameTarget: 'paddle-checkout-container',
frameStyle: 'width: 100%; min-width: 312px; background-color: transparent;'
}
});HTML Data Attributes
<a
href="#"
data-paddle-product="pri_xxx"
data-paddle-quantity="1"
data-paddle-email="user@example.com"
>Buy Now</a>Price Preview
const preview = await paddle.PricePreview({
items: [{ priceId: 'pri_xxx', quantity: 1 }],
address: { countryCode: 'US' }
});
console.log(preview.data.details.totals.total); // "19.99"Events
| Event | Description |
|---|---|
checkout.loaded | Checkout frame loaded |
checkout.customer.created | New customer created |
checkout.payment.initiated | Payment processing started |
checkout.completed | Payment successful |
checkout.closed | Checkout closed |
checkout.error | Payment failed |
Update Checkout
// Update items after open
paddle.Checkout.updateItems([
{ priceId: 'pri_xxx', quantity: 2 }
]);
// Close checkout
paddle.Checkout.close();Paddle SDKs
Official SDKs for server-side integration.
Node.js
npm install @paddle/paddle-node-sdkimport Paddle from '@paddle/paddle-node-sdk';
const paddle = new Paddle(process.env.PADDLE_API_KEY, {
environment: 'sandbox' // 'production'
});
// Products
const products = await paddle.products.list();
const product = await paddle.products.create({
name: 'Pro Plan',
taxCategory: 'standard'
});
// Prices
const prices = await paddle.prices.list({ productId: 'pro_xxx' });
const price = await paddle.prices.create({
productId: 'pro_xxx',
description: 'Monthly',
unitPrice: { amount: '999', currencyCode: 'USD' },
billingCycle: { interval: 'month', frequency: 1 }
});
// Transactions
const transaction = await paddle.transactions.create({
items: [{ priceId: 'pri_xxx', quantity: 1 }]
});
// Subscriptions
const subscription = await paddle.subscriptions.get('sub_xxx');
await paddle.subscriptions.update('sub_xxx', {
items: [{ priceId: 'pri_new', quantity: 1 }]
});
await paddle.subscriptions.cancel('sub_xxx', { effectiveFrom: 'nextBillingPeriod' });
// Customers
const customers = await paddle.customers.list({ email: 'user@example.com' });Python
pip install paddle-python-sdkfrom paddle_billing import Client, Environment
paddle = Client(
api_key="your_api_key",
options=Options(environment=Environment.SANDBOX)
)
# Products
products = paddle.products.list()
product = paddle.products.create(
name="Pro Plan",
tax_category="standard"
)
# Subscriptions
subscription = paddle.subscriptions.get("sub_xxx")
paddle.subscriptions.cancel(
"sub_xxx",
effective_from="next_billing_period"
)PHP
composer require paddle/paddle-php-sdkuse Paddle\SDK\Client;
$paddle = new Client('your_api_key');
// Products
$products = $paddle->products->list();
// Subscriptions
$subscription = $paddle->subscriptions->get('sub_xxx');
$paddle->subscriptions->cancel('sub_xxx', [
'effective_from' => 'next_billing_period'
]);Go
go get github.com/PaddleHQ/paddle-go-sdkimport paddle "github.com/PaddleHQ/paddle-go-sdk"
client, _ := paddle.New(
os.Getenv("PADDLE_API_KEY"),
paddle.WithBaseURL(paddle.SandboxBaseURL),
)
// Products
products, _ := client.ListProducts(ctx, nil)
// Subscriptions
sub, _ := client.GetSubscription(ctx, "sub_xxx")Error Handling
try {
await paddle.subscriptions.get('sub_invalid');
} catch (error) {
if (error.code === 'entity_not_found') {
console.log('Subscription not found');
}
}Paddle Subscriptions
Full subscription lifecycle management.
Create Subscription
Via checkout (customer initiates):
paddle.Checkout.open({
items: [{ priceId: 'pri_monthly', quantity: 1 }],
customer: { email: 'user@example.com' }
});Subscription States
| Status | Description |
|---|---|
trialing | In trial period |
active | Actively billed |
past_due | Payment failed, retrying |
paused | Temporarily suspended |
canceled | Terminated |
Upgrade/Downgrade
// API: Update subscription items
PATCH /subscriptions/{sub_id}
{
"items": [{ "price_id": "pri_annual", "quantity": 1 }],
"proration_billing_mode": "prorated_immediately"
}Proration modes:
prorated_immediately- Charge/credit nowprorated_next_billing_period- Apply next cyclefull_immediately- Full new price nowfull_next_billing_period- Full price next cycledo_not_bill- No charge for change
Multi-Item Subscriptions
// Add item to existing subscription
PATCH /subscriptions/{sub_id}
{
"items": [
{ "price_id": "pri_base", "quantity": 1 },
{ "price_id": "pri_addon", "quantity": 5 }
]
}Trials
Set trial on price:
POST /prices
{
"product_id": "pro_xxx",
"unit_price": { "amount": "999", "currency_code": "USD" },
"billing_cycle": { "interval": "month", "frequency": 1 },
"trial_period": { "interval": "day", "frequency": 14 }
}Pause/Resume
// Pause at end of period
POST /subscriptions/{sub_id}/pause
{
"effective_from": "next_billing_period"
}
// Resume immediately
POST /subscriptions/{sub_id}/resume
{
"effective_from": "immediately"
}Cancel
// Cancel at end of period
POST /subscriptions/{sub_id}/cancel
{
"effective_from": "next_billing_period"
}
// Cancel immediately
POST /subscriptions/{sub_id}/cancel
{
"effective_from": "immediately"
}Customer Portal
Self-service subscription management:
// Get portal URL
POST /customers/{ctm_id}/portal-sessions
// Response
{
"data": {
"id": "cps_xxx",
"customer_id": "ctm_xxx",
"urls": {
"general": { "overview": "https://..." }
}
}
}Paddle Webhooks
Event-driven notifications for payment lifecycle.
Setup
1. Dashboard → Developer Tools → Notifications 2. Create new destination with endpoint URL 3. Select events to receive 4. Copy signing secret
Signature Verification
Header: Paddle-Signature Format: ts=1234567890;h1=sha256_signature
Node.js SDK
import Paddle from '@paddle/paddle-node-sdk';
const paddle = new Paddle(process.env.PADDLE_API_KEY);
app.post('/webhooks/paddle', async (req, res) => {
const signature = req.headers['paddle-signature'];
const rawBody = req.body; // raw request body string
try {
const event = paddle.webhooks.unmarshal(
rawBody,
process.env.PADDLE_WEBHOOK_SECRET,
signature
);
await handleEvent(event);
res.status(200).send('OK');
} catch (err) {
res.status(400).send('Invalid signature');
}
});Manual Verification
import crypto from 'crypto';
function verifyPaddleWebhook(
rawBody: string,
signature: string,
secret: string
): boolean {
const [tsPart, h1Part] = signature.split(';');
const ts = tsPart.replace('ts=', '');
const h1 = h1Part.replace('h1=', '');
const signedPayload = `${ts}:${rawBody}`;
const expectedSig = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(h1),
Buffer.from(expectedSig)
);
}Key Events
| Event | Description |
|---|---|
transaction.completed | Payment successful |
transaction.payment_failed | Payment failed |
subscription.created | New subscription |
subscription.updated | Subscription changed |
subscription.canceled | Subscription canceled |
subscription.past_due | Payment overdue |
subscription.paused | Subscription paused |
subscription.resumed | Subscription resumed |
customer.created | New customer |
customer.updated | Customer updated |
Event Payload
{
"event_id": "evt_xxx",
"event_type": "subscription.created",
"occurred_at": "2024-01-15T10:00:00Z",
"notification_id": "ntf_xxx",
"data": {
"id": "sub_xxx",
"status": "active",
"customer_id": "ctm_xxx",
"items": [{ "price": { "id": "pri_xxx" }, "quantity": 1 }],
"billing_cycle": { "interval": "month", "frequency": 1 },
"current_billing_period": {
"starts_at": "2024-01-15",
"ends_at": "2024-02-15"
}
}
}Best Practices
- Store
event_idfor idempotency - Return 200 immediately, process async
- Implement retry handling (Paddle retries failed deliveries)
- Use webhook secret per environment
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:
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):
const validation = await polar.licenses.validate({
key: "MYAPP-XXXX-XXXX-XXXX",
organization_id: "org_xxx"
});
if (validation.valid) {
// Grant access
}Activation/Deactivation:
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:
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:
{
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:
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:
{
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:
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:
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:
const balance = await polar.meters.getBalance({
customer_id: "cust_xxx",
meter_id: "meter_xxx"
});6. Custom Benefits
Flexible placeholder for manual fulfillment.
Create:
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 createdactive- Benefit deliveredrevoked- Access removed
Webhooks
benefit_grant.created- Grant createdbenefit_grant.updated- Status changedbenefit_grant.revoked- Access revoked
Auto-revoke Triggers
- Subscription canceled
- Subscription revoked
- Refund processed
- Product changed (if benefit not on new product)
Querying Grants
const grants = await polar.benefitGrants.list({
customer_id: "cust_xxx",
benefit_id: "benefit_xxx",
is_granted: true
});Attaching Benefits to Products
Via API
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
// Generate customer portal link
const session = await polar.customerSessions.create({
external_customer_id: userId
});
// Customer sees:
// - Active subscriptions
// - Granted benefits
// - Download links
// - License keys
// - InstructionsImplementation Patterns
License Key Validation
// 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
// 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
// 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
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
# 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
// 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
// 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
// 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
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
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)
// 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
// 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
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
// 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
// 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
// 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
// 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
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
// 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)
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
// 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
// __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
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:
const link = await polar.checkoutLinks.create({
product_price_id: "price_xxx",
success_url: "https://example.com/success"
});
// Returns: link.url2. Checkout Sessions (API)
- Programmatically created
- Server-side API call
- Dynamic workflows
- Custom logic
Create Session:
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.urlResponse:
{
"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:
<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):
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 (orproductsarray for multiple)success_url- Post-payment redirect (absolute URL)
Optional
external_customer_id- Your user ID mappingembed_origin- For embedded checkoutscustomer_email- Pre-fill emailcustomer_name- Pre-fill namediscount_id- Pre-apply discount codeallow_discount_codes- Allow customer to enter codes (default: true)metadata- Custom data (key-value)custom_field_data- Pre-fill custom fieldscustomer_billing_address- Pre-fill billing address
Success URL Placeholder
{
success_url: "https://example.com/success?checkout_id={CHECKOUT_ID}"
}
// Polar replaces {CHECKOUT_ID} with actual checkout IDMulti-Product Checkout
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
const session = await polar.checkouts.create({
product_price_id: "price_xxx",
discount_id: "discount_xxx",
success_url: "https://example.com/success"
});Allow Customer Codes
{
allow_discount_codes: true // default
// Set to false to disable code entry
}Checkout States
open- Ready for paymentconfirmed- Payment successfulexpired- Session expired (typically 24 hours)
Events
Webhook Events:
checkout.created- Session createdcheckout.updated- Session updatedorder.created- Order created after successful paymentorder.paid- Payment confirmed
Handle Success:
// 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
// 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
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']);
});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:
Authorization: Bearer polar_xxxxxxxxxxxxxxxxSecurity: 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 callsScopes:
products:read/write- Product managementcheckouts:read/write- Checkout operationsorders:read- View orderssubscriptions:read/write- Subscription managementbenefits:read/write- Benefit configurationcustomers:read/write- Customer managementdiscounts:read/write- Discount codesrefunds: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:
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:
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 productsubscription_create- New subscriptionsubscription_cycle- Renewal invoicesubscription_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
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:
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
await polar.events.create({
external_customer_id: "user_123",
event_name: "api_call",
properties: {
tokens: 1000,
model: "gpt-4"
}
});2. Meters: Filter & aggregate events
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
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
const product = await polar.products.create({
name: "Pro Plan",
metadata: {
feature_x: "enabled",
tier: "pro",
custom_field: "value"
}
});Custom Fields
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
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
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
const products = await polar.products.list({
organization_id: "org_xxx",
is_archived: false
});Update Product
const product = await polar.products.update(productId, {
name: "Pro Plan Updated",
description: "New description"
});Archive Product
await polar.products.archive(productId);
// Products can be unarchived later
// Cannot be deleted (maintains order history)Update Benefits
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
SePay API Reference
Base URL: https://my.sepay.vn/userapi/ Rate Limit: 2 calls/second
Transaction API
List Transactions
GET /userapi/transactions/listParameters:
account_number(string) - Bank account IDtransaction_date_min/max(yyyy-mm-dd) - Date rangesince_id(integer) - Start from IDlimit(integer) - Max 5000 per requestreference_number(string) - Transaction referenceamount_in(number) - Incoming amountamount_out(number) - Outgoing amount
Response:
{
"status": 200,
"transactions": [{
"id": 92704,
"gateway": "Vietcombank",
"transaction_date": "2023-03-25 14:02:37",
"account_number": "0123499999",
"content": "payment content",
"transfer_type": "in",
"transfer_amount": 2277000,
"accumulated": 19077000,
"reference_number": "MBVCB.3278907687",
"bank_account_id": 123
}]
}Transaction Details
GET /userapi/transactions/details/{transaction_id}Count Transactions
GET /userapi/transactions/countBank Account API
List Bank Accounts
GET /userapi/bankaccounts/listParameters:
short_name- Bank identifierlast_transaction_date_min/max- Date rangesince_id- Starting account IDlimit- Results per page (default 100)accumulated_min/max- Balance range
Response:
{
"id": 123,
"account_holder_name": "NGUYEN VAN A",
"account_number": "0123456789",
"accumulated": 50000000,
"last_transaction": "2025-01-13 10:30:00",
"bank_short_name": "VCB",
"active": 1
}Account Details
GET /userapi/bankaccounts/details/{bank_account_id}Count Accounts
GET /userapi/bankaccounts/countOrder-Based Virtual Account API
Concept: Each order gets unique VA with exact amount matching for automated confirmation.
Flow: 1. Create order → API generates unique VA 2. Display VA + QR to customer 3. Customer transfers to VA 4. Bank notifies SePay on success 5. SePay triggers webhook 6. Update order status
Advantages:
- Precision: VA accepts only exact amounts
- Independence: Each order has own VA (no content parsing)
- Security: VAs auto-cancel after success/expiration
- Integration: RESTful API
Supported Banks: BIDV and others (check docs for full list)
Error Handling
HTTP Status Codes:
- 200 OK - Successful
- 201 Created - Resource created
- 400 Bad Request - Invalid parameters
- 401 Unauthorized - Invalid/missing auth
- 403 Forbidden - Insufficient permissions
- 404 Not Found - Resource not found
- 429 Too Many Requests - Rate limit exceeded
- 500 Internal Server Error - Server error
- 503 Service Unavailable - Temporarily unavailable
Rate Limit Response:
{
"status": 429,
"error": "rate_limit_exceeded",
"message": "Too many requests"
}Check x-sepay-userapi-retry-after header for retry timing.
Best Practices
1. Pagination: Use limit and since_id for large datasets 2. Date Ranges: Query specific periods to reduce response size 3. Rate Limiting: Implement exponential backoff 4. Error Handling: Log all errors with context 5. Caching: Cache bank account lists 6. Monitoring: Track API response times and error rates 7. Reconciliation: Regular transaction matching
Stripe.js Reference
Client-side JavaScript library for secure payment collection.
Installation
Include on every page (enables fraud detection):
<script src="https://js.stripe.com/v3/"></script>Or via npm:
npm install @stripe/stripe-jsimport { loadStripe } from '@stripe/stripe-js';
const stripe = await loadStripe('pk_test_...');Initialization
const stripe = Stripe('pk_test_...', {
apiVersion: '2024-12-18.acacia', // Optional
locale: 'auto', // Optional
stripeAccount: 'acct_xxx', // For Connect
});Elements (Payment Forms)
Create container for UI components:
const elements = stripe.elements({
clientSecret: 'pi_xxx_secret_xxx',
appearance: { theme: 'stripe' },
});Payment Element (Recommended)
Auto-renders available payment methods:
const paymentElement = elements.create('payment');
paymentElement.mount('#payment-element');Confirm Payment
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: 'https://example.com/complete',
},
});
if (error) {
// Show error to customer
}Embedded Checkout
Mount Stripe-hosted checkout in your page:
const checkout = await stripe.initEmbeddedCheckout({
clientSecret: 'cs_xxx',
});
checkout.mount('#checkout');Element Types
| Element | Use Case |
|---|---|
payment | Full payment form (recommended) |
card | Card-only input |
address | Shipping/billing address |
linkAuthentication | Link login/signup |
expressCheckout | Apple Pay, Google Pay buttons |
Appearance API
const appearance = {
theme: 'stripe', // 'night', 'flat', 'none'
variables: {
colorPrimary: '#0570de',
colorBackground: '#ffffff',
borderRadius: '4px',
},
rules: {
'.Input': { border: '1px solid #ccc' },
},
};Security
- Always load from
https://js.stripe.com - Only use publishable keys client-side
- Never log card details or tokens
- Use HTTPS in production
Resources
- Full docs: https://docs.stripe.com/js
- Elements: https://docs.stripe.com/payments/elements
- Appearance: https://docs.stripe.com/elements/appearance-api
Related skills
How it compares
Use payment-integration when comparing or wiring multiple payment gateways with shared webhook and subscription patterns, not for a single-provider quickstart alone.
FAQ
Which payment providers does payment-integration cover?
payment-integration version 2.2.0 covers five providers: Stripe for global checkout and Connect, Polar for SaaS MoR subscriptions, Paddle for tax-compliant MoR billing, SePay for Vietnamese VietQR and bank transfers, and Creem.io for licensing.
Does payment-integration include Stripe webhook guidance?
payment-integration includes Stripe webhook verification, idempotency patterns, and local testing via stripe-cli references. It also documents CheckoutSessions, Billing, Payment Element, and server SDK integration paths in bundled reference docs.