polar-integration

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Polar SDK integration

Polar SDK 集成

A standalone guide to wiring up Polar's three core HTTP endpoints — Checkout, Customer Portal, and Webhooks — using
@polar-sh/sdk
directly. The recipes are framework-agnostic Web Standards (
Request
/
Response
); each section also lists the small idiomatic adjustments per framework.
本独立指南介绍如何直接使用
@polar-sh/sdk
配置Polar的三个核心HTTP端点——Checkout、Customer Portal和Webhooks。示例基于Web标准(
Request
/
Response
)实现,同时列出了各框架下的适配调整方案。

Setup

设置

Install the SDK:
bash
npm install @polar-sh/sdk
安装SDK:
bash
npm install @polar-sh/sdk

or pnpm / yarn / bun

或使用pnpm / yarn / bun


Don't install `@polar-sh/<framework>` packages (e.g. `@polar-sh/nextjs`, `@polar-sh/express`, `@polar-sh/hono`, etc.) — they are deprecated. `@polar-sh/sdk` is all you need for these recipes.

Required environment variables (use whatever loader your framework provides — `process.env`, `Deno.env`, `import.meta.env`, etc.):

- `POLAR_ACCESS_TOKEN` — organization access token from the Polar dashboard.
- `POLAR_WEBHOOK_SECRET` — only needed for the webhook recipe.
- `POLAR_SERVER` — set to `sandbox` while developing, omit (or `production`) in prod.

Construct one client and reuse it:

```ts
import { Polar } from "@polar-sh/sdk";

const polar = new Polar({
  accessToken: process.env.POLAR_ACCESS_TOKEN,
  server: (process.env.POLAR_SERVER as "sandbox" | "production") ?? "production",
});
The three recipes below are independent — implement only the ones the user asks for.


请勿安装`@polar-sh/<framework>`类包(如`@polar-sh/nextjs`、`@polar-sh/express`、`@polar-sh/hono`等)——这些包已废弃。完成以下方案仅需`@polar-sh/sdk`即可。

所需环境变量(使用框架提供的任意加载器——`process.env`、`Deno.env`、`import.meta.env`等):

- `POLAR_ACCESS_TOKEN` — 从Polar控制台获取的组织访问令牌。
- `POLAR_WEBHOOK_SECRET` — 仅在实现Webhook方案时需要。
- `POLAR_SERVER` — 开发环境设置为`sandbox`,生产环境可省略(或设置为`production`)。

创建一个客户端实例并复用:

```ts
import { Polar } from "@polar-sh/sdk";

const polar = new Polar({
  accessToken: process.env.POLAR_ACCESS_TOKEN,
  server: (process.env.POLAR_SERVER as "sandbox" | "production") ?? "production",
});
以下三个方案相互独立——仅实现用户需要的部分即可。

Recipe 1 — Checkout endpoint

方案1 — Checkout端点

Goal: an HTTP endpoint that creates a Polar checkout session and 302-redirects the browser to the hosted checkout page.
Underlying API:
POST /v1/checkouts/
(SDK:
polar.checkouts.create(...)
). Full schema: https://polar.sh/docs/api-reference/checkouts/create-session.
目标:创建一个HTTP端点,用于生成Polar结账会话并通过302重定向将浏览器引导至托管结账页面。
底层API
POST /v1/checkouts/
(SDK方法:
polar.checkouts.create(...)
)。完整Schema:https://polar.sh/docs/api-reference/checkouts/create-session

polar.checkouts.create()
field reference

polar.checkouts.create()
字段参考

