ShipKit docs
The stack

Payments

Polar checkout, signed webhooks, and the three parts you cannot ship two of.

Payments are Polar.sh, acting as merchant of record so VAT and sales tax are their problem rather than yours.

The integration has three parts, and all three are required. Ship two and either money arrives with nobody credited, or users are charged and see no change. This is rule #7 on the rules page for that reason.

Checkout

POST /api/v1/checkout creates a session and returns { checkoutUrl }. The body picks a plan:

{ "plan": "monthly" }

monthly and annual map to POLAR_PRODUCT_ID_PRO_MONTHLY and POLAR_PRODUCT_ID_PRO_ANNUAL. An unmapped plan fails with a BAD_REQUEST naming the plan rather than creating a session Polar cannot fulfil.

The caller's userId goes into the checkout metadata and into customer_external_id. Both, deliberately: that is the only thread connecting a payment back to an account, and the webhook needs it to resolve who to credit.

Webhook

POST /api/v1/webhooks/polar verifies the Standard Webhooks signature with POLAR_WEBHOOK_SECRET, then upserts a subscriptions row keyed on the Polar subscription id and linked to the resolved user. It handles subscription.created, subscription.updated and subscription.canceled.

Crediting is immediate. Nothing polls.

The signature check uses real secrets in development too, and disabling it "just to test locally" is how an unauthenticated endpoint that grants subscriptions reaches production. Use a tunnel instead: smee.io, ngrok or cloudflared, pointed at the API's dev port.

Gate

GET /api/v1/subscription/status returns the authenticated user's current subscription, and the server enforces the same thing independently. Client state decides what to render; it never decides what is allowed.

In practice you gate on entitlements rather than on a subscription row directly, because a plan is a set of capabilities and a subscription is a payment fact.

Unconfigured is a supported state

Without POLAR_ACCESS_TOKEN and the product ids, checkout is unavailable and every user resolves to the free plan. Gates still work, the app still runs, and you can build the whole product before opening a Polar account. See getting started.

Grace, cancellation and the states in between

A subscription is not a boolean. packages/entitlements/src/plan.ts resolves:

{ plan, status, cancelAtPeriodEnd, currentPeriodEnd, inGracePeriod }

A past_due subscription keeps its access for ENTITLEMENTS_GRACE_DAYS (default 7) while the provider retries the charge. Locking somebody out on the first decline turns a bank hiccup into a cancellation.

cancelAtPeriodEnd means a user who cancelled today still has paid time left. Revoking immediately is a refund request waiting to happen.

Surface both in the UI. A user whose card failed should see a banner, and one who cancelled should see when access ends.

Testing the whole loop

The reference caller is apps/web/app/[locale]/pricing/page.tsx, and the demo runs it against a live Polar account. Polar's dashboard has a webhook tester for replaying events without repeating a purchase, which is the fastest way to exercise subscription.updated and subscription.canceled — the two you will otherwise never see until a real customer hits them.

On this page