Polar

Connect Polar's developer-first monetization platform with Strapi v5 to manage subscriptions, process payments, and gate premium content. This guide covers SDK setup, webhook handling, and a complete paywall project example

Polar  logo

What Is Polar?

Polar is a monetization and payments platform built for developers. It functions as a Merchant of Record, which means Polar takes on the legal responsibility for processing payments, handling global tax compliance, and managing billing infrastructure on your behalf.

The platform supports three payment models: recurring subscriptions, usage-based billing, and one-time payments. It also includes a benefits delivery system for managing digital entitlements tied to those payments.

For developers, the key draw is the API-first architecture. Polar exposes endpoints (and a TypeScript SDK) for managing subscriptions and configuring webhooks, and provides an official Python SDK; however, its documentation does not confirm dedicated checkout-session or customer-data endpoints, nor official SDKs for PHP or Go.

The TypeScript SDK (@polar-sh/sdk) is particularly relevant for Strapi integrations, offering full type safety and tree-shakeable module imports.

Why Integrate Polar with Strapi

Pairing Polar's billing infrastructure with Strapi's content management capabilities creates a stack where payment logic and content delivery share a single backend.

  • Subscription-gated content without custom billing code. Polar manages the entire subscription lifecycle—creation, renewals, cancellations—while Strapi controls which content is accessible at each tier.
  • Real-time sync through webhooks. Polar's webhook events notify your Strapi instance the moment a subscription changes status, so content access stays current without polling.
  • Global tax compliance handled for you. As a Merchant of Record, Polar manages VAT, sales tax, and regional compliance, removing a significant operational burden from your team.
  • Strapi v5's Document Service API simplifies data management. The Document Service gives you a clean interface for creating and updating subscription records directly from webhook handlers.
  • Flexible monetization models. Whether you need recurring subscriptions for a membership site, one-time purchases for premium guides, or usage-based billing for API access, the same integration pattern applies.
  • Developer-friendly SDK on both sides. Polar's TypeScript SDK and Strapi's factory functions (createCoreController, createCoreService) reduce boilerplate and provide type safety across the integration.

How to Integrate Polar with Strapi

Prerequisites

Before starting, confirm you have the following:

  • Node.js 18+ (LTS recommended)
  • Strapi v5 project initialized (npx create-strapi@latest)
  • Polar account with an organization created at polar.sh
  • Polar Organization Access Token generated from your Polar dashboard
  • At least one Polar product configured with pricing (subscription or one-time)
  • A tool for testing webhooks locally — Polar provides a CLI for this, or you can use a tunneling service

You should be comfortable with Strapi's custom controller and service patterns. If those are new territory, the Strapi docs cover both thoroughly.

Step 1: Install the Polar SDK

From your Strapi project root, add the Polar TypeScript SDK:

npm install @polar-sh/sdk

This installs @polar-sh/sdk, which provides typed methods for checkout sessions, subscriptions, customers, and webhook verification.

Step 2: Configure Environment Variables

Add your Polar credentials to the .env file at your Strapi project root. Never hardcode these values in source files.

# .env
POLAR_ACCESS_TOKEN=your_organization_access_token_here
POLAR_WEBHOOK_SECRET=your_webhook_secret_here
POLAR_ENVIRONMENT=production

Use Polar's sandbox environment by switching the API base URL to https://sandbox-api.polar.sh/v1 or by passing server: 'sandbox' to the official SDKs during development.

Strapi's built-in env() helper provides type casting and fallback values when you reference these variables in configuration files:

// Accessing env vars in Strapi config files
env('POLAR_ACCESS_TOKEN')
env('POLAR_ENVIRONMENT', 'sandbox')

Keep your .env file out of version control. Strapi's environment configuration docs cover additional security practices for production deployments.

Step 3: Create a Polar Service in Strapi

Encapsulate all Polar SDK interactions in a dedicated Strapi service. This keeps your controllers clean and makes the Polar client reusable across multiple endpoints.

Create the file ./src/api/polar/services/polar.js:

// ./src/api/polar/services/polar.js
const { Polar } = require('@polar-sh/sdk');

