Update to Polar
Snapshot Nov 3, 2026 · 19:24 UTC · version 0.2.2
Collection source: downloaded plugin package.
Payment or plan references changed
Skill instructions — Now says: Access Tokens, or with `polar auth token` once the CLI is signed in to the sandbox (see "Install and sign in" below) Set up the client as in...
Observed in published text. Live prices and checkout terms have not been verified by this change.
Up to 10 plugins free · no card required.
All fields and original values.
Skill instructions
Nov 3, 2026 · 00:14 UTC
Developers // TypeScript import { Polar } from "@polar-sh/sdk"; const polar = new Polar({ accessToken: process.env.POLAR_ACCESS_TOKEN, server: "sandbox", // Switch to "production" for live # Python from polar_sdk import Polar polar =...
Nov 3, 2026 · 19:24 UTC
Access Tokens, or with `polar auth token` once the CLI is signed in to the sandbox (see "Install and sign in" below) Set up the client as in `polar-integration` ("Setup") and pass `environment: "sandbox"`: import { createPolar } from "@p...
Compare saved observations
Download comparison JSONSKILL.md text changes
− removed · + added
--- before +++ after @@ -26,74 +26,45 @@ 1. Go to https://sandbox.polar.sh/start 2. Create a new account (or use "Go to sandbox" from org switcher) 3. Create a test organization -4. Generate an access token in Settings → Developers +4. Generate an access token in Settings → Access Tokens, or with `polar auth token` once the CLI is signed in to the sandbox (see "Install and sign in" below) ### SDK Configuration +Set up the client as in `polar-integration` ("Setup") and pass `environment: "sandbox"`: + ```typescript -// TypeScript -import { Polar } from "@polar-sh/sdk"; +import { createPolar } from "@polar-sh/sdk/2026-10"; -const polar = new Polar({ - accessToken: process.env.POLAR_ACCESS_TOKEN, - server: "sandbox", // Switch to "production" for live +const polar = createPolar({ + accessToken: process.env.POLAR_ACCESS_TOKEN!, + environment: "sandbox", }); ``` ```python -# Python -from polar_sdk import Polar +import os -polar = Polar( - access_token=os.environ["POLAR_ACCESS_TOKEN"], - server="sandbox", -) -``` +from polar.v2026_10 import Polar -```go -// Go -s := polargo.New( - polargo.WithServer("sandbox"), - polargo.WithSecurity(os.Getenv("POLAR_ACCESS_TOKEN")), -) +polar = Polar(os.environ["POLAR_ACCESS_TOKEN"], environment="sandbox") ``` ### Sandbox Limitations -- Subscriptions auto-cancel after 90 days -- No real money is processed -- Data is isolated from production +- No real money is processed, and data is isolated from production +- Customer-facing emails (receipts, renewal reminders) are only delivered to members of your organization; `you+test@example.com` aliases are accepted +- The API allows 100 requests per minute, against 500 in production, which matters for large integration suites ## Test Cards -Polar uses Stripe for payment processing. Use these test card numbers: - -### Successful Payments - -| Card Number | Brand | CVC | Expiry | -|-------------|-------|-----|--------| -| 4242 4242 4242 4242 | Visa | Any 3 digits | Any future date | -| 5555 5555 5555 4444 | Mastercard | Any 3 digits | Any future date | -| 3782 822463 10005 | Amex | Any 4 digits | Any future date | -| 6011 1111 1111 1117 | Discover | Any 3 digits | Any future date | - -### Declined Payments - -| Card Number | Decline Reason | -|-------------|----------------| -| 4000 0000 0000 0002 | Generic decline | -| 4000 0000 0000 9995 | Insufficient funds | -| 4000 0000 0000 9987 | Lost card | -| 4000 0000 0000 9979 | Stolen card | -| 4000 0000 0000 0069 | Expired card | -| 4000 0000 0000 0127 | Incorrect CVC | - -### 3D Secure Testing +Polar uses Stripe for payment processing, so any of [Stripe's test cards](https://docs.stripe.com/testing#cards) work. Use a future expiry date and any CVC: | Card Number | Behavior | |-------------|----------| -| 4000 0027 6000 3184 | Requires 3DS authentication | -| 4000 0000 0000 3220 | Requires 3DS authentication | +| 4242 4242 4242 4242 | Successful payment | +| 4000 0000 0000 0002 | Generic decline | +| 4000 0000 0000 9995 | Insufficient funds | +| 4000 0027 6000 3184 | Requires 3D Secure authentication | ## Local Webhook Testing @@ -118,13 +89,15 @@ export POLAR_ENVIRONMENT=sandbox ``` +If `POLAR_ACCESS_TOKEN` is already exported in the shell, for example from the app's `.env`, the CLI uses it instead of the login. + ### Forward events to your app ```bash polar webhook listen 3000/api/polar/webhook ``` -`listen` takes a port, a `port/path`, or a full URL. It runs until stopped, so start it in its own terminal or as a background process and keep it running while you test. It forwards every webhook event for the organization, including the ones real sandbox activity produces, such as completing a test checkout. +`listen` takes a port, a `port/path`, or a full URL. It runs until stopped, so start it in its own terminal or as a background process and keep it running while you test. It forwards every webhook event for the organization, including the ones real sandbox activity produces, such as completing a test checkout. If the account has several organizations, pick one with `--org <id>` or `polar auth org`. ### Set the webhook secret @@ -158,52 +131,46 @@ ### Test Checkout Flow +Use an organization access token with the `products:write` and `checkouts:write` scopes. Organization tokens already imply the organization, so don't pass `organization_id`. + ```typescript import { describe, it, expect, beforeAll } from "vitest"; -import { Polar } from "@polar-sh/sdk"; +import { createPolar } from "@polar-sh/sdk/2026-10"; -describe("Polar Checkout", () => { - const polar = new Polar({ - accessToken: process.env.POLAR_SANDBOX_TOKEN!, - server: "sandbox", - }); +const polar = createPolar({ + accessToken: process.env.POLAR_ACCESS_TOKEN!, + environment: "sandbox", +}); +describe("Polar Checkout", () => { let testProductId: string; beforeAll(async () => { - // Create test product const product = await polar.products.create({ name: "Test Product", - organizationId: process.env.POLAR_ORG_ID!, - prices: [{ - type: "one_time", - amountType: "fixed", - priceAmount: 1000, - priceCurrency: "usd", - }], + prices: [{ amount_type: "fixed", price_amount: 1000, price_currency: "usd" }], }); testProductId = product.id; }); - it("should create checkout session", async () => { + it("creates a checkout session", async () => { const checkout = await polar.checkouts.create({ products: [testProductId], - successUrl: "http://localhost:3000/success", - customerEmail: "test@example.com", + success_url: "http://localhost:3000/success", + customer_email: "test@example.com", }); expect(checkout.status).toBe("open"); - expect(checkout.url).toBeDefined(); expect(checkout.url).toContain("sandbox"); }); - it("should retrieve checkout", async () => { + it("retrieves a checkout", async () => { const checkout = await polar.checkouts.create({ products: [testProductId], - successUrl: "http://localhost:3000/success", + success_url: "http://localhost:3000/success", }); - const retrieved = await polar.checkouts.get({ id: checkout.id }); + const retrieved = await polar.checkouts.get(checkout.id); expect(retrieved.id).toBe(checkout.id); }); }); @@ -211,15 +178,15 @@ ### Test Webhook Handler -Generate fixtures from real payloads instead of writing them by hand. `--json` prints the payload without sending it, so `listen` doesn't need to be running: +Generate fixtures from real payloads instead of writing them by hand. `--dry-run` prints the payload without sending it, so `listen` doesn't need to be running: ```bash mkdir -p test/fixtures -polar webhook trigger order.paid --json --seed 1 > test/fixtures/order.paid.json -polar webhook trigger subscription.created --json --seed 1 > test/fixtures/subscription.created.json +polar webhook trigger order.paid --dry-run --json --seed 1 > test/fixtures/order.paid.json +polar webhook trigger subscription.created --dry-run --json --seed 1 > test/fixtures/subscription.created.json ``` -Sign them the way Polar does ([Standard Webhooks](https://www.standardwebhooks.com/)): an HMAC-SHA256 of `id.timestamp.body`, base64-encoded and keyed with the secret string. This test calls the webhook route from `polar-integration` Recipe 3 directly: +Sign them the way `polar webhook listen` does ([Standard Webhooks](https://www.standardwebhooks.com/)): an HMAC-SHA256 of `id.timestamp.body`, base64-encoded and keyed with the secret string. This test calls the webhook route from `polar-integration` Recipe 3 directly: ```typescript import { createHmac } from "node:crypto"; @@ -256,7 +223,7 @@ it("accepts a signed order.paid event", async () => { const body = await readFile("test/fixtures/order.paid.json", "utf8"); const response = await POST(webhookRequest(body)); - expect(response.status).toBe(200); + expect(response.status).toBe(202); }); it("rejects an invalid signature", async () => { @@ -269,27 +236,28 @@ ### Test License Key Validation +Validation needs a token with `license_keys:write`. A valid key returns `status: "granted"`; an unknown key throws `ResourceNotFound`: + ```typescript -describe("License Keys", () => { - it("should validate license key", async () => { - // First create a customer with a license key benefit - // Then validate the key - const result = await polar.licenseKeys.validate({ - key: "TEST-XXXX-XXXX-XXXX", - organizationId: process.env.POLAR_ORG_ID!, - }); +import { describe, it, expect } from "vitest"; +import { createPolar, errors } from "@polar-sh/sdk/2026-10"; - expect(result.valid).toBe(true); - expect(result.customer).toBeDefined(); - }); +const polar = createPolar({ + accessToken: process.env.POLAR_ACCESS_TOKEN!, + environment: "sandbox", +}); +const organization_id = process.env.POLAR_ORG_ID!; - it("should reject invalid license key", async () => { - const result = await polar.licenseKeys.validate({ - key: "INVALID-KEY", - organizationId: process.env.POLAR_ORG_ID!, - }); +describe("License keys", () => { + it("validates a granted key", async () => { + const result = await polar.licenseKeys.validate({ key: process.env.TEST_LICENSE_KEY!, organization_id }); + expect(result.status).toBe("granted"); + }); - expect(result.valid).toBe(false); + it("rejects an unknown key", async () => { + await expect( + polar.licenseKeys.validate({ key: "INVALID-KEY", organization_id }), + ).rejects.toBeInstanceOf(errors.ResourceNotFound); }); }); ``` @@ -298,42 +266,40 @@ ### Mock SDK +Mock `createPolar` and keep the real `webhooks` and `errors` exports. Responses use the same `snake_case` fields as the API: + ```typescript import { vi } from "vitest"; -// Mock the entire SDK -vi.mock("@polar-sh/sdk", () => ({ - Polar: vi.fn().mockImplementation(() => ({ +vi.mock("@polar-sh/sdk/2026-10", async (importOriginal) => ({ + ...(await importOriginal<typeof import("@polar-sh/sdk/2026-10")>()), + createPolar: () => ({ checkouts: { create: vi.fn().mockResolvedValue({ id: "checkout_mock", url: "https://sandbox.polar.sh/checkout/mock", status: "open", }), - get: vi.fn().mockResolvedValue({ - id: "checkout_mock", - status: "succeeded", - }), + get: vi.fn().mockResolvedValue({ id: "checkout_mock", status: "succeeded" }), }, customers: { - getState: vi.fn().mockResolvedValue({ - activeSubscriptions: [ - { id: "sub_mock", status: "active", productId: "prod_mock" }, - ], - grantedBenefits: [], + getStateExternal: vi.fn().mockResolvedValue({ + active_subscriptions: [{ id: "sub_mock", status: "active", product_id: "prod_mock" }], + granted_benefits: [], + active_meters: [], }), }, subscriptions: { - list: vi.fn().mockResolvedValue([]), - cancel: vi.fn().mockResolvedValue({}), + revoke: vi.fn(), + update: vi.fn(), }, - })), + }), })); ``` ### Mock Webhook Payloads -Load the fixtures generated with `polar webhook trigger <event> --json` (see Test Webhook Handler). Hand-written payloads drift from the real shape, and `validateEvent` rejects them. +Load the fixtures generated with `polar webhook trigger <event> --dry-run --json` (see Test Webhook Handler). Hand-written payloads drift from the real shape, and `validateEvent` only checks the signature and event type, so the drift goes unnoticed. ```typescript import orderPaid from "./fixtures/order.paid.json"; @@ -355,16 +321,14 @@ env: POLAR_ACCESS_TOKEN: ${{ secrets.POLAR_SANDBOX_TOKEN }} - POLAR_WEBHOOK_SECRET: ${{ secrets.POLAR_SANDBOX_WEBHOOK_SECRET }} POLAR_ORG_ID: ${{ secrets.POLAR_SANDBOX_ORG_ID }} - POLAR_SERVER: sandbox steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22" cache: "npm" - run: npm ci @@ -376,14 +340,16 @@ run: npm run test:integration ``` +If CI also runs the Polar CLI, add `POLAR_ENVIRONMENT: sandbox`. + ### Environment Setup Create sandbox credentials specifically for CI: 1. Create a dedicated sandbox organization for CI -2. Generate a CI-specific access token +2. Create an organization access token for it with only the scopes the tests need (`polar auth token --scope products:write --scope checkouts:write` opens the token page with them preselected) 3. Commit the webhook fixtures; handler tests sign them with a test secret and need no webhook endpoint -4. Store credentials in GitHub Secrets +4. Store the token and organization ID in GitHub Secrets ## Debugging Tips @@ -409,24 +375,28 @@ **Checkout not completing** - Use test cards, not real cards - Check browser console for errors -- Verify successUrl is correct +- Verify `success_url` is correct **API returns 401** -- Verify you're using sandbox token with sandbox API -- Check token hasn't expired -- Ensure token has required scopes +- Verify you're using a sandbox token with the sandbox API +- Check the token hasn't expired or been revoked -### Enable Debug Logging +**API returns 403** +- The token is missing a scope the endpoint needs + +### Inspect API Errors + +The SDK throws typed errors with the HTTP status and response body: ```typescript -const polar = new Polar({ - accessToken: process.env.POLAR_ACCESS_TOKEN, - server: "sandbox", - // Enable debug mode if available -}); +import { PolarClientError } from "@polar-sh/sdk"; -// Log all requests -polar.checkouts.create({...}).then(console.log).catch(console.error); +try { + await polar.checkouts.create({ products: [productId], success_url: "http://localhost:3000/success" }); +} catch (error) { + if (error instanceof PolarClientError) console.error(error.statusCode, error.error); + throw error; +} ``` ## Test Checklist
Full technical diff · 1 changed fields
changed /skill_md_contents
"---\nname: polar-testing\ndescription: |\n Guide for testing Polar payment integrations using the sandbox environment. Use this skill when: (1) Setting up the Polar sandbox for development; (2) Testing checkout flows without real payments; (3) Using Stripe test cards with Polar; (4) Writing integration tests for payment flows; (5) Testing webhooks locally with the Polar CLI (`polar webhook listen`, `polar webhook trigger`) and generating webhook fixtures; (6) Mocking Polar in unit tests; (7) Setting up CI/CD pipelines with Polar sandbox; (8) Debugging payment issues in sandbox.\n---\n\n# Polar Testing Guide\n\nTest Polar integrations safely using the sandbox environment - a fully isolated server where you can experiment without affecting production data or processing real payments.\n\n## Sandbox Environment\n\n### Access\n\n- **Dashboard**: https://sandbox.polar.sh\n- **API**: https://sandbox-api.polar.sh/v1\n\nThe sandbox is completely isolated from production. You need separate:\n- User account\n- Organization\n- Access tokens\n- Webhook endpoints\n\n### Setup Steps\n\n1. Go to https://sandbox.polar.sh/start\n2. Create a new account (or use \"Go to sandbox\" from org switcher)\n3. Create a test organization\n4. Generate an access token in Settings → Developers\n\n### SDK Configuration\n\n```typescript\n// TypeScript\nimport { Polar } from \"@polar-sh/sdk\";\n\nconst polar = new Polar({\n accessToken: process.env.POLAR_ACCESS_TOKEN,\n server: \"sandbox\", // Switch to \"production\" for live\n});\n```\n\n```python\n# Python\nfrom polar_sdk import Polar\n\npolar = Polar(\n access_token=os.environ[\"POLAR_ACCESS_TOKEN\"],\n server=\"sandbox\",\n)\n```\n\n```go\n// Go\ns := polargo.New(\n polargo.WithServer(\"sandbox\"),\n polargo.WithSecurity(os.Getenv(\"POLAR_ACCESS_TOKEN\")),\n)\n```\n\n### Sandbox Limitations\n\n- Subscriptions auto-cancel after 90 days\n- No real money is processed\n- Data is isolated from production\n\n## Test Cards\n\nPolar uses Stripe for payment processing. Use these test card numbers:\n\n### Successful Payments\n\n| Card Number | Brand | CVC | Expiry |\n|-------------|-------|-----|--------|\n| 4242 4242 4242 4242 | Visa | Any 3 digits | Any future date |\n| 5555 5555 5555 4444 | Mastercard | Any 3 digits | Any future date |\n| 3782 822463 10005 | Amex | Any 4 digits | Any future date |\n| 6011 1111 1111 1117 | Discover | Any 3 digits | Any future date |\n\n### Declined Payments\n\n| Card Number | Decline Reason |\n|-------------|----------------|\n| 4000 0000 0000 0002 | Generic decline |\n| 4000 0000 0000 9995 | Insufficient funds |\n| 4000 0000 0000 9987 | Lost card |\n| 4000 0000 0000 9979 | Stolen card |\n| 4000 0000 0000 0069 | Expired card |\n| 4000 0000 0000 0127 | Incorrect CVC |\n\n### 3D Secure Testing\n\n| Card Number | Behavior |\n|-------------|----------|\n| 4000 0027 6000 3184 | Requires 3DS authentication |\n| 4000 0000 0000 3220 | Requires 3DS authentication |\n\n## Local Webhook Testing\n\nUse the Polar CLI. It forwards your organization's webhook events straight to your local server: no tunnel, no public URL, and no webhook endpoint in the dashboard.\n\n### Install and sign in\n\n```bash\nnpm install -g @polar-sh/cli\n\npolar auth login --sandbox\n```\n\nFor other install methods, see the [CLI docs](https://polar.sh/docs/integrate/cli/introduction).\n\n`polar auth login` opens a browser, so ask the user to run it. Check the result with `polar auth whoami --json`: `environments` lists the signed-in environments and `organization` is the active one.\n\nWhere no browser is available (CI, remote machines, cloud agents), an organization access token replaces `auth login`:\n\n```bash\nexport POLAR_ACCESS_TOKEN=polar_oat_... # scopes: webhooks:read, webhooks:write, organizations:read\nexport POLAR_ENVIRONMENT=sandbox\n```\n\n### Forward events to your app\n\n```bash\npolar webhook listen 3000/api/polar/webhook\n```\n\n`listen` takes a port, a `port/path`, or a full URL. It runs until stopped, so start it in its own terminal or as a background process and keep it running while you test. It forwards every webhook event for the organization, including the ones real sandbox activity produces, such as completing a test checkout.\n\n### Set the webhook secret\n\n`listen` signs forwarded events with a local secret, not a dashboard endpoint's secret. Write it to `.env` without printing it:\n\n```bash\nsecret=$(polar webhook listen --print-secret) && echo \"POLAR_WEBHOOK_SECRET=$secret\" >> .env\n```\n\nIf `.env` already has a `POLAR_WEBHOOK_SECRET` line, replace it instead of appending, then restart the dev server. Deployed environments use the secret of their dashboard webhook endpoint.\n\n### Send test events\n\n```bash\npolar webhook trigger order.paid\n```\n\n`trigger` sends a sample event through the running `listen`. When your handler doesn't accept it (a non-2xx response, a connection error, or a redirect), it prints the handler's response body and exits with status 1. Read that output, fix the handler, and run the same command again until it exits with 0.\n\nA redirect usually means auth middleware is intercepting the webhook route. Exclude the route from it: Polar never follows redirects when delivering webhooks.\n\n```bash\npolar webhook trigger --list --json # every event you can send\npolar webhook trigger order.paid --override data.customer.email=jane@example.com\npolar webhook trigger order.paid --seed 7 # the same IDs on every run\n```\n\n`--override` takes `path=value` and can be repeated. The API rejects paths that don't exist in the payload.\n\n## Integration Testing\n\n### Test Checkout Flow\n\n```typescript\nimport { describe, it, expect, beforeAll } from \"vitest\";\nimport { Polar } from \"@polar-sh/sdk\";\n\ndescribe(\"Polar Checkout\", () => {\n const polar = new Polar({\n accessToken: process.env.POLAR_SANDBOX_TOKEN!,\n server: \"sandbox\",\n });\n\n let testProductId: string;\n\n beforeAll(async () => {\n // Create test product\n const product = await polar.products.create({\n name: \"Test Product\",\n organizationId: process.env.POLAR_ORG_ID!,\n prices: [{\n type: \"one_time\",\n amountType: \"fixed\",\n priceAmount: 1000,\n priceCurrency: \"usd\",\n }],\n });\n testProductId = product.id;\n });\n\n it(\"should create checkout session\", async () => {\n const checkout = await polar.checkouts.create({\n products: [testProductId],\n successUrl: \"http://localhost:3000/success\",\n customerEmail: \"test@example.com\",\n });\n\n expect(checkout.status).toBe(\"open\");\n expect(checkout.url).toBeDefined();\n expect(checkout.url).toContain(\"sandbox\");\n });\n\n it(\"should retrieve checkout\", async () => {\n const checkout = await polar.checkouts.create({\n products: [testProductId],\n successUrl: \"http://localhost:3000/success\",\n });\n\n const retrieved = await polar.checkouts.get({ id: checkout.id });\n expect(retrieved.id).toBe(checkout.id);\n });\n});\n```\n\n### Test Webhook Handler\n\nGenerate fixtures from real payloads instead of writing them by hand. `--json` prints the payload without sending it, so `listen` doesn't need to be running:\n\n```bash\nmkdir -p test/fixtures\npolar webhook trigger order.paid --json --seed 1 > test/fixtures/order.paid.json\npolar webhook trigger subscription.created --json --seed 1 > test/fixtures/subscription.created.json\n```\n\nSign them the way Polar does ([Standard Webhooks](https://www.standardwebhooks.com/)): an HMAC-SHA256 of `id.timestamp.body`, base64-encoded and keyed with the secret string. This test calls the webhook route from `polar-integration` Recipe 3 directly:\n\n```typescript\nimport { createHmac } from \"node:crypto\";\nimport { readFile } from \"node:fs/promises\";\nimport { beforeAll, describe, expect, it, vi } from \"vitest\";\n\nconst secret = \"test_webhook_secret\";\nlet POST: (request: Request) => Promise<Response>;\n\nbeforeAll(async () => {\n vi.stubEnv(\"POLAR_WEBHOOK_SECRET\", secret);\n ({ POST } = await import(\"../app/api/polar/webhook/route\"));\n});\n\nconst sign = (id: string, timestamp: number, body: string) =>\n `v1,${createHmac(\"sha256\", secret).update(`${id}.${timestamp}.${body}`).digest(\"base64\")}`;\n\nconst webhookRequest = (body: string, signature?: string) => {\n const id = \"msg_test\";\n const timestamp = Math.floor(Date.now() / 1000);\n return new Request(\"http://localhost/api/polar/webhook\", {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n \"webhook-id\": id,\n \"webhook-timestamp\": String(timestamp),\n \"webhook-signature\": signature ?? sign(id, timestamp, body),\n },\n body,\n });\n};\n\ndescribe(\"Polar webhook handler\", () => {\n it(\"accepts a signed order.paid event\", async () => {\n const body = await readFile(\"test/fixtures/order.paid.json\", \"utf8\");\n const response = await POST(webhookRequest(body));\n expect(response.status).toBe(200);\n });\n\n it(\"rejects an invalid signature\", async () => {\n const body = await readFile(\"test/fixtures/order.paid.json\", \"utf8\");\n const response = await POST(webhookRequest(body, \"v1,invalid\"));\n expect(response.status).toBe(403);\n });\n});\n```\n\n### Test License Key Validation\n\n```typescript\ndescribe(\"License Keys\", () => {\n it(\"should validate license key\", async () => {\n // First create a customer with a license key benefit\n // Then validate the key\n const result = await polar.licenseKeys.validate({\n key: \"TEST-XXXX-XXXX-XXXX\",\n organizationId: process.env.POLAR_ORG_ID!,\n });\n\n expect(result.valid).toBe(true);\n expect(result.customer).toBeDefined();\n });\n\n it(\"should reject invalid license key\", async () => {\n const result = await polar.licenseKeys.validate({\n key: \"INVALID-KEY\",\n organizationId: process.env.POLAR_ORG_ID!,\n });\n\n expect(result.valid).toBe(false);\n });\n});\n```\n\n## Mocking Polar in Unit Tests\n\n### Mock SDK\n\n```typescript\nimport { vi } from \"vitest\";\n\n// Mock the entire SDK\nvi.mock(\"@polar-sh/sdk\", () => ({\n Polar: vi.fn().mockImplementation(() => ({\n checkouts: {\n create: vi.fn().mockResolvedValue({\n id: \"checkout_mock\",\n url: \"https://sandbox.polar.sh/checkout/mock\",\n status: \"open\",\n }),\n get: vi.fn().mockResolvedValue({\n id: \"checkout_mock\",\n status: \"succeeded\",\n }),\n },\n customers: {\n getState: vi.fn().mockResolvedValue({\n activeSubscriptions: [\n { id: \"sub_mock\", status: \"active\", productId: \"prod_mock\" },\n ],\n grantedBenefits: [],\n }),\n },\n subscriptions: {\n list: vi.fn().mockResolvedValue([]),\n cancel: vi.fn().mockResolvedValue({}),\n },\n })),\n}));\n```\n\n### Mock Webhook Payloads\n\nLoad the fixtures generated with `polar webhook trigger <event> --json` (see Test Webhook Handler). Hand-written payloads drift from the real shape, and `validateEvent` rejects them.\n\n```typescript\nimport orderPaid from \"./fixtures/order.paid.json\";\nimport subscriptionCreated from \"./fixtures/subscription.created.json\";\n```\n\n## CI/CD Integration\n\n### GitHub Actions\n\n```yaml\nname: Test Polar Integration\n\non: [push, pull_request]\n\njobs:\n test:\n runs-on: ubuntu-latest\n\n env:\n POLAR_ACCESS_TOKEN: ${{ secrets.POLAR_SANDBOX_TOKEN }}\n POLAR_WEBHOOK_SECRET: ${{ secrets.POLAR_SANDBOX_WEBHOOK_SECRET }}\n POLAR_ORG_ID: ${{ secrets.POLAR_SANDBOX_ORG_ID }}\n POLAR_SERVER: sandbox\n\n steps:\n - uses: actions/checkout@v4\n\n - uses: actions/setup-node@v4\n with:\n node-version: \"20\"\n cache: \"npm\"\n\n - run: npm ci\n\n - name: Run unit tests\n run: npm run test:unit\n\n - name: Run integration tests\n run: npm run test:integration\n```\n\n### Environment Setup\n\nCreate sandbox credentials specifically for CI:\n\n1. Create a dedicated sandbox organization for CI\n2. Generate a CI-specific access token\n3. Commit the webhook fixtures; handler tests sign them with a test secret and need no webhook endpoint\n4. Store credentials in GitHub Secrets\n\n## Debugging Tips\n\n### Check Webhook Delivery\n\nLocally, `polar webhook listen` logs every forwarded event with your handler's status, and `polar webhook trigger` prints the handler's response body when it fails.\n\nFor a deployed endpoint:\n\n1. Go to sandbox.polar.sh → Settings → Webhooks\n2. Click on your endpoint\n3. View delivery history and payloads\n4. Check response codes and errors\n\n### Common Issues\n\n**Webhook signature mismatch**\n- Ensure you're using the sandbox webhook secret\n- Locally, use the secret from `polar webhook listen --print-secret` and restart the dev server after changing `.env`\n- Check that the raw body is being passed (not parsed JSON)\n- Verify timestamp is within tolerance (5 minutes)\n\n**Checkout not completing**\n- Use test cards, not real cards\n- Check browser console for errors\n- Verify successUrl is correct\n\n**API returns 401**\n- Verify you're using sandbox token with sandbox API\n- Check token hasn't expired\n- Ensure token has required scopes\n\n### Enable Debug Logging\n\n```typescript\nconst polar = new Polar({\n accessToken: process.env.POLAR_ACCESS_TOKEN,\n server: \"sandbox\",\n // Enable debug mode if available\n});\n\n// Log all requests\npolar.checkouts.create({...}).then(console.log).catch(console.error);\n```\n\n## Test Checklist\n\nBefore going to production:\n\n- [ ] Checkout flow completes successfully\n- [ ] Webhooks received and processed\n- [ ] Subscription lifecycle works (create, cancel, revoke)\n- [ ] Benefits granted and revoked correctly\n- [ ] License key validation works\n- [ ] Error handling for declined payments\n- [ ] Customer portal accessible\n- [ ] Refund flow tested\n""---\nname: polar-testing\ndescription: |\n Guide for testing Polar payment integrations using the sandbox environment. Use this skill when: (1) Setting up the Polar sandbox for development; (2) Testing checkout flows without real payments; (3) Using Stripe test cards with Polar; (4) Writing integration tests for payment flows; (5) Testing webhooks locally with the Polar CLI (`polar webhook listen`, `polar webhook trigger`) and generating webhook fixtures; (6) Mocking Polar in unit tests; (7) Setting up CI/CD pipelines with Polar sandbox; (8) Debugging payment issues in sandbox.\n---\n\n# Polar Testing Guide\n\nTest Polar integrations safely using the sandbox environment - a fully isolated server where you can experiment without affecting production data or processing real payments.\n\n## Sandbox Environment\n\n### Access\n\n- **Dashboard**: https://sandbox.polar.sh\n- **API**: https://sandbox-api.polar.sh/v1\n\nThe sandbox is completely isolated from production. You need separate:\n- User account\n- Organization\n- Access tokens\n- Webhook endpoints\n\n### Setup Steps\n\n1. Go to https://sandbox.polar.sh/start\n2. Create a new account (or use \"Go to sandbox\" from org switcher)\n3. Create a test organization\n4. Generate an access token in Settings → Access Tokens, or with `polar auth token` once the CLI is signed in to the sandbox (see \"Install and sign in\" below)\n\n### SDK Configuration\n\nSet up the client as in `polar-integration` (\"Setup\") and pass `environment: \"sandbox\"`:\n\n```typescript\nimport { createPolar } from \"@polar-sh/sdk/2026-10\";\n\nconst polar = createPolar({\n accessToken: process.env.POLAR_ACCESS_TOKEN!,\n environment: \"sandbox\",\n});\n```\n\n```python\nimport os\n\nfrom polar.v2026_10 import Polar\n\npolar = Polar(os.environ[\"POLAR_ACCESS_TOKEN\"], environment=\"sandbox\")\n```\n\n### Sandbox Limitations\n\n- No real money is processed, and data is isolated from production\n- Customer-facing emails (receipts, renewal reminders) are only delivered to members of your organization; `you+test@example.com` aliases are accepted\n- The API allows 100 requests per minute, against 500 in production, which matters for large integration suites\n\n## Test Cards\n\nPolar uses Stripe for payment processing, so any of [Stripe's test cards](https://docs.stripe.com/testing#cards) work. Use a future expiry date and any CVC:\n\n| Card Number | Behavior |\n|-------------|----------|\n| 4242 4242 4242 4242 | Successful payment |\n| 4000 0000 0000 0002 | Generic decline |\n| 4000 0000 0000 9995 | Insufficient funds |\n| 4000 0027 6000 3184 | Requires 3D Secure authentication |\n\n## Local Webhook Testing\n\nUse the Polar CLI. It forwards your organization's webhook events straight to your local server: no tunnel, no public URL, and no webhook endpoint in the dashboard.\n\n### Install and sign in\n\n```bash\nnpm install -g @polar-sh/cli\n\npolar auth login --sandbox\n```\n\nFor other install methods, see the [CLI docs](https://polar.sh/docs/integrate/cli/introduction).\n\n`polar auth login` opens a browser, so ask the user to run it. Check the result with `polar auth whoami --json`: `environments` lists the signed-in environments and `organization` is the active one.\n\nWhere no browser is available (CI, remote machines, cloud agents), an organization access token replaces `auth login`:\n\n```bash\nexport POLAR_ACCESS_TOKEN=polar_oat_... # scopes: webhooks:read, webhooks:write, organizations:read\nexport POLAR_ENVIRONMENT=sandbox\n```\n\nIf `POLAR_ACCESS_TOKEN` is already exported in the shell, for example from the app's `.env`, the CLI uses it instead of the login.\n\n### Forward events to your app\n\n```bash\npolar webhook listen 3000/api/polar/webhook\n```\n\n`listen` takes a port, a `port/path`, or a full URL. It runs until stopped, so start it in its own terminal or as a background process and keep it running while you test. It forwards every webhook event for the organization, including the ones real sandbox activity produces, such as completing a test checkout. If the account has several organizations, pick one with `--org <id>` or `polar auth org`.\n\n### Set the webhook secret\n\n`listen` signs forwarded events with a local secret, not a dashboard endpoint's secret. Write it to `.env` without printing it:\n\n```bash\nsecret=$(polar webhook listen --print-secret) && echo \"POLAR_WEBHOOK_SECRET=$secret\" >> .env\n```\n\nIf `.env` already has a `POLAR_WEBHOOK_SECRET` line, replace it instead of appending, then restart the dev server. Deployed environments use the secret of their dashboard webhook endpoint.\n\n### Send test events\n\n```bash\npolar webhook trigger order.paid\n```\n\n`trigger` sends a sample event through the running `listen`. When your handler doesn't accept it (a non-2xx response, a connection error, or a redirect), it prints the handler's response body and exits with status 1. Read that output, fix the handler, and run the same command again until it exits with 0.\n\nA redirect usually means auth middleware is intercepting the webhook route. Exclude the route from it: Polar never follows redirects when delivering webhooks.\n\n```bash\npolar webhook trigger --list --json # every event you can send\npolar webhook trigger order.paid --override data.customer.email=jane@example.com\npolar webhook trigger order.paid --seed 7 # the same IDs on every run\n```\n\n`--override` takes `path=value` and can be repeated. The API rejects paths that don't exist in the payload.\n\n## Integration Testing\n\n### Test Checkout Flow\n\nUse an organization access token with the `products:write` and `checkouts:write` scopes. Organization tokens already imply the organization, so don't pass `organization_id`.\n\n```typescript\nimport { describe, it, expect, beforeAll } from \"vitest\";\nimport { createPolar } from \"@polar-sh/sdk/2026-10\";\n\nconst polar = createPolar({\n accessToken: process.env.POLAR_ACCESS_TOKEN!,\n environment: \"sandbox\",\n});\n\ndescribe(\"Polar Checkout\", () => {\n let testProductId: string;\n\n beforeAll(async () => {\n const product = await polar.products.create({\n name: \"Test Product\",\n prices: [{ amount_type: \"fixed\", price_amount: 1000, price_currency: \"usd\" }],\n });\n testProductId = product.id;\n });\n\n it(\"creates a checkout session\", async () => {\n const checkout = await polar.checkouts.create({\n products: [testProductId],\n success_url: \"http://localhost:3000/success\",\n customer_email: \"test@example.com\",\n });\n\n expect(checkout.status).toBe(\"open\");\n expect(checkout.url).toContain(\"sandbox\");\n });\n\n it(\"retrieves a checkout\", async () => {\n const checkout = await polar.checkouts.create({\n products: [testProductId],\n success_url: \"http://localhost:3000/success\",\n });\n\n const retrieved = await polar.checkouts.get(checkout.id);\n expect(retrieved.id).toBe(checkout.id);\n });\n});\n```\n\n### Test Webhook Handler\n\nGenerate fixtures from real payloads instead of writing them by hand. `--dry-run` prints the payload without sending it, so `listen` doesn't need to be running:\n\n```bash\nmkdir -p test/fixtures\npolar webhook trigger order.paid --dry-run --json --seed 1 > test/fixtures/order.paid.json\npolar webhook trigger subscription.created --dry-run --json --seed 1 > test/fixtures/subscription.created.json\n```\n\nSign them the way `polar webhook listen` does ([Standard Webhooks](https://www.standardwebhooks.com/)): an HMAC-SHA256 of `id.timestamp.body`, base64-encoded and keyed with the secret string. This test calls the webhook route from `polar-integration` Recipe 3 directly:\n\n```typescript\nimport { createHmac } from \"node:crypto\";\nimport { readFile } from \"node:fs/promises\";\nimport { beforeAll, describe, expect, it, vi } from \"vitest\";\n\nconst secret = \"test_webhook_secret\";\nlet POST: (request: Request) => Promise<Response>;\n\nbeforeAll(async () => {\n vi.stubEnv(\"POLAR_WEBHOOK_SECRET\", secret);\n ({ POST } = await import(\"../app/api/polar/webhook/route\"));\n});\n\nconst sign = (id: string, timestamp: number, body: string) =>\n `v1,${createHmac(\"sha256\", secret).update(`${id}.${timestamp}.${body}`).digest(\"base64\")}`;\n\nconst webhookRequest = (body: string, signature?: string) => {\n const id = \"msg_test\";\n const timestamp = Math.floor(Date.now() / 1000);\n return new Request(\"http://localhost/api/polar/webhook\", {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n \"webhook-id\": id,\n \"webhook-timestamp\": String(timestamp),\n \"webhook-signature\": signature ?? sign(id, timestamp, body),\n },\n body,\n });\n};\n\ndescribe(\"Polar webhook handler\", () => {\n it(\"accepts a signed order.paid event\", async () => {\n const body = await readFile(\"test/fixtures/order.paid.json\", \"utf8\");\n const response = await POST(webhookRequest(body));\n expect(response.status).toBe(202);\n });\n\n it(\"rejects an invalid signature\", async () => {\n const body = await readFile(\"test/fixtures/order.paid.json\", \"utf8\");\n const response = await POST(webhookRequest(body, \"v1,invalid\"));\n expect(response.status).toBe(403);\n });\n});\n```\n\n### Test License Key Validation\n\nValidation needs a token with `license_keys:write`. A valid key returns `status: \"granted\"`; an unknown key throws `ResourceNotFound`:\n\n```typescript\nimport { describe, it, expect } from \"vitest\";\nimport { createPolar, errors } from \"@polar-sh/sdk/2026-10\";\n\nconst polar = createPolar({\n accessToken: process.env.POLAR_ACCESS_TOKEN!,\n environment: \"sandbox\",\n});\nconst organization_id = process.env.POLAR_ORG_ID!;\n\ndescribe(\"License keys\", () => {\n it(\"validates a granted key\", async () => {\n const result = await polar.licenseKeys.validate({ key: process.env.TEST_LICENSE_KEY!, organization_id });\n expect(result.status).toBe(\"granted\");\n });\n\n it(\"rejects an unknown key\", async () => {\n await expect(\n polar.licenseKeys.validate({ key: \"INVALID-KEY\", organization_id }),\n ).rejects.toBeInstanceOf(errors.ResourceNotFound);\n });\n});\n```\n\n## Mocking Polar in Unit Tests\n\n### Mock SDK\n\nMock `createPolar` and keep the real `webhooks` and `errors` exports. Responses use the same `snake_case` fields as the API:\n\n```typescript\nimport { vi } from \"vitest\";\n\nvi.mock(\"@polar-sh/sdk/2026-10\", async (importOriginal) => ({\n ...(await importOriginal<typeof import(\"@polar-sh/sdk/2026-10\")>()),\n createPolar: () => ({\n checkouts: {\n create: vi.fn().mockResolvedValue({\n id: \"checkout_mock\",\n url: \"https://sandbox.polar.sh/checkout/mock\",\n status: \"open\",\n }),\n get: vi.fn().mockResolvedValue({ id: \"checkout_mock\", status: \"succeeded\" }),\n },\n customers: {\n getStateExternal: vi.fn().mockResolvedValue({\n active_subscriptions: [{ id: \"sub_mock\", status: \"active\", product_id: \"prod_mock\" }],\n granted_benefits: [],\n active_meters: [],\n }),\n },\n subscriptions: {\n revoke: vi.fn(),\n update: vi.fn(),\n },\n }),\n}));\n```\n\n### Mock Webhook Payloads\n\nLoad the fixtures generated with `polar webhook trigger <event> --dry-run --json` (see Test Webhook Handler). Hand-written payloads drift from the real shape, and `validateEvent` only checks the signature and event type, so the drift goes unnoticed.\n\n```typescript\nimport orderPaid from \"./fixtures/order.paid.json\";\nimport subscriptionCreated from \"./fixtures/subscription.created.json\";\n```\n\n## CI/CD Integration\n\n### GitHub Actions\n\n```yaml\nname: Test Polar Integration\n\non: [push, pull_request]\n\njobs:\n test:\n runs-on: ubuntu-latest\n\n env:\n POLAR_ACCESS_TOKEN: ${{ secrets.POLAR_SANDBOX_TOKEN }}\n POLAR_ORG_ID: ${{ secrets.POLAR_SANDBOX_ORG_ID }}\n\n steps:\n - uses: actions/checkout@v4\n\n - uses: actions/setup-node@v4\n with:\n node-version: \"22\"\n cache: \"npm\"\n\n - run: npm ci\n\n - name: Run unit tests\n run: npm run test:unit\n\n - name: Run integration tests\n run: npm run test:integration\n```\n\nIf CI also runs the Polar CLI, add `POLAR_ENVIRONMENT: sandbox`.\n\n### Environment Setup\n\nCreate sandbox credentials specifically for CI:\n\n1. Create a dedicated sandbox organization for CI\n2. Create an organization access token for it with only the scopes the tests need (`polar auth token --scope products:write --scope checkouts:write` opens the token page with them preselected)\n3. Commit the webhook fixtures; handler tests sign them with a test secret and need no webhook endpoint\n4. Store the token and organization ID in GitHub Secrets\n\n## Debugging Tips\n\n### Check Webhook Delivery\n\nLocally, `polar webhook listen` logs every forwarded event with your handler's status, and `polar webhook trigger` prints the handler's response body when it fails.\n\nFor a deployed endpoint:\n\n1. Go to sandbox.polar.sh → Settings → Webhooks\n2. Click on your endpoint\n3. View delivery history and payloads\n4. Check response codes and errors\n\n### Common Issues\n\n**Webhook signature mismatch**\n- Ensure you're using the sandbox webhook secret\n- Locally, use the secret from `polar webhook listen --print-secret` and restart the dev server after changing `.env`\n- Check that the raw body is being passed (not parsed JSON)\n- Verify timestamp is within tolerance (5 minutes)\n\n**Checkout not completing**\n- Use test cards, not real cards\n- Check browser console for errors\n- Verify `success_url` is correct\n\n**API returns 401**\n- Verify you're using a sandbox token with the sandbox API\n- Check the token hasn't expired or been revoked\n\n**API returns 403**\n- The token is missing a scope the endpoint needs\n\n### Inspect API Errors\n\nThe SDK throws typed errors with the HTTP status and response body:\n\n```typescript\nimport { PolarClientError } from \"@polar-sh/sdk\";\n\ntry {\n await polar.checkouts.create({ products: [productId], success_url: \"http://localhost:3000/success\" });\n} catch (error) {\n if (error instanceof PolarClientError) console.error(error.statusCode, error.error);\n throw error;\n}\n```\n\n## Test Checklist\n\nBefore going to production:\n\n- [ ] Checkout flow completes successfully\n- [ ] Webhooks received and processed\n- [ ] Subscription lifecycle works (create, cancel, revoke)\n- [ ] Benefits granted and revoked correctly\n- [ ] License key validation works\n- [ ] Error handling for declined payments\n- [ ] Customer portal accessible\n- [ ] Refund flow tested\n"Full snapshot data
{
"description": "Guide for testing Polar payment integrations using the sandbox environment. Use this skill when: (1) Setting up the Polar sandbox for development; (2) Testing checkout flows without real payments; (3) Using Stripe test cards with Polar; (4) Writing integration tests for payment flows; (5) Testing webhooks locally with the Polar CLI (`polar webhook listen`, `polar webhook trigger`) and generating webhook fixtures; (6) Mocking Polar in unit tests; (7) Setting up CI/CD pipelines with Polar sandbox; (8) Debugging payment issues in sandbox.\n",
"included_files": [],
"name": "polar-testing",
"skill_md_contents": "---\nname: polar-testing\ndescription: |\n Guide for testing Polar payment integrations using the sandbox environment. Use this skill when: (1) Setting up the Polar sandbox for development; (2) Testing checkout flows without real payments; (3) Using Stripe test cards with Polar; (4) Writing integration tests for payment flows; (5) Testing webhooks locally with the Polar CLI (`polar webhook listen`, `polar webhook trigger`) and generating webhook fixtures; (6) Mocking Polar in unit tests; (7) Setting up CI/CD pipelines with Polar sandbox; (8) Debugging payment issues in sandbox.\n---\n\n# Polar Testing Guide\n\nTest Polar integrations safely using the sandbox environment - a fully isolated server where you can experiment without affecting production data or processing real payments.\n\n## Sandbox Environment\n\n### Access\n\n- **Dashboard**: https://sandbox.polar.sh\n- **API**: https://sandbox-api.polar.sh/v1\n\nThe sandbox is completely isolated from production. You need separate:\n- User account\n- Organization\n- Access tokens\n- Webhook endpoints\n\n### Setup Steps\n\n1. Go to https://sandbox.polar.sh/start\n2. Create a new account (or use \"Go to sandbox\" from org switcher)\n3. Create a test organization\n4. Generate an access token in Settings → Access Tokens, or with `polar auth token` once the CLI is signed in to the sandbox (see \"Install and sign in\" below)\n\n### SDK Configuration\n\nSet up the client as in `polar-integration` (\"Setup\") and pass `environment: \"sandbox\"`:\n\n```typescript\nimport { createPolar } from \"@polar-sh/sdk/2026-10\";\n\nconst polar = createPolar({\n accessToken: process.env.POLAR_ACCESS_TOKEN!,\n environment: \"sandbox\",\n});\n```\n\n```python\nimport os\n\nfrom polar.v2026_10 import Polar\n\npolar = Polar(os.environ[\"POLAR_ACCESS_TOKEN\"], environment=\"sandbox\")\n```\n\n### Sandbox Limitations\n\n- No real money is processed, and data is isolated from production\n- Customer-facing emails (receipts, renewal reminders) are only delivered to members of your organization; `you+test@example.com` aliases are accepted\n- The API allows 100 requests per minute, against 500 in production, which matters for large integration suites\n\n## Test Cards\n\nPolar uses Stripe for payment processing, so any of [Stripe's test cards](https://docs.stripe.com/testing#cards) work. Use a future expiry date and any CVC:\n\n| Card Number | Behavior |\n|-------------|----------|\n| 4242 4242 4242 4242 | Successful payment |\n| 4000 0000 0000 0002 | Generic decline |\n| 4000 0000 0000 9995 | Insufficient funds |\n| 4000 0027 6000 3184 | Requires 3D Secure authentication |\n\n## Local Webhook Testing\n\nUse the Polar CLI. It forwards your organization's webhook events straight to your local server: no tunnel, no public URL, and no webhook endpoint in the dashboard.\n\n### Install and sign in\n\n```bash\nnpm install -g @polar-sh/cli\n\npolar auth login --sandbox\n```\n\nFor other install methods, see the [CLI docs](https://polar.sh/docs/integrate/cli/introduction).\n\n`polar auth login` opens a browser, so ask the user to run it. Check the result with `polar auth whoami --json`: `environments` lists the signed-in environments and `organization` is the active one.\n\nWhere no browser is available (CI, remote machines, cloud agents), an organization access token replaces `auth login`:\n\n```bash\nexport POLAR_ACCESS_TOKEN=polar_oat_... # scopes: webhooks:read, webhooks:write, organizations:read\nexport POLAR_ENVIRONMENT=sandbox\n```\n\nIf `POLAR_ACCESS_TOKEN` is already exported in the shell, for example from the app's `.env`, the CLI uses it instead of the login.\n\n### Forward events to your app\n\n```bash\npolar webhook listen 3000/api/polar/webhook\n```\n\n`listen` takes a port, a `port/path`, or a full URL. It runs until stopped, so start it in its own terminal or as a background process and keep it running while you test. It forwards every webhook event for the organization, including the ones real sandbox activity produces, such as completing a test checkout. If the account has several organizations, pick one with `--org <id>` or `polar auth org`.\n\n### Set the webhook secret\n\n`listen` signs forwarded events with a local secret, not a dashboard endpoint's secret. Write it to `.env` without printing it:\n\n```bash\nsecret=$(polar webhook listen --print-secret) && echo \"POLAR_WEBHOOK_SECRET=$secret\" >> .env\n```\n\nIf `.env` already has a `POLAR_WEBHOOK_SECRET` line, replace it instead of appending, then restart the dev server. Deployed environments use the secret of their dashboard webhook endpoint.\n\n### Send test events\n\n```bash\npolar webhook trigger order.paid\n```\n\n`trigger` sends a sample event through the running `listen`. When your handler doesn't accept it (a non-2xx response, a connection error, or a redirect), it prints the handler's response body and exits with status 1. Read that output, fix the handler, and run the same command again until it exits with 0.\n\nA redirect usually means auth middleware is intercepting the webhook route. Exclude the route from it: Polar never follows redirects when delivering webhooks.\n\n```bash\npolar webhook trigger --list --json # every event you can send\npolar webhook trigger order.paid --override data.customer.email=jane@example.com\npolar webhook trigger order.paid --seed 7 # the same IDs on every run\n```\n\n`--override` takes `path=value` and can be repeated. The API rejects paths that don't exist in the payload.\n\n## Integration Testing\n\n### Test Checkout Flow\n\nUse an organization access token with the `products:write` and `checkouts:write` scopes. Organization tokens already imply the organization, so don't pass `organization_id`.\n\n```typescript\nimport { describe, it, expect, beforeAll } from \"vitest\";\nimport { createPolar } from \"@polar-sh/sdk/2026-10\";\n\nconst polar = createPolar({\n accessToken: process.env.POLAR_ACCESS_TOKEN!,\n environment: \"sandbox\",\n});\n\ndescribe(\"Polar Checkout\", () => {\n let testProductId: string;\n\n beforeAll(async () => {\n const product = await polar.products.create({\n name: \"Test Product\",\n prices: [{ amount_type: \"fixed\", price_amount: 1000, price_currency: \"usd\" }],\n });\n testProductId = product.id;\n });\n\n it(\"creates a checkout session\", async () => {\n const checkout = await polar.checkouts.create({\n products: [testProductId],\n success_url: \"http://localhost:3000/success\",\n customer_email: \"test@example.com\",\n });\n\n expect(checkout.status).toBe(\"open\");\n expect(checkout.url).toContain(\"sandbox\");\n });\n\n it(\"retrieves a checkout\", async () => {\n const checkout = await polar.checkouts.create({\n products: [testProductId],\n success_url: \"http://localhost:3000/success\",\n });\n\n const retrieved = await polar.checkouts.get(checkout.id);\n expect(retrieved.id).toBe(checkout.id);\n });\n});\n```\n\n### Test Webhook Handler\n\nGenerate fixtures from real payloads instead of writing them by hand. `--dry-run` prints the payload without sending it, so `listen` doesn't need to be running:\n\n```bash\nmkdir -p test/fixtures\npolar webhook trigger order.paid --dry-run --json --seed 1 > test/fixtures/order.paid.json\npolar webhook trigger subscription.created --dry-run --json --seed 1 > test/fixtures/subscription.created.json\n```\n\nSign them the way `polar webhook listen` does ([Standard Webhooks](https://www.standardwebhooks.com/)): an HMAC-SHA256 of `id.timestamp.body`, base64-encoded and keyed with the secret string. This test calls the webhook route from `polar-integration` Recipe 3 directly:\n\n```typescript\nimport { createHmac } from \"node:crypto\";\nimport { readFile } from \"node:fs/promises\";\nimport { beforeAll, describe, expect, it, vi } from \"vitest\";\n\nconst secret = \"test_webhook_secret\";\nlet POST: (request: Request) => Promise<Response>;\n\nbeforeAll(async () => {\n vi.stubEnv(\"POLAR_WEBHOOK_SECRET\", secret);\n ({ POST } = await import(\"../app/api/polar/webhook/route\"));\n});\n\nconst sign = (id: string, timestamp: number, body: string) =>\n `v1,${createHmac(\"sha256\", secret).update(`${id}.${timestamp}.${body}`).digest(\"base64\")}`;\n\nconst webhookRequest = (body: string, signature?: string) => {\n const id = \"msg_test\";\n const timestamp = Math.floor(Date.now() / 1000);\n return new Request(\"http://localhost/api/polar/webhook\", {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n \"webhook-id\": id,\n \"webhook-timestamp\": String(timestamp),\n \"webhook-signature\": signature ?? sign(id, timestamp, body),\n },\n body,\n });\n};\n\ndescribe(\"Polar webhook handler\", () => {\n it(\"accepts a signed order.paid event\", async () => {\n const body = await readFile(\"test/fixtures/order.paid.json\", \"utf8\");\n const response = await POST(webhookRequest(body));\n expect(response.status).toBe(202);\n });\n\n it(\"rejects an invalid signature\", async () => {\n const body = await readFile(\"test/fixtures/order.paid.json\", \"utf8\");\n const response = await POST(webhookRequest(body, \"v1,invalid\"));\n expect(response.status).toBe(403);\n });\n});\n```\n\n### Test License Key Validation\n\nValidation needs a token with `license_keys:write`. A valid key returns `status: \"granted\"`; an unknown key throws `ResourceNotFound`:\n\n```typescript\nimport { describe, it, expect } from \"vitest\";\nimport { createPolar, errors } from \"@polar-sh/sdk/2026-10\";\n\nconst polar = createPolar({\n accessToken: process.env.POLAR_ACCESS_TOKEN!,\n environment: \"sandbox\",\n});\nconst organization_id = process.env.POLAR_ORG_ID!;\n\ndescribe(\"License keys\", () => {\n it(\"validates a granted key\", async () => {\n const result = await polar.licenseKeys.validate({ key: process.env.TEST_LICENSE_KEY!, organization_id });\n expect(result.status).toBe(\"granted\");\n });\n\n it(\"rejects an unknown key\", async () => {\n await expect(\n polar.licenseKeys.validate({ key: \"INVALID-KEY\", organization_id }),\n ).rejects.toBeInstanceOf(errors.ResourceNotFound);\n });\n});\n```\n\n## Mocking Polar in Unit Tests\n\n### Mock SDK\n\nMock `createPolar` and keep the real `webhooks` and `errors` exports. Responses use the same `snake_case` fields as the API:\n\n```typescript\nimport { vi } from \"vitest\";\n\nvi.mock(\"@polar-sh/sdk/2026-10\", async (importOriginal) => ({\n ...(await importOriginal<typeof import(\"@polar-sh/sdk/2026-10\")>()),\n createPolar: () => ({\n checkouts: {\n create: vi.fn().mockResolvedValue({\n id: \"checkout_mock\",\n url: \"https://sandbox.polar.sh/checkout/mock\",\n status: \"open\",\n }),\n get: vi.fn().mockResolvedValue({ id: \"checkout_mock\", status: \"succeeded\" }),\n },\n customers: {\n getStateExternal: vi.fn().mockResolvedValue({\n active_subscriptions: [{ id: \"sub_mock\", status: \"active\", product_id: \"prod_mock\" }],\n granted_benefits: [],\n active_meters: [],\n }),\n },\n subscriptions: {\n revoke: vi.fn(),\n update: vi.fn(),\n },\n }),\n}));\n```\n\n### Mock Webhook Payloads\n\nLoad the fixtures generated with `polar webhook trigger <event> --dry-run --json` (see Test Webhook Handler). Hand-written payloads drift from the real shape, and `validateEvent` only checks the signature and event type, so the drift goes unnoticed.\n\n```typescript\nimport orderPaid from \"./fixtures/order.paid.json\";\nimport subscriptionCreated from \"./fixtures/subscription.created.json\";\n```\n\n## CI/CD Integration\n\n### GitHub Actions\n\n```yaml\nname: Test Polar Integration\n\non: [push, pull_request]\n\njobs:\n test:\n runs-on: ubuntu-latest\n\n env:\n POLAR_ACCESS_TOKEN: ${{ secrets.POLAR_SANDBOX_TOKEN }}\n POLAR_ORG_ID: ${{ secrets.POLAR_SANDBOX_ORG_ID }}\n\n steps:\n - uses: actions/checkout@v4\n\n - uses: actions/setup-node@v4\n with:\n node-version: \"22\"\n cache: \"npm\"\n\n - run: npm ci\n\n - name: Run unit tests\n run: npm run test:unit\n\n - name: Run integration tests\n run: npm run test:integration\n```\n\nIf CI also runs the Polar CLI, add `POLAR_ENVIRONMENT: sandbox`.\n\n### Environment Setup\n\nCreate sandbox credentials specifically for CI:\n\n1. Create a dedicated sandbox organization for CI\n2. Create an organization access token for it with only the scopes the tests need (`polar auth token --scope products:write --scope checkouts:write` opens the token page with them preselected)\n3. Commit the webhook fixtures; handler tests sign them with a test secret and need no webhook endpoint\n4. Store the token and organization ID in GitHub Secrets\n\n## Debugging Tips\n\n### Check Webhook Delivery\n\nLocally, `polar webhook listen` logs every forwarded event with your handler's status, and `polar webhook trigger` prints the handler's response body when it fails.\n\nFor a deployed endpoint:\n\n1. Go to sandbox.polar.sh → Settings → Webhooks\n2. Click on your endpoint\n3. View delivery history and payloads\n4. Check response codes and errors\n\n### Common Issues\n\n**Webhook signature mismatch**\n- Ensure you're using the sandbox webhook secret\n- Locally, use the secret from `polar webhook listen --print-secret` and restart the dev server after changing `.env`\n- Check that the raw body is being passed (not parsed JSON)\n- Verify timestamp is within tolerance (5 minutes)\n\n**Checkout not completing**\n- Use test cards, not real cards\n- Check browser console for errors\n- Verify `success_url` is correct\n\n**API returns 401**\n- Verify you're using a sandbox token with the sandbox API\n- Check the token hasn't expired or been revoked\n\n**API returns 403**\n- The token is missing a scope the endpoint needs\n\n### Inspect API Errors\n\nThe SDK throws typed errors with the HTTP status and response body:\n\n```typescript\nimport { PolarClientError } from \"@polar-sh/sdk\";\n\ntry {\n await polar.checkouts.create({ products: [productId], success_url: \"http://localhost:3000/success\" });\n} catch (error) {\n if (error instanceof PolarClientError) console.error(error.statusCode, error.error);\n throw error;\n}\n```\n\n## Test Checklist\n\nBefore going to production:\n\n- [ ] Checkout flow completes successfully\n- [ ] Webhooks received and processed\n- [ ] Subscription lifecycle works (create, cancel, revoke)\n- [ ] Benefits granted and revoked correctly\n- [ ] License key validation works\n- [ ] Error handling for declined payments\n- [ ] Customer portal accessible\n- [ ] Refund flow tested\n"
}SHA-256 of public snapshot: d31e2d8df40b5f33af1517457db3540a98d965136530ba0d351db0b35738605e