Payments Recipe ‐ Polar.sh - spinningideas/resources GitHub Wiki

Integrating Polar.sh Payments with React

Table of Contents

Initial Setup

1. Polar.sh Account Setup

  1. Create a Polar.sh account at polar.sh
  2. Verify your account and link your bank account
  3. Navigate to API settings to generate your API keys
  4. Generate a POLAR_ACCESS_TOKEN with appropriate permissions
  5. Store this token securely in your environment variables

2. Create Subscription Products

  1. In your Polar dashboard, navigate to Products
  2. Create monthly subscription product:
    • Set name, description, and pricing
    • Configure billing cycle (monthly)
    • Set up any trial periods if needed
  3. Create annual subscription product:
    • Set name, description, and pricing
    • Configure billing cycle (annual)
    • Consider adding a discount compared to monthly plan
  4. Note the product_id values for each product

3. Configure Webhook Settings

  1. In Polar dashboard, go to Developer → Webhooks
  2. Generate a POLAR_WEBHOOK_SECRET for signature verification
  3. Configure webhook endpoint URL (your backend endpoint)
  4. Select events to listen for:
    • order.created
    • subscription.created
    • subscription.updated
    • subscription.canceled
  5. Save webhook configuration

Backend Implementation

1. Set Up Environment Variables

Create or update your .env file with:

POLAR_ACCESS_TOKEN=your_polar_access_token
POLAR_WEBHOOK_SECRET=your_webhook_secret

2. Create Database Schema

See related implementations:

Required Database Tables

-- 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()
);

Table Descriptions

  1. 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
  2. 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

3. Create Checkout Session Endpoint

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;

4. Implement Webhook Handler

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;

Frontend Implementation

1. Create Subscription Components

// 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>
  );
}

2. Create Subscription Management Component

// 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);
}

Webhook Integration

1. Webhook Verification

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);

2. Handling Different Event Types

  • 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

3. Error Handling and Retries

  • 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

Testing and Deployment

1. Local Testing

  1. Use ngrok or similar tool to expose your local webhook endpoint
  2. Configure Polar webhook URL to your ngrok URL
  3. Test the full subscription flow locally

2. Sandbox Testing

  1. Use Polar's sandbox environment for testing
  2. Test subscription creation, updates, and cancellations
  3. Verify webhook events are properly received and processed

3. Production Deployment

  1. Deploy your backend to production environment
  2. Update webhook URL in Polar dashboard to production URL
  3. Test the full flow in production with a test account
  4. Monitor webhook events and error logs

4. Monitoring and Maintenance

  1. Set up monitoring for webhook failures
  2. Create alerts for failed subscription events
  3. Implement a dashboard to track subscription metrics
  4. Regularly audit webhook events against subscription records

Additional Resources


Polar.sh Docs


See also:


Polar.sh Alternatives

⚠️ **GitHub.com Fallback** ⚠️