module.exports = ({ strapi }) => {
  const polar = new Polar({
    accessToken: process.env.POLAR_ACCESS_TOKEN,
    server: process.env.POLAR_ENVIRONMENT || 'sandbox',
  });

  return {
    getClient() {
      return polar;
    },

    async createCheckoutSession({ productId, customerEmail, successUrl }) {
      const result = await polar.checkouts.create({
        productId,
        customerEmail,
        successUrl,
      });
      return result;
    },

    async getSubscription(subscriptionId) {
      const subscription = await polar.subscriptions.get(subscriptionId);
      return subscription;
    },

    async listSubscriptions(filters = {}) {
      const subscriptions = await polar.subscriptions.list(filters);
      return subscriptions;
    },

    validateWebhookEvent(payload, signature) {
      return polar.webhooks.validateEvent(
        payload,
        signature,
        process.env.POLAR_WEBHOOK_SECRET
      );
    },
  };
};

This service initializes the Polar client once and exposes methods for creating checkout sessions, querying subscriptions, and validating incoming webhooks.

Step 4: Create a Subscription Content-Type

You need a place in Strapi to store subscription records that map Polar subscription data to your users. Use the Strapi CLI or the Admin Panel to create a new Collection Type called Subscription with these fields:

FieldTypeDescription
polarSubscriptionIdText (unique)The subscription ID from Polar
polarCustomerIdTextThe customer ID from Polar
customerEmailEmailSubscriber's email address
statusEnumerationValues such as active, canceled, past_due, trialing, and others as documented by Polar
productIdTextThe Polar product this subscription is for

After creating the content-type through the Admin Panel, Strapi generates the API files automatically. The Document Service API will handle all CRUD operations on this collection using documentId — the v5 identifier that replaces numeric id.

Step 5: Build the Webhook Handler

This is where the integration comes together. Polar sends webhook events when subscriptions change state. Your Strapi instance needs a custom endpoint to receive and process those events.

Create the route file at ./src/api/polar/routes/polar.js:

// ./src/api/polar/routes/polar.js
module.exports = {
  routes: [
    {
      method: 'POST',
      path: '/polar/webhook',
      handler: 'polar.handleWebhook',
      config: {
        auth: false,
      },
    },
    {
      method: 'POST',
      path: '/polar/checkout',
      handler: 'polar.createCheckout',
      config: {
        auth: false,
      },
    },
  ],
};

The auth: false setting on the webhook route is intentional—Polar can't authenticate with Strapi's API tokens. Signature verification (next step) handles security instead.

Now create the controller at ./src/api/polar/controllers/polar.js:

// ./src/api/polar/controllers/polar.js
module.exports = ({ strapi }) => ({
  async handleWebhook(ctx) {
    const signature = ctx.request.headers['webhook-signature'];
    const polarService = strapi.service('api::polar.polar');

    if (!signature) {
      ctx.status = 400;
      ctx.body = { error: 'Missing webhook-signature header' };
      return;
    }

    let event;
    try {
      event = validateEvent(
        ctx.request.body,
        ctx.request.headers,
        process.env['POLAR_WEBHOOK_SECRET'] ?? ''
      );
    } catch (error) {
      strapi.log.error('Polar webhook signature verification failed:', error);
      ctx.status = 401;
      ctx.body = { error: 'Invalid signature' };
      return;
    }

    // Respond immediately — Polar expects a fast acknowledgment
    ctx.status = 200;
    ctx.body = { received: true };

    // Process the event asynchronously
    setImmediate(async () => {
      try {
        await processWebhookEvent(strapi, event);
      } catch (error) {
        strapi.log.error('Webhook processing error:', error);
      }
    });
  },

  async createCheckout(ctx) {
    const { productId, customerEmail, successUrl } = ctx.request.body;
    const polarService = strapi.service('api::polar.polar');

    try {
      const session = await polarService.createCheckoutSession({
        productId,
        customerEmail,
        successUrl,
      });

      ctx.body = { checkoutUrl: session.url };
    } catch (error) {
      strapi.log.error('Checkout creation failed:', error);
      ctx.status = 500;
      ctx.body = { error: 'Failed to create checkout session' };
    }
  },
});