The SDK uses
camelCase
; the underlying HTTP API uses
snake_case
. Pick whichever you see in the user's existing code.
SDK field (camelCase)API field (snake_case)RequiredNotes
products
products
yesArray of product UUIDs. The first is selected by default.
customerId
customer_id
noExisting Polar customer ID.
externalCustomerId
external_customer_id
noYour own user ID. If no Polar customer matches, one is created with this external ID.
customerEmail
customer_email
no
customerName
customer_name
no
customerBillingName
customer_billing_name
no
customerBillingAddress
customer_billing_address
no
{ country: "US", line1, line2, city, state, postal_code }
.
country
(ISO alpha-2) is required if address is set.
customerTaxId
customer_tax_id
no
customerIpAddress
customer_ip_address
no
customerMetadata
customer_metadata
noCopied to the created customer.
isBusinessCustomer
is_business_customer
noDefault
false
. If
true
, full billing address + name required.
metadata
metadata
noCopied to the resulting order/subscription.
customFieldData
custom_field_data
noMap of custom field slug → value.
discountId
discount_id
no
allowDiscountCodes
allow_discount_codes
noDefault
true
.
requireBillingAddress
require_billing_address
noDefault
false
. US customers always required regardless.
seats
seats
noPredefined number of seats (seat-based pricing only).
minSeats
/
maxSeats
min_seats
/
max_seats
noSeat-based pricing only.
amount
amount
noCents. Only used for
custom
(pay-what-you-want) prices.
subscriptionId
subscription_id
noUpgrade an existing free subscription.
successUrl
success_url
noSee below.
returnUrl
return_url
noBack-button URL on the checkout page.
embedOrigin
embed_origin
noSet when embedding the checkout in an iframe.
locale
locale
noIETF BCP 47 (
en
,
en-US
,
fr-CA
, ...). Defaults to
en-US
.
currency
currency
noISO 4217 (e.g.
usd
,
eur
).
trialInterval
/
trialIntervalCount
trial_interval
/
trial_interval_count
noOverride product trial.
allowTrial
allow_trial
noDefault
true
. Set
false
to disable an otherwise-configured trial.
prices
prices
noMap of product ID → ad-hoc prices (override catalog prices).
Response:
{ id, url, client_secret, expires_at, status, ... }
. Redirect the user to
url
.
client_secret
is only needed if you are embedding the checkout via
@polar-sh/checkout
.
SDK使用驼峰式命名;底层HTTP API使用蛇形命名。可根据用户现有代码选择对应格式。
SDK字段(驼峰式)API字段(蛇形命名)必填说明
products
products
产品UUID数组。默认选中第一个产品。
customerId
customer_id
已存在的Polar客户ID。
externalCustomerId
external_customer_id
自定义用户ID。如果没有匹配的Polar客户,将以此外部ID创建新客户。
customerEmail
customer_email
customerName
customer_name
customerBillingName
customer_billing_name
customerBillingAddress
customer_billing_address
{ country: "US", line1, line2, city, state, postal_code }
。若设置地址,
country
(ISO alpha-2格式)为必填项。
customerTaxId
customer_tax_id
customerIpAddress
customer_ip_address
customerMetadata
customer_metadata
会复制到创建的客户信息中。
isBusinessCustomer
is_business_customer
默认
false
。若设为
true
,需填写完整账单地址和名称。
metadata
metadata
会复制到生成的订单/订阅信息中。
customFieldData
custom_field_data
自定义字段slug→值的映射。
discountId
discount_id
allowDiscountCodes
allow_discount_codes
默认
true
requireBillingAddress
require_billing_address
默认
false
。美国客户始终需要填写账单地址。
seats
seats
预定义席位数量(仅适用于按席位定价的产品)。
minSeats
/
maxSeats
min_seats
/
max_seats
仅适用于按席位定价的产品。
amount
amount
单位为分。仅适用于
custom
(随你付)定价模式。
subscriptionId
subscription_id
升级现有免费订阅。
successUrl
success_url
详见下文。
returnUrl
return_url
结账页面上的返回按钮URL。
embedOrigin
embed_origin
在iframe中嵌入结账页面时设置。
locale
locale
IETF BCP 47格式(如
en
en-US
fr-CA
等)。默认
en-US
currency
currency
ISO 4217格式(如
usd
eur
)。
trialInterval
/
trialIntervalCount
trial_interval
/
trial_interval_count
覆盖产品默认试用设置。
allowTrial
allow_trial
默认
true
。设为
false
可禁用已配置的试用。
prices
prices
产品ID→临时价格的映射(覆盖目录定价)。
响应
{ id, url, client_secret, expires_at, status, ... }
。将用户重定向至
url
。仅当通过
@polar-sh/checkout
嵌入结账页面时才需要
client_secret

