> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getsabo.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Polar Webhooks

> Process Polar subscription + order events and sync Supabase tables

<Info>
  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`.
</Info>

* **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

| Event                   | Purpose                                                   |
| ----------------------- | --------------------------------------------------------- |
| `subscription.created`  | Upsert subscription row with product/billing info         |
| `subscription.updated`  | Keep status, plan name, cancellation dates in sync        |
| `subscription.active`   | Force status back to `active` after payment               |
| `subscription.canceled` | Record `cancel_at`, `cancel_at_period_end`, `canceled_at` |
| `subscription.revoked`  | Immediate cancellation                                    |
| `order.created`         | Logged for observability (no DB write)                    |
| `order.paid`            | Insert payment record + fetch invoice URL                 |
| `order.refunded`        | Insert negative payment record and mark as refunded       |

<Note>
  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.
</Note>

## Implementation Highlights

<AccordionGroup>
  <Accordion title="Metadata fallback">
    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.
  </Accordion>

  <Accordion title="Invoice helper">
    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.
  </Accordion>

  <Accordion title="Service client + envs">
    `createServiceClient()` uses `SUPABASE_SECRET_KEY` to bypass Row Level Security. API base URLs respect `POLAR_SANDBOX`; set it to `false` in production.
  </Accordion>
</AccordionGroup>

### Code (abridged)

```ts src/app/api/webhooks/polar/route.ts (excerpt) theme={null}
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

<Steps>
  <Step title="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`.
  </Step>

  <Step title="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.).
  </Step>

  <Step title="Verify database">
    Check `user_subscriptions` and `payment_history` in Supabase to confirm new rows were inserted/updated.
  </Step>
</Steps>

## Troubleshooting

<AccordionGroup>
  <Accordion title="No user ID found">
    Ensure checkout metadata includes `user_id`. The pricing component does this automatically—if you customize it, keep the metadata intact.
  </Accordion>

  <Accordion title="Invoice URL null">
    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.
  </Accordion>

  <Accordion title="401 or 400 from Supabase">
    Verify `SUPABASE_SECRET_KEY` is present so the service client can bypass RLS. Missing keys cause insert/update failures.
  </Accordion>
</AccordionGroup>