async function processWebhookEvent(strapi, event) {
  const { type, data } = event;

  switch (type) {
    case 'subscription.active':
      await handleSubscriptionActive(strapi, data);
      break;
    case 'subscription.canceled':
      await handleSubscriptionCanceled(strapi, data);
      break;
    case 'subscription.past_due':
      await handleSubscriptionPastDue(strapi, data);
      break;
    case 'subscription.revoked':
      await handleSubscriptionRevoked(strapi, data);
      break;
    case 'order.paid':
      strapi.log.info(`Order paid: ${data.order_id}`);
      break;
    default:
      strapi.log.info(`Unhandled Polar event: ${type}`);
  }
}

async function handleSubscriptionActive(strapi, data) {
  const existing = await strapi
    .documents('api::subscription.subscription')
    .findMany({
      filters: { polarSubscriptionId: data.subscription_id },
    });

  if (existing.length > 0) {
    await strapi.documents('api::subscription.subscription').update({
      documentId: existing[0].documentId,
      data: { status: 'active' },
    });
    return;
  }

  await strapi.documents('api::subscription.subscription').create({
    data: {
      polarSubscriptionId: data.subscription_id,
      polarCustomerId: data.customer_id,
      customerEmail: data.customer_email,
      status: 'active',
      productId: data.product_id,
    },
  });
}

async function handleSubscriptionCanceled(strapi, data) {
  const existing = await strapi
    .documents('api::subscription.subscription')
    .findMany({
      filters: { polarSubscriptionId: data.subscription_id },
    });

  if (existing.length > 0) {
    await strapi.documents('api::subscription.subscription').update({
      documentId: existing[0].documentId,
      data: { status: 'canceled' },
    });
  }
}

async function handleSubscriptionPastDue(strapi, data) {
  const existing = await strapi
    .documents('api::subscription.subscription')
    .findMany({
      filters: { polarSubscriptionId: data.subscription_id },
    });

  if (existing.length > 0) {
    await strapi.documents('api::subscription.subscription').update({
      documentId: existing[0].documentId,
      data: { status: 'past_due' },
    });
  }
}

async function handleSubscriptionRevoked(strapi, data) {
  const existing = await strapi
    .documents('api::subscription.subscription')
    .findMany({
      filters: { polarSubscriptionId: data.subscription_id },
    });

  if (existing.length > 0) {
    await strapi.documents('api::subscription.subscription').update({
      documentId: existing[0].documentId,
      data: { status: 'revoked' },
    });
  }
}

A few things worth noting here. The controller responds with a fast 2xx (often 202) immediately before processing—this quick acknowledgment is critical for reliable delivery. Polar retries webhooks up to 10 times with exponential backoff, and slow responses trigger unnecessary retries. The findMany + check pattern provides basic idempotency: if a subscription.active event arrives twice, the second call updates instead of creating a duplicate.

Step 6: Configure Polar Webhook Endpoint

With your Strapi server running, register the webhook URL in your Polar dashboard or programmatically through the Webhooks API.

Your endpoint URL follows the pattern:

https://your-strapi-domain.com/api/polar/webhook

Subscribe to these events at minimum:

  • subscription.active
  • subscription.canceled
  • subscription.past_due
  • subscription.revoked
  • order.paid

For local development, Polar provides a CLI tool that relays webhooks to your local server:

npm install -g @polar-sh/cli
polar webhooks relay --port 3000

Step 7: Test the Integration

Start your Strapi dev server:

npm run develop

Test the checkout endpoint by sending a POST request:

curl -X POST http://localhost:1337/api/polar/checkout \
  -H "Content-Type: application/json" \
  -d '{
    "productId": "your_polar_product_id",
    "customerEmail": "test@example.com",
    "successUrl": "http://localhost:3000/success"
  }'