Notes on specific fields

特定字段说明

  • successUrl
    and
    {CHECKOUT_ID}
    .
    Polar performs a literal string substitution: any
    {CHECKOUT_ID}
    token in the URL is replaced with the actual session ID at redirect time. The official API docs example uses
    ?checkout_id={CHECKOUT_ID}
    ; you can use any param name you want. Useful when your success page needs to fetch the session via
    polar.checkouts.get(checkoutId)
    .
  • theme
    (UI hint, not an API field).
    The hosted checkout UI accepts
    ?theme=light|dark
    in the URL. To apply a theme, append it to
    result.url
    after creating the session — it's not a field on
    polar.checkouts.create()
    .
  • successUrl
    {CHECKOUT_ID}
    :Polar会执行字面字符串替换:URL中的任何
    {CHECKOUT_ID}
    令牌都会在重定向时替换为实际会话ID。官方API文档示例使用
    ?checkout_id={CHECKOUT_ID}
    ;你可以使用任意参数名称。当成功页面需要通过
    polar.checkouts.get(checkoutId)
    获取会话信息时,此功能非常有用。
  • theme
    (UI提示,非API字段)
    :托管结账UI支持在URL中添加
    ?theme=light|dark
    参数。要应用主题,请在创建会话后将其追加到
    result.url
    中——它不是
    polar.checkouts.create()
    的字段。

Canonical implementation (Web Standards — Request/Response)

标准实现(Web标准 — Request/Response)

Works as-is in Deno, Bun, Cloudflare Workers, Supabase Edge Functions, SvelteKit, Remix, TanStack Start, Astro endpoints, and Next.js Route Handlers (NextRequest extends Request).
ts
import { Polar } from "@polar-sh/sdk";

const polar = new Polar({
  accessToken: process.env.POLAR_ACCESS_TOKEN,
  server: "production",
});

const SUCCESS_URL = "https://example.com/success?checkoutId={CHECKOUT_ID}";
const THEME: "light" | "dark" | undefined = undefined;

export async function GET(request: Request): Promise<Response> {
  const url = new URL(request.url);
  const products = url.searchParams.getAll("products");

  if (products.length === 0) {
    return Response.json(
      { error: "Missing products in query params" },
      { status: 400 },
    );
  }

  const json = (key: string) =>
    url.searchParams.has(key)
      ? JSON.parse(url.searchParams.get(key) ?? "{}")
      : undefined;
  const str = (key: string) => url.searchParams.get(key) ?? undefined;

  try {
    const result = await polar.checkouts.create({
      products,
      successUrl: SUCCESS_URL,
      customerId: str("customerId"),
      externalCustomerId: str("customerExternalId"),
      customerEmail: str("customerEmail"),
      customerName: str("customerName"),
      customerBillingAddress: json("customerBillingAddress"),
      customerTaxId: str("customerTaxId"),
      customerIpAddress: str("customerIpAddress"),
      customerMetadata: json("customerMetadata"),
      metadata: json("metadata"),
      discountId: str("discountId"),
      allowDiscountCodes: url.searchParams.has("allowDiscountCodes")
        ? url.searchParams.get("allowDiscountCodes") === "true"
        : undefined,
      seats: url.searchParams.has("seats")
        ? Number.parseInt(url.searchParams.get("seats") ?? "1", 10)
        : undefined,
    });

    const redirectUrl = new URL(result.url);
    if (THEME) redirectUrl.searchParams.set("theme", THEME);

    return Response.redirect(redirectUrl.toString(), 302);
  } catch (error) {
    console.error(error);
    return new Response(null, { status: 500 });
  }
}
This handler accepts a GET request with
?products=<product_id>
and forwards optional customer-prefill params straight to
polar.checkouts.create()
. Drop fields the caller won't ever provide; add fields from the table above (
metadata
,
embedOrigin
,
locale
, ...) as needed for your use case. If your frontend is server-rendered and already knows the user, hardcode
externalCustomerId
from your auth context instead of reading it from the query.
可直接在Deno、Bun、Cloudflare Workers、Supabase Edge Functions、SvelteKit、Remix、TanStack Start、Astro端点和Next.js路由处理器(NextRequest继承自Request)中使用。
ts
import { Polar } from "@polar-sh/sdk";

