Payments Recipe ‐ Polar.sh - spinningideas/resources GitHub Wiki
- Initial Setup
- Backend Implementation
- Frontend Implementation
- Webhook Integration
- Testing and Deployment
- Create a Polar.sh account at polar.sh
- Verify your account and link your bank account
- Navigate to API settings to generate your API keys
- Generate a
POLAR_ACCESS_TOKEN
with appropriate permissions - Store this token securely in your environment variables
- In your Polar dashboard, navigate to Products
- Create monthly subscription product:
- Set name, description, and pricing
- Configure billing cycle (monthly)
- Set up any trial periods if needed
- Create annual subscription product:
- Set name, description, and pricing
- Configure billing cycle (annual)
- Consider adding a discount compared to monthly plan
- Note the
product_id
values for each product
- In Polar dashboard, go to Developer → Webhooks
- Generate a
POLAR_WEBHOOK_SECRET
for signature verification - Configure webhook endpoint URL (your backend endpoint)
- Select events to listen for:
order.created
subscription.created
subscription.updated
subscription.canceled
- Save webhook configuration
Create or update your .env
file with:
POLAR_ACCESS_TOKEN=your_polar_access_token
POLAR_WEBHOOK_SECRET=your_webhook_secret
See related implementations:
- https://github.com/polarsource/polar-supabase-starter/blob/main/supabase/migrations/20230530034630_init.sql
- https://github.com/michaelshimeles/react-starter-kit/blob/main/convex/schema.ts
-- Store user subscription information
CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id),
polar_id TEXT NOT NULL,
polar_price_id TEXT,
currency TEXT,
interval TEXT,
status TEXT NOT NULL,
current_period_start TIMESTAMP WITH TIME ZONE,
current_period_end TIMESTAMP WITH TIME ZONE,
cancel_at_period_end BOOLEAN DEFAULT FALSE,
amount INTEGER,
started_at TIMESTAMP WITH TIME ZONE,
ended_at TIMESTAMP WITH TIME ZONE,
canceled_at TIMESTAMP WITH TIME ZONE,
customer_id TEXT,
metadata JSONB DEFAULT '{}'::JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
-- For webhook event auditing
CREATE TABLE webhook_events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
type TEXT NOT NULL,
polar_event_id TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
modified_at TIMESTAMP WITH TIME ZONE NOT NULL,
data JSONB NOT NULL,
processed_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
-
subscriptions - Stores all subscription data from Polar
-
polar_id
: The unique ID from Polar for this subscription -
user_id
: Reference to your user -
status
: Current status (active, canceled, etc.) -
current_period_start/end
: Current billing period -
cancel_at_period_end
: Whether subscription will cancel at period end
-
-
webhook_events - Audit log for all webhook events
- Helps with debugging and tracking webhook history
- Prevents duplicate processing of events
- Useful for reconciliation and troubleshooting
Create an API endpoint for generating checkout links:
// Example using Express
import express from 'express';
import fetch from 'node-fetch';
const router = express.Router();
router.post('/create-checkout', async (req, res) => {
try {
const { priceId, userId } = req.body;
if (!priceId || !userId) {
return res.status(400).json({ error: 'Price ID and User ID are required' });
}
// Verify user exists in your database
// Create checkout link with Polar API
const polarApiUrl = 'https://api.polar.sh/api/checkout/v1/links';
const polarAccessToken = process.env.POLAR_ACCESS_TOKEN;
if (!polarAccessToken) {
throw new Error('POLAR_ACCESS_TOKEN is not set');
}
const response = await fetch(polarApiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${polarAccessToken}`
},
body: JSON.stringify({
price_id: priceId,
success_url: `${req.headers.origin}/subscription/success`,
cancel_url: `${req.headers.origin}/subscription/cancel`,
metadata: {
userId: userId
}
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(`Polar API error: ${JSON.stringify(errorData)}`);
}
const checkoutData = await response.json();
return res.json({ url: checkoutData.url });
} catch (error) {
return res.status(500).json({ error: error.message });
}
});
export default router;
Create a webhook handler to process Polar events:
// Example using Express
import express from 'express';
import { Webhook } from 'standardwebhooks';
const router = express.Router();
router.post('/polar-webhooks', async (req, res) => {
try {
const secret = process.env.POLAR_WEBHOOK_SECRET;
if (!secret) throw new Error('POLAR_WEBHOOK_SECRET is not set');
const wh = new Webhook(secret);
const rawBody = req.rawBody; // You'll need body-parser middleware configured to preserve raw body
const signature = req.headers['webhook-signature'];
if (!signature) {
return res.status(400).json({ error: 'Missing webhook signature' });
}
const payload = wh.verify(rawBody, { 'webhook-signature': signature });
await handleWebhookEvent(payload);
return res.json({ received: true });
} catch (error) {
console.error('Webhook processing failed:', error.message);
return res.status(400).json({ error: error.message });
}
});
async function handleWebhookEvent(body) {
const eventType = body.type;
const subscriptionData = body.data;
// Store webhook event for auditing
await storeWebhookEvent({
type: eventType,
polar_event_id: body.data.id,
created_at: body.data.created_at,
modified_at: body.data.modified_at || body.data.created_at,
data: body.data,
});
// Extract user ID from metadata or customer info
let userId;
if (subscriptionData.metadata?.userId) {
userId = subscriptionData.metadata.userId;
} else if (subscriptionData.customer) {
// Find user by customer ID in your database
userId = await getUserIdByCustomerId(subscriptionData.customer.id);
}
if (!userId) {
console.error('Could not determine user ID from webhook data');
return;
}
switch (eventType) {
case 'subscription.created': {
// Store subscription details
await createSubscription({
user_id: userId,
polar_id: subscriptionData.id,
status: subscriptionData.status,
// ... other subscription fields
});
// Update user's premium status
await updateUserPremiumStatus(userId, true);
break;
}
case 'subscription.updated': {
// Update subscription details
await updateSubscription(subscriptionData.id, {
status: subscriptionData.status,
// ... other subscription fields
});
break;
}
case 'subscription.canceled': {
// Update subscription as canceled
await updateSubscription(subscriptionData.id, {
status: 'canceled',
canceled_at: new Date().toISOString(),
ended_at: subscriptionData.ended_at || subscriptionData.current_period_end
});
// Check if user has any active subscriptions left
const hasActiveSubscription = await checkUserHasActiveSubscription(userId);
if (!hasActiveSubscription) {
// No active subscriptions left, remove premium status
await updateUserPremiumStatus(userId, false);
}
break;
}
case 'order.created': {
// Handle one-time purchases if needed
console.log('Order created:', subscriptionData.id);
break;
}
default:
console.log(`Unhandled event type: ${eventType}`);
}
}
export default router;
// SubscriptionPlans.jsx
import React from 'react';
import { useNavigate } from 'react-router-dom';
// Replace with your actual Polar price IDs
const SUBSCRIPTION_PLANS = [
{
name: 'Monthly',
description: 'Access to all premium features',
price: '$9.99',
interval: 'month',
priceId: 'price_monthly_id_here',
features: ['Feature 1', 'Feature 2', 'Feature 3']
},
{
name: 'Annual',
description: 'Save 20% with yearly billing',
price: '$95.88',
interval: 'year',
priceId: 'price_annual_id_here',
features: ['Feature 1', 'Feature 2', 'Feature 3', 'Priority support']
}
];
export function SubscriptionPlans() {
const navigate = useNavigate();
const handleSubscribe = async (priceId) => {
try {
// Get current user
const user = await getCurrentUser();
if (!user) {
alert("Please sign in to subscribe");
navigate('/login');
return;
}
// Call your backend to create checkout session
const response = await fetch('/api/create-checkout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
priceId,
userId: user.id
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to create checkout session');
}
const { url } = await response.json();
// Redirect to Polar checkout
window.location.href = url;
} catch (error) {
console.error('Error creating checkout session:', error);
alert('Failed to create checkout session. Please try again.');
}
};
return (
<div className="subscription-plans">
<h2>Choose a Subscription Plan</h2>
<div className="plans-container">
{SUBSCRIPTION_PLANS.map((plan) => (
<div key={plan.priceId} className="plan-card">
<h3>{plan.name}</h3>
<p>{plan.description}</p>
<div className="price">
<span className="amount">{plan.price}</span>
<span className="interval">/{plan.interval}</span>
</div>
<ul className="features">
{plan.features.map((feature, index) => (
<li key={index}>{feature}</li>
))}
</ul>
<button
onClick={() => handleSubscribe(plan.priceId)}
className="subscribe-button"
>
Subscribe
</button>
</div>
))}
</div>
</div>
);
}
// SubscriptionManagement.jsx
import React, { useState, useEffect } from 'react';
export function SubscriptionManagement() {
const [subscription, setSubscription] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function fetchSubscription() {
try {
const user = await getCurrentUser();
if (!user) {
setError('User not authenticated');
setLoading(false);
return;
}
const response = await fetch(`/api/subscriptions/${user.id}`);
if (!response.ok) {
throw new Error('Failed to fetch subscription');
}
const data = await response.json();
setSubscription(data.subscription);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
fetchSubscription();
}, []);
const handleCancelSubscription = async () => {
if (!window.confirm('Are you sure you want to cancel your subscription?')) {
return;
}
try {
const response = await fetch(`/api/subscriptions/${subscription.id}/cancel`, {
method: 'POST',
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to cancel subscription');
}
const updatedSubscription = await response.json();
setSubscription(updatedSubscription.subscription);
alert('Your subscription has been canceled and will end at the end of the current billing period.');
} catch (error) {
console.error('Error canceling subscription:', error);
alert('Failed to cancel subscription. Please try again.');
}
};
if (loading) return <div>Loading subscription details...</div>;
if (error) return <div>Error: {error}</div>;
if (!subscription) return <div>No active subscription found.</div>;
return (
<div className="subscription-management">
<h2>Subscription Details</h2>
<div className="subscription-info">
<p><strong>Status:</strong> {subscription.status}</p>
<p><strong>Plan:</strong> {subscription.plan_name}</p>
<p><strong>Amount:</strong> {formatCurrency(subscription.amount, subscription.currency)}</p>
<p><strong>Billing Period:</strong> {formatDate(subscription.current_period_start)} to {formatDate(subscription.current_period_end)}</p>
{subscription.cancel_at_period_end ? (
<p className="cancellation-notice">
Your subscription will end on {formatDate(subscription.current_period_end)}
</p>
) : (
<button
onClick={handleCancelSubscription}
className="cancel-button"
>
Cancel Subscription
</button>
)}
</div>
</div>
);
}
function formatDate(dateString) {
return new Date(dateString).toLocaleDateString();
}
function formatCurrency(amount, currency) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency || 'USD',
}).format(amount / 100);
}
Polar uses the standardwebhooks
library for webhook signature verification:
import { Webhook } from 'standardwebhooks';
// Verify the webhook signature
const wh = new Webhook(process.env.POLAR_WEBHOOK_SECRET);
const isValid = wh.verify(rawBody, headers);
-
subscription.created
: When a user successfully subscribes- Store subscription details
- Update user's premium status to true
- Send welcome email (optional)
-
subscription.updated
: When subscription details change- Update stored subscription information
- Handle plan changes if applicable
-
subscription.canceled
: When a subscription is canceled- Mark subscription as canceled
- If canceled immediately, update user's premium status
- If canceled at period end, maintain access until period end
-
order.created
: For one-time purchases- Process order details
- Grant appropriate access or benefits
- Implement idempotency to handle duplicate webhook events
- Store webhook event IDs to prevent duplicate processing
- Add logging for all webhook events for debugging
- Implement error handling and retry logic for failed webhook processing
- Use ngrok or similar tool to expose your local webhook endpoint
- Configure Polar webhook URL to your ngrok URL
- Test the full subscription flow locally
- Use Polar's sandbox environment for testing
- Test subscription creation, updates, and cancellations
- Verify webhook events are properly received and processed
- Deploy your backend to production environment
- Update webhook URL in Polar dashboard to production URL
- Test the full flow in production with a test account
- Monitor webhook events and error logs
- Set up monitoring for webhook failures
- Create alerts for failed subscription events
- Implement a dashboard to track subscription metrics
- Regularly audit webhook events against subscription records
Polar.sh Docs
See also:
- https://www.reddit.com/r/SaaS/comments/1kxz7ky/solo_founder_struggling_with_polarsh_subscription/
- https://www.reddit.com/r/SaaS/comments/1kc2fq5/i_tried_polar_as_payments_system_and_this_is_what/
Polar.sh Alternatives