This should return a JSON response with a checkoutUrl. Open that URL in a browser to complete a test purchase (use Polar's sandbox environment for this). After completing checkout, the webhook fires a subscription.active event, and your Strapi Subscription collection should show a new record.

Verify the record through the REST API:

curl http://localhost:1337/api/subscriptions

Project Example: Subscription-Gated Article Platform

This example builds a working content paywall where free articles are publicly accessible and premium articles require an active Polar subscription. Strapi manages the content and subscription records; Polar handles billing.

Content-Type Setup

Create an Article Collection Type in Strapi with these fields:

FieldTypeDescription
titleTextArticle headline
slugUID (linked to title)URL-friendly identifier
contentRich TextFull article body
excerptText (long)Preview shown to non-subscribers
accessTierEnumerationfree or premium

You already have the Subscription collection from Step 4. These two collections are the foundation—Articles hold the content, Subscriptions track who has paid.

Custom Service for Access Verification

Create a service that checks subscription status before serving premium content. This lives at ./src/api/article/services/article.js:

// ./src/api/article/services/article.js
const { createCoreService } = require('@strapi/strapi').factories;

module.exports = createCoreService('api::article.article', ({ strapi }) => ({
  async findOneWithAccess({ documentId, customerEmail }) {
    const article = await strapi
      .documents('api::article.article')
      .findOne({ documentId });

    if (!article) {
      return null;
    }

    // Free articles are always fully accessible
    if (article.accessTier === 'free') {
      return { ...article, hasAccess: true };
    }

    // For premium articles, check subscription status
    if (!customerEmail) {
      return {
        title: article.title,
        excerpt: article.excerpt,
        accessTier: article.accessTier,
        hasAccess: false,
      };
    }

    const activeSubscriptions = await strapi
      .documents('api::subscription.subscription')
      .findMany({
        filters: {
          customerEmail,
          status: 'active',
        },
      });

    if (activeSubscriptions.length > 0) {
      return { ...article, hasAccess: true };
    }

    // Subscriber exists but doesn't have active subscription
    return {
      title: article.title,
      excerpt: article.excerpt,
      accessTier: article.accessTier,
      hasAccess: false,
    };
  },

  async findPublicList(params = {}) {
    const articles = await strapi
      .documents('api::article.article')
      .findMany({
        fields: ['title', 'slug', 'excerpt', 'accessTier'],
        sort: 'createdAt:desc',
        ...params,
      });

    return articles;
  },
}));

The findOneWithAccess method is the gating logic. Premium articles return only the title and excerpt unless the requesting email maps to an active subscription.

Custom Controller for Gated Access

Override the default findOne behavior and add a public listing endpoint. Create ./src/api/article/controllers/article.js:

// ./src/api/article/controllers/article.js
const { createCoreController } = require('@strapi/strapi').factories;

module.exports = createCoreController('api::article.article', ({ strapi }) => ({
  async findOne(ctx) {
    const { id: documentId } = ctx.params;
    const customerEmail = ctx.query.email || null;

    const article = await strapi
      .service('api::article.article')
      .findOneWithAccess({ documentId, customerEmail });

    if (!article) {
      return ctx.notFound('Article not found');
    }

    ctx.body = { data: article };
  },

  async find(ctx) {
    const articles = await strapi
      .service('api::article.article')
      .findPublicList({
        pagination: {
          page: ctx.query.page || 1,
          pageSize: ctx.query.pageSize || 10,
        },
      });

    ctx.body = { data: articles };
  },
}));

Custom Routes

Configure the core router to expose these endpoints publicly. In Strapi v4, ./src/api/article/routes/article.js holds the core router (custom routes would go in a separate file like custom-article.js):

// ./src/api/article/routes/article.js
const { createCoreRouter } = require('@strapi/strapi').factories;

module.exports = createCoreRouter('api::article.article', {
  config: {
    find: {
      auth: false,
    },
    findOne: {
      auth: false,
    },
  },
});

Lifecycle Hook for New Premium Content Notifications

When editors publish a premium article, you might want to notify subscribers. Lifecycle hooks handle this cleanly. Create ./src/api/article/content-types/article/lifecycles.js:

// ./src/api/article/content-types/article/lifecycles.js
module.exports = {
  async afterCreate(event) {
    const { result } = event;

    if (result.accessTier !== 'premium') {
      return;
    }

    // Fetch all active subscribers
    const activeSubscribers = await strapi
      .documents('api::subscription.subscription')
      .findMany({
        filters: { status: 'active' },
      });

    strapi.log.info(
      `New premium article "${result.title}" published. ` +
      `${activeSubscribers.length} active subscribers to notify.`
    );

    // Integrate with your email service here
    // e.g., send notification emails to activeSubscribers
  },
};

Frontend Integration

On the client side, the flow works like this:

  1. List articles by calling GET /api/articles — returns titles, excerpts, and access tiers for all articles.
  2. Read an article by calling GET /api/articles/:documentId?email=user@example.com — returns full content if the email has an active subscription, or just the excerpt with hasAccess: false.
  3. Start a subscription by calling POST /api/polar/checkout with a productId and customerEmail — returns a Polar checkout URL.
  4. After payment, Polar's webhook fires subscription.active, Strapi creates the subscription record, and subsequent article requests for that email return full content.

Here's a minimal frontend example for the checkout flow:

// Frontend: initiate checkout
async function startSubscription(email) {
  const response = await fetch('https://your-strapi-domain.com/api/polar/checkout', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      productId: 'your_polar_product_id',
      customerEmail: email,
      successUrl: `${window.location.origin}/welcome`,
    }),
  });

  const { checkoutUrl } = await response.json();
  window.location.href = checkoutUrl;
}