const polar = new Polar({
  accessToken: process.env.POLAR_ACCESS_TOKEN,
  server: "production",
});

const SUCCESS_URL = "https://example.com/success?checkoutId={CHECKOUT_ID}";
const THEME: "light" | "dark" | undefined = undefined;

export async function GET(request: Request): Promise<Response> {
  const url = new URL(request.url);
  const products = url.searchParams.getAll("products");

  if (products.length === 0) {
    return Response.json(
      { error: "查询参数中缺少products" },
      { status: 400 },
    );
  }

  const json = (key: string) =>
    url.searchParams.has(key)
      ? JSON.parse(url.searchParams.get(key) ?? "{}")
      : undefined;
  const str = (key: string) => url.searchParams.get(key) ?? undefined;

  try {
    const result = await polar.checkouts.create({
      products,
      successUrl: SUCCESS_URL,
      customerId: str("customerId"),
      externalCustomerId: str("customerExternalId"),
      customerEmail: str("customerEmail"),
      customerName: str("customerName"),
      customerBillingAddress: json("customerBillingAddress"),
      customerTaxId: str("customerTaxId"),
      customerIpAddress: str("customerIpAddress"),
      customerMetadata: json("customerMetadata"),
      metadata: json("metadata"),
      discountId: str("discountId"),
      allowDiscountCodes: url.searchParams.has("allowDiscountCodes")
        ? url.searchParams.get("allowDiscountCodes") === "true"
        : undefined,
      seats: url.searchParams.has("seats")
        ? Number.parseInt(url.searchParams.get("seats") ?? "1", 10)
        : undefined,
    });

    const redirectUrl = new URL(result.url);
    if (THEME) redirectUrl.searchParams.set("theme", THEME);

    return Response.redirect(redirectUrl.toString(), 302);
  } catch (error) {
    console.error(error);
    return new Response(null, { status: 500 });
  }
}
该处理函数接受带有
?products=<product_id>
的GET请求,并将可选的客户预填参数直接转发给
polar.checkouts.create()
。可删除调用方永远不会提供的字段;根据需求添加上表中的字段(如
metadata
embedOrigin
locale
等)。如果前端是服务端渲染且已获取用户信息,可从认证上下文直接硬编码
externalCustomerId
,而非从查询参数读取。

Framework variations

框架适配方案

The body of the handler is identical — only the signature, URL access, and response constructors change.
Next.js (App Router): put the canonical handler in
app/api/checkout/route.ts
.
NextRequest
/
NextResponse
are optional;
request: Request
works.
Express:
ts
import type { Request, Response } from "express";

app.get("/checkout", async (req: Request, res: Response) => {
  const url = new URL(`${req.protocol}://${req.get("host")}${req.originalUrl}`);
  // ... same body using `url.searchParams` ...
  // res.redirect(redirectUrl.toString());
  // res.status(400).json({ error: "..." });
});
Fastify:
ts
fastify.get("/checkout", async (request, reply) => {
  const url = new URL(`${request.protocol}://${request.hostname}${request.url}`);
  // ... same body ...
  // reply.redirect(redirectUrl.toString());
});
Hono:
ts
app.get("/checkout", async (c) => {
  const url = new URL(c.req.url);
  // ... same body ...
  return c.redirect(redirectUrl.toString());
});
Elysia:
ts
app.get("/checkout", ({ request, redirect }) => {
  const url = new URL(request.url);
  // ... same body, `return redirect(redirectUrl.toString())` ...
});
Nuxt (server route):
server/api/checkout.get.ts
using
defineEventHandler
,
getQuery
,
sendRedirect(event, url, 302)
.
SvelteKit:
src/routes/api/checkout/+server.ts
exporting
GET({ request, url })
— the canonical handler works directly.
Astro:
src/pages/api/checkout.ts
exporting
export const GET: APIRoute = async ({ request }) => { ... }
.

