Billing with Polar
Billing is optional. Without Polar credentials every billing route answers
billing_unavailable and everyone stays on
the Free plan.
Set up Polar
Section titled “Set up Polar”Start in Polar’s sandbox (POLAR_SERVER=sandbox in api.env). Sandbox and
production are separate environments with their own tokens, webhooks and
products; switch to POLAR_SERVER=production for real payments.
-
Settings → Developers → Access tokens: create an organization access token and save it as
secrets/polar_access_token. -
Settings → Webhooks → Add endpoint:
- URL
https://app.skillpouch.net/v1/billing/webhooks/polar - format Raw
- events
subscription.created,subscription.updated,subscription.active,subscription.canceled,subscription.uncanceled,subscription.revoked,product.created,product.updated,order.paid,order.updated,order.refunded
Save its secret as
secrets/polar_webhook_secret. - URL
Checkout returns to WEB_ORIGIN/app/account/billing?ok=1. That URL is sent
with each checkout; there is nothing to set in Polar.
For local development, use the same webhook path on a tunnel to your
machine (<tunnel>/v1/billing/webhooks/polar, see apps/api/.env.example).
How it works
Section titled “How it works”POST /v1/billing/checkoutreturns a hosted Polar checkout URL tied to the account.- Polar calls the webhook. The API verifies the signature and stores each event once: duplicates are ignored and older updates never overwrite newer ones. Then it recomputes the account’s plan limits.
- A daily job re-reads open subscriptions in case a webhook was missed.
Downgrades never delete data: pouches over the new limit become read-only.
Products and plans
Section titled “Products and plans”Each paid plan is one Polar product. Polar handles the price and the checkout; SkillPouch keeps each plan’s limits and the product’s ID. The webhook maps a subscription back to its plan through that ID.
Create every product in both sandbox and production, and tag it the same way in each.
Metadata
Section titled “Metadata”Set these under Products → (product) → Metadata. Keys and values are case-sensitive.
| Key | Value | Required | Meaning |
|---|---|---|---|
plan |
plan slug, e.g. supporter |
yes | Links the product to the plan with this slug |
pouch_limit |
whole number, e.g. 3 |
for a new plan | Pouches allowed |
storage_bytes |
whole number, e.g. 1073741824 (1 GiB) |
for a new plan | Encrypted storage allowed |
visible |
0 to hide |
no | Hidden plans can only be given by an admin |
Limits in the database win once a plan exists, so editing the metadata later doesn’t quietly change what paying users get.
What syncs from Polar
Section titled “What syncs from Polar”SkillPouch reads the tagged products when Polar sends product.created or
product.updated, when the API starts, every hour, and when a checkout
finds no product for its plan. For each product it:
- links it to the plan with the same slug
- copies the name, description, price and interval; rename a product in Polar and the billing page follows within seconds
- creates the plan when the slug is new, using
pouch_limitandstorage_bytes - shows or hides the plan according to
visible
Editing a plan in the admin dashboard pushes its name, description and price to Polar, so the two stay the same either way. Polar creates a new price only when the price or interval changes; existing subscribers keep theirs.
Hidden plans
Section titled “Hidden plans”A plan with visible set to 0 isn’t listed on the billing page and can’t
be bought. An admin gives it to someone with a grant
(POST /v1/admin/users/:id/grants). While someone is on a hidden plan,
every other plan is disabled on their billing page and the API refuses
checkouts and plan changes
(plan_change_unavailable).
A hidden plan doesn’t need a Polar product: a plan created through the
admin API with is_public: false works the same way. The Free plan
(free) has no Polar product.
Adding a plan
Section titled “Adding a plan”- Create the product in the Polar sandbox with a recurring price.
- Add the metadata above with a new slug.
- Create the same product, with the same metadata, in production when it’s ready.
- For a look of its own on the billing page, add a style in
apps/web/src/features/billing/plan-styles.tsandapps/web/src/styles/globals.css. Without one it uses the Supporter look.