// Frontend: fetch article with access check
async function fetchArticle(documentId, email) {
  const url = new URL(`https://your-strapi-domain.com/api/articles/${documentId}`);
  if (email) url.searchParams.set('email', email);

  const response = await fetch(url);
  const { data } = await response.json();

  if (!data.hasAccess) {
    // Show excerpt and subscription prompt
    return { preview: true, ...data };
  }

  // Show full article
  return { preview: false, ...data };
}

This architecture keeps billing concerns entirely on the backend. The frontend never touches Polar API keys or subscription validation logic—it just passes an email and reacts to the hasAccess flag.

Strapi Open Office Hours

If you have any questions about Strapi 5 or just would like to stop by and say hi, you can join us at Strapi's Discord Open Office Hours, Monday through Friday, from 12:30 pm to 1:30 pm CST: Strapi Discord Open Office Hours.

For more details, visit the Strapi documentation and Polar documentation.

Frequently Answered Questions

Yes. Polar provides a dedicated sandbox API at https://sandbox-api.polar.sh/v1 for testing without processing real payments. Configure the Polar SDK client with server: "sandbox" (or the equivalent SDK option) to route all requests to the sandbox environment, for example:

const polar = new Polar({
  accessToken: process.env.POLAR_ACCESS_TOKEN!,
  server: "sandbox", // omit or use 'production' for live
});

Generate a separate Organization Access Token for the sandbox environment. This lets you test the full checkout-to-webhook cycle, including subscription creation and cancellation, without any financial transactions.

Polar follows the Standard Webhooks specification, using HMAC-SHA256 for cryptographic signing. Each webhook request includes a webhook-signature header. The @polar-sh/sdk provides a validateEvent method that compares this signature against your webhook secret using timing-safe comparison to prevent timing attacks. In Strapi, you handle this inside a custom controller with auth: false on the route, since Polar authenticates via signature rather than API tokens.

Polar automatically retries webhook deliveries up to 10 times using exponential backoff. If a webhook endpoint has 10+ consecutive failed deliveries (non‑2xx responses), Polar automatically disables the endpoint. Once disabled, it will no longer receive events and must be manually re‑enabled from the Polar dashboard after the issue is fixed. To avoid this, respond with a 200 status immediately and process events asynchronously. Implementing idempotent handlers—checking for existing records before creating new ones—ensures that duplicate deliveries from retries don't corrupt your Strapi subscription data.

Yes. Strapi v5 replaced the Entity Service with the Document Service API. All data operations now use strapi.documents() and reference records by documentId rather than numeric id. The webhook handlers and service methods in this guide use Document Service patterns exclusively. If you encounter older tutorials referencing strapi.entityService, those are v4 patterns and won't work with Strapi v5's architecture.

Polar supports usage-based billing alongside subscriptions and one-time payments. You can track consumption metrics—like API calls or content downloads—within Strapi using a custom collection type, then report usage to Polar through its API. The webhook integration pattern remains the same: Polar sends events when billing thresholds are reached or invoices are generated, and your Strapi handlers update records accordingly. The service layer described in this guide adapts directly to usage-based flows.