处理函数的核心逻辑完全相同——仅签名、URL访问方式和响应构造器有所不同。
Next.js(App Router):将标准处理函数放入
app/api/checkout/route.ts
NextRequest
/
NextResponse
为可选;使用
request: Request
即可。
Express
ts
import type { Request, Response } from "express";

app.get("/checkout", async (req: Request, res: Response) => {
  const url = new URL(`${req.protocol}://${req.get("host")}${req.originalUrl}`);
  // ... 使用`url.searchParams`的核心逻辑相同 ...
  // res.redirect(redirectUrl.toString());
  // res.status(400).json({ error: "..." });
});
Fastify
ts
fastify.get("/checkout", async (request, reply) => {
  const url = new URL(`${request.protocol}://${request.hostname}${request.url}`);
  // ... 核心逻辑相同 ...
  // reply.redirect(redirectUrl.toString());
});
Hono
ts
app.get("/checkout", async (c) => {
  const url = new URL(c.req.url);
  // ... 核心逻辑相同 ...
  return c.redirect(redirectUrl.toString());
});
Elysia
ts
app.get("/checkout", ({ request, redirect }) => {
  const url = new URL(request.url);
  // ... 核心逻辑相同,返回`return redirect(redirectUrl.toString())` ...
});
Nuxt(服务端路由):在
server/api/checkout.get.ts
中使用
defineEventHandler
getQuery
sendRedirect(event, url, 302)
SvelteKit:在
src/routes/api/checkout/+server.ts
中导出
GET({ request, url })
——标准处理函数可直接使用。
Astro:在
src/pages/api/checkout.ts
中导出
export const GET: APIRoute = async ({ request }) => { ... }

Recipe 2 — Customer Portal endpoint

方案2 — Customer Portal端点

Goal: an authenticated endpoint that creates a Polar customer session and 302-redirects to the hosted customer portal (subscriptions, invoices, payment methods).
Underlying API:
POST /v1/customer-sessions/
(SDK:
polar.customerSessions.create(...)
). Full schema: https://polar.sh/docs/api-reference/customer-portal/sessions/create.
目标:创建一个认证端点,用于生成Polar客户会话并通过302重定向至托管客户门户(管理订阅、发票、支付方式)。
底层API
POST /v1/customer-sessions/
(SDK方法:
polar.customerSessions.create(...)
)。完整Schema:https://polar.sh/docs/api-reference/customer-portal/sessions/create

polar.customerSessions.create()
field reference

polar.customerSessions.create()
字段参考

The request body is a union — pass either
customerId
or
externalCustomerId
, not both.
SDK fieldAPI fieldRequiredNotes
customerId
customer_id
one of these twoExisting Polar customer UUID.
externalCustomerId
external_customer_id
one of these twoYour own user ID.
returnUrl
return_url
noBack-button URL on the portal page.
memberId
member_id
noOnly for orgs with
member_model_enabled
. Polar customer member UUID. Defaults to the owner member for individual customers.
externalMemberId
external_member_id
noYour member ID, alternative to
memberId
.
Response:
{ id, customer_portal_url, token, expires_at, customer, customer_id, return_url, ... }
. Redirect the user to
customer_portal_url
.
token
and
customer
are useful if you want to render portal data inside your own UI instead of redirecting.
请求体为联合类型——仅需传递
customerId
externalCustomerId
其中一个
,不可同时传递。
SDK字段API字段必填说明
customerId
customer_id
二选一已存在的Polar客户UUID。
externalCustomerId
external_customer_id
二选一自定义用户ID。
returnUrl
return_url
门户页面上的返回按钮URL。
memberId
member_id
仅适用于启用
member_model_enabled
的组织。Polar客户成员UUID。个人客户默认使用所有者成员。
externalMemberId
external_member_id
自定义成员ID,作为
memberId
的替代方案。
响应
{ id, customer_portal_url, token, expires_at, customer, customer_id, return_url, ... }
。将用户重定向至
customer_portal_url
。如果希望在自有UI中渲染门户数据而非重定向,
token
customer
字段会很有用。

