Skip to main content
This endpoint receives Polar subscription/order lifecycle events and keeps user_subscriptions and payment_history in sync. It must be publicly accessible and secured with POLAR_WEBHOOK_SECRET.
  • Endpoint: POST /api/webhooks/polar
  • File Location: src/app/api/webhooks/polar/route.ts
  • Authentication: Polar webhook signature (POLAR_WEBHOOK_SECRET)
  • Raw body: Next.js automatically provides the raw request body to @polar-sh/nextjs

Supported Events

EventPurpose
subscription.createdUpsert subscription row with product/billing info
subscription.updatedKeep status, plan name, cancellation dates in sync
subscription.activeForce status back to active after payment
subscription.canceledRecord cancel_at, cancel_at_period_end, canceled_at
subscription.revokedImmediate cancellation
order.createdLogged for observability (no DB write)
order.paidInsert payment record + fetch invoice URL
order.refundedInsert negative payment record and mark as refunded
Events are forwarded as soon as Polar receives an update. Always keep this endpoint live—even if the user closes the checkout success page, webhooks complete the subscription sync.

Implementation Highlights

The handler first looks for subscription.metadata.user_id, falling back to subscription.customer.metadata.user_id. This guards against legacy customers that do not include metadata on the subscription itself.
For order.paid / order.refunded, the helper calls POST /v1/orders/{id}/invoice followed by GET /v1/orders/{id}/invoice to fetch a hosted invoice URL. The link is stored in payment_history.invoice_url and surfaced in the billing UI.
createServiceClient() uses SUPABASE_SECRET_KEY to bypass Row Level Security. API base URLs respect POLAR_SANDBOX; set it to false in production.

Code (abridged)

src/app/api/webhooks/polar/route.ts (excerpt)
import { Webhooks } from "@polar-sh/nextjs";
import { createServiceClient } from "@/lib/supabase/server";

export const POST = Webhooks({
  webhookSecret: process.env.POLAR_WEBHOOK_SECRET!,

  onSubscriptionCreated: async (payload) => {
    const supabase = createServiceClient();
    const subscription = payload.data;
    const userId =
      (subscription.metadata?.user_id as string | undefined) ??
      (subscription.customer?.metadata?.user_id as string | undefined);

    if (!userId) return;

    await supabase.from('user_subscriptions').upsert({
      user_id: userId,
      polar_customer_id: subscription.customerId,
      polar_subscription_id: subscription.id,
      polar_product_id: subscription.productId,
      plan_name: subscription.product?.name || 'Unknown Plan',
      status: subscription.status,
      billing_cycle: subscription.recurringInterval,
      current_period_start: subscription.currentPeriodStart,
      current_period_end: subscription.currentPeriodEnd,
      cancel_at_period_end: subscription.cancelAtPeriodEnd,
      created_at: subscription.createdAt,
      updated_at: new Date().toISOString(),
    }, { onConflict: 'user_id' });
  },

  onOrderPaid: async (payload) => {
    const supabase = createServiceClient();
    const order = payload.data;

    // Look up the user from the subscription
    const { data: subscriptionRecord } = await supabase
      .from('user_subscriptions')
      .select('user_id')
      .eq('polar_subscription_id', order.subscriptionId)
      .single();

    if (!subscriptionRecord?.user_id) return;

    const invoiceUrl = await getInvoiceUrl(order.id);

    await supabase.from('payment_history').insert({
      user_id: subscriptionRecord.user_id,
      polar_subscription_id: order.subscriptionId,
      polar_order_id: order.id,
      amount: order.netAmount,
      currency: order.currency,
      status: 'succeeded',
      description: `${order.product?.name || 'Subscription'} payment`,
      invoice_url: invoiceUrl,
      created_at: order.createdAt,
    });
  },
});

Testing

1

Expose local server

Run ngrok http 3000 (or similar) and configure the URL in Polar Dashboard → Settings → Webhooks. Copy the signing secret to POLAR_WEBHOOK_SECRET.
2

Trigger events

Complete a sandbox checkout or use Polar’s dashboard to simulate subscription updates. Watch your terminal—the handler logs every event (Subscription created, Order paid, etc.).
3

Verify database

Check user_subscriptions and payment_history in Supabase to confirm new rows were inserted/updated.

Troubleshooting

Ensure checkout metadata includes user_id. The pricing component does this automatically—if you customize it, keep the metadata intact.
Polar may return 409 if an invoice already exists. The helper handles this, but if invoice_url is still null, try re-fetching after a short delay.
Verify SUPABASE_SECRET_KEY is present so the service client can bypass RLS. Missing keys cause insert/update failures.