Canonical implementation

标准实现

ts
import { Polar } from "@polar-sh/sdk";

const polar = new Polar({
  accessToken: process.env.POLAR_ACCESS_TOKEN,
  server: "production",
});

const RETURN_URL = "https://example.com/account";

// Replace with your auth: read a cookie/JWT/session and return your user id.
async function getExternalCustomerId(request: Request): Promise<string | null> {
  const session = await getSession(request); // your auth helper
  return session?.userId ?? null;
}

export async function GET(request: Request): Promise<Response> {
  const externalCustomerId = await getExternalCustomerId(request);
  if (!externalCustomerId) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  try {
    const { customerPortalUrl } = await polar.customerSessions.create({
      externalCustomerId,
      returnUrl: RETURN_URL,
    });
    return Response.redirect(customerPortalUrl, 302);
  } catch (error) {
    console.error(error);
    return new Response(null, { status: 500 });
  }
}
If you have a Polar customer ID instead of your own user ID, swap
externalCustomerId
for
customerId
in the
polar.customerSessions.create({ ... })
call.
externalCustomerId
is usually what you want — your auth system already knows the user's ID.
ts
import { Polar } from "@polar-sh/sdk";

const polar = new Polar({
  accessToken: process.env.POLAR_ACCESS_TOKEN,
  server: "production",
});

const RETURN_URL = "https://example.com/account";

// 替换为你的认证逻辑:读取cookie/JWT/会话并返回用户ID。
async function getExternalCustomerId(request: Request): Promise<string | null> {
  const session = await getSession(request); // 你的认证工具函数
  return session?.userId ?? null;
}

export async function GET(request: Request): Promise<Response> {
  const externalCustomerId = await getExternalCustomerId(request);
  if (!externalCustomerId) {
    return Response.json({ error: "未授权" }, { status: 401 });
  }

  try {
    const { customerPortalUrl } = await polar.customerSessions.create({
      externalCustomerId,
      returnUrl: RETURN_URL,
    });
    return Response.redirect(customerPortalUrl, 302);
  } catch (error) {
    console.error(error);
    return new Response(null, { status: 500 });
  }
}
如果你已有Polar客户ID而非自定义用户ID,可将
polar.customerSessions.create({ ... })
中的
externalCustomerId
替换为
customerId
。通常
externalCustomerId
是更优选择——你的认证系统已知晓用户ID。

Framework variations

框架适配方案

Same shape as Checkout — only the request/response idioms change. Read auth state however your framework expects (Next.js:
cookies()
from
next/headers
, Express:
req.user
, Hono:
c.get('user')
, etc.) and pass the result into
polar.customerSessions.create
.

与Checkout方案结构相同——仅请求/响应的写法有所不同。按照框架的方式读取认证状态(Next.js:
next/headers
中的
cookies()
,Express:
req.user
,Hono:
c.get('user')
等),并将结果传入
polar.customerSessions.create

Recipe 3 — Webhooks endpoint

方案3 — Webhooks端点

Goal: receive Polar webhook events, verify the signature, and dispatch to per-event handlers.
Contract: POST endpoint at a stable URL configured in the Polar dashboard. Verifies the
webhook-id
,
webhook-timestamp
,
webhook-signature
headers using
validateEvent
from
@polar-sh/sdk/webhooks
. Returns 200 on success, 403 on signature mismatch.
目标:接收Polar Webhook事件,验证签名,并分发给对应事件的处理函数。
约定:在Polar控制台配置的稳定URL上创建POST端点。使用
@polar-sh/sdk/webhooks
中的
validateEvent
验证
webhook-id
webhook-timestamp
webhook-signature
请求头。验证成功返回200,签名不匹配返回403。

Canonical implementation

标准实现

ts
import { validateEvent, WebhookVerificationError } from "@polar-sh/sdk/webhooks";

const WEBHOOK_SECRET = process.env.POLAR_WEBHOOK_SECRET!;

export async function POST(request: Request): Promise<Response> {
  const body = await request.text(); // must be the raw body, not parsed JSON

  let event: ReturnType<typeof validateEvent>;
  try {
    event = validateEvent(
      body,
      {
        "webhook-id": request.headers.get("webhook-id") ?? "",
        "webhook-timestamp": request.headers.get("webhook-timestamp") ?? "",
        "webhook-signature": request.headers.get("webhook-signature") ?? "",
      },
      WEBHOOK_SECRET,
    );
  } catch (error) {
    if (error instanceof WebhookVerificationError) {
      return Response.json({ received: false }, { status: 403 });
    }
    throw error;
  }

  switch (event.type) {
    case "checkout.created":
    case "checkout.updated":
      // event.data is the Checkout
      break;

    case "order.created":
    case "order.updated":
    case "order.paid":
    case "order.refunded":
      // event.data is the Order — fulfill, send receipt, etc.
      break;

    case "subscription.created":
    case "subscription.updated":
    case "subscription.active":
    case "subscription.canceled":
    case "subscription.uncanceled":
    case "subscription.revoked":
      // event.data is the Subscription — flip entitlements in your DB
      break;

    case "refund.created":
    case "refund.updated":
      break;

    case "product.created":
    case "product.updated":
      break;

    case "benefit.created":
    case "benefit.updated":
      break;

    case "benefit_grant.created":
    case "benefit_grant.updated":
    case "benefit_grant.revoked":
      // grant or revoke the user's access to a benefit
      break;

    case "customer.created":
    case "customer.updated":
    case "customer.deleted":
    case "customer.state_changed":
      break;

    case "organization.updated":
      break;
  }

  return Response.json({ received: true });
}
event
is fully typed — TypeScript will narrow
event.data
inside each
case
.
ts
import { validateEvent, WebhookVerificationError } from "@polar-sh/sdk/webhooks";

const WEBHOOK_SECRET = process.env.POLAR_WEBHOOK_SECRET!;

export async function POST(request: Request): Promise<Response> {
  const body = await request.text(); // 必须使用原始请求体,而非解析后的JSON

  let event: ReturnType<typeof validateEvent>;
  try {
    event = validateEvent(
      body,
      {
        "webhook-id": request.headers.get("webhook-id") ?? "",
        "webhook-timestamp": request.headers.get("webhook-timestamp") ?? "",
        "webhook-signature": request.headers.get("webhook-signature") ?? "",
      },
      WEBHOOK_SECRET,
    );
  } catch (error) {
    if (error instanceof WebhookVerificationError) {
      return Response.json({ received: false }, { status: 403 });
    }
    throw error;
  }

  switch (event.type) {
    case "checkout.created":
    case "checkout.updated":
      // event.data为Checkout对象
      break;

    case "order.created":
    case "order.updated":
    case "order.paid":
    case "order.refunded":
      // event.data为Order对象——执行履约、发送收据等操作
      break;

    case "subscription.created":
    case "subscription.updated":
    case "subscription.active":
    case "subscription.canceled":
    case "subscription.uncanceled":
    case "subscription.revoked":
      // event.data为Subscription对象——在数据库中更新用户权限
      break;

    case "refund.created":
    case "refund.updated":
      break;

    case "product.created":
    case "product.updated":
      break;

    case "benefit.created":
    case "benefit.updated":
      break;

    case "benefit_grant.created":
    case "benefit_grant.updated":
    case "benefit_grant.revoked":
      // 授予或撤销用户的权益访问权限
      break;

    case "customer.created":
    case "customer.updated":
    case "customer.deleted":
    case "customer.state_changed":
      break;

    case "organization.updated":
      break;
  }

  return Response.json({ received: true });
}
event
为强类型——TypeScript会在每个
case
中自动推断
event.data
的类型。

Critical details

关键注意事项

  1. Use the raw request body for
    validateEvent
    . If your framework parsed JSON for you (Express's
    express.json()
    , Fastify's default body parser), disable it on this route or read the raw body manually. Signature verification fails on re-serialized JSON.
  2. Respond fast. Polar retries on non-2xx and on timeouts. If a handler is slow (sending email, syncing inventory), enqueue it (queue/cron/background job) and return 200 immediately.
  3. Idempotency. Polar may redeliver. Deduplicate on the
    webhook-id
    header — it's unique per delivery and reused on retries (Standard Webhooks spec). Don't dedupe on
    event.data.id
    : that's the resource ID and is shared across distinct events about the same resource (e.g.
    order.created
    and
    order.paid
    ).
  1. 使用原始请求体进行
    validateEvent
    验证。如果框架已自动解析JSON(如Express的
    express.json()
    、Fastify的默认体解析器),请在此路由上禁用该功能,或手动读取原始请求体。重新序列化的JSON会导致签名验证失败。
  2. 快速响应。Polar会对非2xx响应和超时进行重试。如果处理函数执行缓慢(如发送邮件、同步库存),请将其放入队列(队列/定时任务/后台任务)并立即返回200。
  3. 幂等性。Polar可能会重复投递事件。请根据
    webhook-id
    请求头进行去重——每个投递的
    webhook-id
    都是唯一的,重试时会复用该值(符合Webhooks标准规范)。请勿根据
    event.data.id
    去重:这是资源ID,同一资源的不同事件(如
    order.created
    order.paid
    )会共享该ID。

Framework variations

框架适配方案

Next.js Route Handler: drop the canonical handler into
app/api/polar/webhook/route.ts
. App Router does not auto-parse the body, so
request.text()
is correct.
Express: mount with the raw body parser on this route only:
ts
app.post("/polar/webhook", express.raw({ type: "application/json" }), async (req, res) => {
  const body = (req.body as Buffer).toString("utf8");
  // ... validateEvent / switch ...
  res.json({ received: true });
});
Fastify: add a content-type parser that preserves the raw body, or use
request.rawBody
with
@fastify/raw-body
.
Hono:
await c.req.text()
returns the raw body, and
c.req.header("webhook-id")
reads headers.
Elysia:
await request.text()
on the standard
Request
.
SvelteKit: in
+server.ts
,
await event.request.text()
.
Astro: in
APIRoute
,
await request.text()
.
Nuxt:
await readRawBody(event)
from h3.

  • Don't install
    @polar-sh/<framework>
    packages (e.g.
    @polar-sh/express
    ) — they are deprecated.
    @polar-sh/sdk
    is all you need for these recipes. The only exceptions are
    @polar-sh/nextjs
    for Next.js App Router and
    @polar-sh/better-auth
    for Better Auth.
Next.js路由处理器:将标准处理函数放入
app/api/polar/webhook/route.ts
。App Router不会自动解析请求体,因此
request.text()
是正确的用法。
Express:仅在此路由上挂载原始体解析器:
ts
app.post("/polar/webhook", express.raw({ type: "application/json" }), async (req, res) => {
  const body = (req.body as Buffer).toString("utf8");
  // ... validateEvent / switch逻辑 ...
  res.json({ received: true });
});
Fastify:添加保留原始请求体的内容类型解析器,或使用
@fastify/raw-body
获取
request.rawBody
Hono
await c.req.text()
返回原始请求体,
c.req.header("webhook-id")
读取请求头。
Elysia:在标准
Request
上使用
await request.text()
SvelteKit:在
+server.ts
中使用
await event.request.text()
Astro:在
APIRoute
中使用
await request.text()
Nuxt:使用h3的
await readRawBody(event)

  • 请勿安装
    @polar-sh/<framework>
    类包(如
    @polar-sh/express
    )——这些包已废弃。完成以下方案仅需
    @polar-sh/sdk
    即可。唯一例外是Next.js App Router可使用
    @polar-sh/nextjs
    ,Better Auth可使用
    @polar-sh/better-auth