polar-integration
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChinesePolar SDK integration
Polar SDK 集成
A standalone guide to wiring up Polar's three core HTTP endpoints — Checkout, Customer Portal, and Webhooks — using directly. The recipes are framework-agnostic Web Standards (/); each section also lists the small idiomatic adjustments per framework.
@polar-sh/sdkRequestResponse本独立指南介绍如何直接使用配置Polar的三个核心HTTP端点——Checkout、Customer Portal和Webhooks。示例基于Web标准(/)实现,同时列出了各框架下的适配调整方案。
@polar-sh/sdkRequestResponseSetup
设置
Install the SDK:
bash
npm install @polar-sh/sdk安装SDK:
bash
npm install @polar-sh/sdkor 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: (SDK: ). Full schema: https://polar.sh/docs/api-reference/checkouts/create-session.
POST /v1/checkouts/polar.checkouts.create(...)目标:创建一个HTTP端点,用于生成Polar结账会话并通过302重定向将浏览器引导至托管结账页面。
底层API:(SDK方法:)。完整Schema:https://polar.sh/docs/api-reference/checkouts/create-session。
POST /v1/checkouts/polar.checkouts.create(...)polar.checkouts.create()
field reference
polar.checkouts.create()polar.checkouts.create()
字段参考
polar.checkouts.create()The SDK uses ; the underlying HTTP API uses . Pick whichever you see in the user's existing code.
camelCasesnake_case| SDK field (camelCase) | API field (snake_case) | Required | Notes |
|---|---|---|---|
| | yes | Array of product UUIDs. The first is selected by default. |
| | no | Existing Polar customer ID. |
| | no | Your own user ID. If no Polar customer matches, one is created with this external ID. |
| | no | |
| | no | |
| | no | |
| | no | |
| | no | |
| | no | |
| | no | Copied to the created customer. |
| | no | Default |
| | no | Copied to the resulting order/subscription. |
| | no | Map of custom field slug → value. |
| | no | |
| | no | Default |
| | no | Default |
| | no | Predefined number of seats (seat-based pricing only). |
| | no | Seat-based pricing only. |
| | no | Cents. Only used for |
| | no | Upgrade an existing free subscription. |
| | no | See below. |
| | no | Back-button URL on the checkout page. |
| | no | Set when embedding the checkout in an iframe. |
| | no | IETF BCP 47 ( |
| | no | ISO 4217 (e.g. |
| | no | Override product trial. |
| | no | Default |
| | no | Map of product ID → ad-hoc prices (override catalog prices). |
Response: . Redirect the user to . is only needed if you are embedding the checkout via .
{ id, url, client_secret, expires_at, status, ... }urlclient_secret@polar-sh/checkoutSDK使用驼峰式命名;底层HTTP API使用蛇形命名。可根据用户现有代码选择对应格式。
| SDK字段(驼峰式) | API字段(蛇形命名) | 必填 | 说明 |
|---|---|---|---|
| | 是 | 产品UUID数组。默认选中第一个产品。 |
| | 否 | 已存在的Polar客户ID。 |
| | 否 | 自定义用户ID。如果没有匹配的Polar客户,将以此外部ID创建新客户。 |
| | 否 | |
| | 否 | |
| | 否 | |
| | 否 | |
| | 否 | |
| | 否 | |
| | 否 | 会复制到创建的客户信息中。 |
| | 否 | 默认 |
| | 否 | 会复制到生成的订单/订阅信息中。 |
| | 否 | 自定义字段slug→值的映射。 |
| | 否 | |
| | 否 | 默认 |
| | 否 | 默认 |
| | 否 | 预定义席位数量(仅适用于按席位定价的产品)。 |
| | 否 | 仅适用于按席位定价的产品。 |
| | 否 | 单位为分。仅适用于 |
| | 否 | 升级现有免费订阅。 |
| | 否 | 详见下文。 |
| | 否 | 结账页面上的返回按钮URL。 |
| | 否 | 在iframe中嵌入结账页面时设置。 |
| | 否 | IETF BCP 47格式(如 |
| | 否 | ISO 4217格式(如 |
| | 否 | 覆盖产品默认试用设置。 |
| | 否 | 默认 |
| | 否 | 产品ID→临时价格的映射(覆盖目录定价)。 |
响应:。将用户重定向至。仅当通过嵌入结账页面时才需要。
{ id, url, client_secret, expires_at, status, ... }url@polar-sh/checkoutclient_secretNotes on specific fields
特定字段说明
- and
successUrl. 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}; you can use any param name you want. Useful when your success page needs to fetch the session via?checkout_id={CHECKOUT_ID}.polar.checkouts.get(checkoutId) - (UI hint, not an API field). The hosted checkout UI accepts
themein the URL. To apply a theme, append it to?theme=light|darkafter creating the session — it's not a field onresult.url.polar.checkouts.create()
- 与
successUrl:Polar会执行字面字符串替换:URL中的任何{CHECKOUT_ID}令牌都会在重定向时替换为实际会话ID。官方API文档示例使用{CHECKOUT_ID};你可以使用任意参数名称。当成功页面需要通过?checkout_id={CHECKOUT_ID}获取会话信息时,此功能非常有用。polar.checkouts.get(checkoutId) - (UI提示,非API字段):托管结账UI支持在URL中添加
theme参数。要应用主题,请在创建会话后将其追加到?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 and forwards optional customer-prefill params straight to . Drop fields the caller won't ever provide; add fields from the table above (, , , ...) as needed for your use case. If your frontend is server-rendered and already knows the user, hardcode from your auth context instead of reading it from the query.
?products=<product_id>polar.checkouts.create()metadataembedOriginlocaleexternalCustomerId可直接在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 });
}
}该处理函数接受带有的GET请求,并将可选的客户预填参数直接转发给。可删除调用方永远不会提供的字段;根据需求添加上表中的字段(如、、等)。如果前端是服务端渲染且已获取用户信息,可从认证上下文直接硬编码,而非从查询参数读取。
?products=<product_id>polar.checkouts.create()metadataembedOriginlocaleexternalCustomerIdFramework 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 . / are optional; works.
app/api/checkout/route.tsNextRequestNextResponserequest: RequestExpress:
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): using , , .
server/api/checkout.get.tsdefineEventHandlergetQuerysendRedirect(event, url, 302)SvelteKit: exporting — the canonical handler works directly.
src/routes/api/checkout/+server.tsGET({ request, url })Astro: exporting .
src/pages/api/checkout.tsexport const GET: APIRoute = async ({ request }) => { ... }处理函数的核心逻辑完全相同——仅签名、URL访问方式和响应构造器有所不同。
Next.js(App Router):将标准处理函数放入。/为可选;使用即可。
app/api/checkout/route.tsNextRequestNextResponserequest: RequestExpress:
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.tsdefineEventHandlergetQuerysendRedirect(event, url, 302)SvelteKit:在中导出——标准处理函数可直接使用。
src/routes/api/checkout/+server.tsGET({ request, url })Astro:在中导出。
src/pages/api/checkout.tsexport 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: (SDK: ). Full schema: https://polar.sh/docs/api-reference/customer-portal/sessions/create.
POST /v1/customer-sessions/polar.customerSessions.create(...)目标:创建一个认证端点,用于生成Polar客户会话并通过302重定向至托管客户门户(管理订阅、发票、支付方式)。
底层API:(SDK方法:)。完整Schema:https://polar.sh/docs/api-reference/customer-portal/sessions/create。
POST /v1/customer-sessions/polar.customerSessions.create(...)polar.customerSessions.create()
field reference
polar.customerSessions.create()polar.customerSessions.create()
字段参考
polar.customerSessions.create()The request body is a union — pass either or , not both.
customerIdexternalCustomerId| SDK field | API field | Required | Notes |
|---|---|---|---|
| | one of these two | Existing Polar customer UUID. |
| | one of these two | Your own user ID. |
| | no | Back-button URL on the portal page. |
| | no | Only for orgs with |
| | no | Your member ID, alternative to |
Response: . Redirect the user to . and are useful if you want to render portal data inside your own UI instead of redirecting.
{ id, customer_portal_url, token, expires_at, customer, customer_id, return_url, ... }customer_portal_urltokencustomer请求体为联合类型——仅需传递或其中一个,不可同时传递。
customerIdexternalCustomerId| SDK字段 | API字段 | 必填 | 说明 |
|---|---|---|---|
| | 二选一 | 已存在的Polar客户UUID。 |
| | 二选一 | 自定义用户ID。 |
| | 否 | 门户页面上的返回按钮URL。 |
| | 否 | 仅适用于启用 |
| | 否 | 自定义成员ID,作为 |
响应:。将用户重定向至。如果希望在自有UI中渲染门户数据而非重定向,和字段会很有用。
{ id, customer_portal_url, token, expires_at, customer, customer_id, return_url, ... }customer_portal_urltokencustomerCanonical 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 for in the call. is usually what you want — your auth system already knows the user's ID.
externalCustomerIdcustomerIdpolar.customerSessions.create({ ... })externalCustomerIdts
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,可将中的替换为。通常是更优选择——你的认证系统已知晓用户ID。
polar.customerSessions.create({ ... })externalCustomerIdcustomerIdexternalCustomerIdFramework variations
框架适配方案
Same shape as Checkout — only the request/response idioms change. Read auth state however your framework expects (Next.js: from , Express: , Hono: , etc.) and pass the result into .
cookies()next/headersreq.userc.get('user')polar.customerSessions.create与Checkout方案结构相同——仅请求/响应的写法有所不同。按照框架的方式读取认证状态(Next.js:中的,Express:,Hono:等),并将结果传入。
next/headerscookies()req.userc.get('user')polar.customerSessions.createRecipe 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 , , headers using from . Returns 200 on success, 403 on signature mismatch.
webhook-idwebhook-timestampwebhook-signaturevalidateEvent@polar-sh/sdk/webhooks目标:接收Polar Webhook事件,验证签名,并分发给对应事件的处理函数。
约定:在Polar控制台配置的稳定URL上创建POST端点。使用中的验证、、请求头。验证成功返回200,签名不匹配返回403。
@polar-sh/sdk/webhooksvalidateEventwebhook-idwebhook-timestampwebhook-signatureCanonical 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 });
}eventevent.datacasets
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 });
}eventcaseevent.dataCritical details
关键注意事项
- Use the raw request body for . If your framework parsed JSON for you (Express's
validateEvent, Fastify's default body parser), disable it on this route or read the raw body manually. Signature verification fails on re-serialized JSON.express.json() - 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.
- Idempotency. Polar may redeliver. Deduplicate on the header — it's unique per delivery and reused on retries (Standard Webhooks spec). Don't dedupe on
webhook-id: that's the resource ID and is shared across distinct events about the same resource (e.g.event.data.idandorder.created).order.paid
- 使用原始请求体进行验证。如果框架已自动解析JSON(如Express的
validateEvent、Fastify的默认体解析器),请在此路由上禁用该功能,或手动读取原始请求体。重新序列化的JSON会导致签名验证失败。express.json() - 快速响应。Polar会对非2xx响应和超时进行重试。如果处理函数执行缓慢(如发送邮件、同步库存),请将其放入队列(队列/定时任务/后台任务)并立即返回200。
- 幂等性。Polar可能会重复投递事件。请根据请求头进行去重——每个投递的
webhook-id都是唯一的,重试时会复用该值(符合Webhooks标准规范)。请勿根据webhook-id去重:这是资源ID,同一资源的不同事件(如event.data.id和order.created)会共享该ID。order.paid
Framework variations
框架适配方案
Next.js Route Handler: drop the canonical handler into . App Router does not auto-parse the body, so is correct.
app/api/polar/webhook/route.tsrequest.text()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 with .
request.rawBody@fastify/raw-bodyHono: returns the raw body, and reads headers.
await c.req.text()c.req.header("webhook-id")Elysia: on the standard .
await request.text()RequestSvelteKit: in , .
+server.tsawait event.request.text()Astro: in , .
APIRouteawait request.text()Nuxt: from h3.
await readRawBody(event)- Don't install packages (e.g.
@polar-sh/<framework>) — they are deprecated.@polar-sh/expressis all you need for these recipes. The only exceptions are@polar-sh/sdkfor Next.js App Router and@polar-sh/nextjsfor Better Auth.@polar-sh/better-auth
Next.js路由处理器:将标准处理函数放入。App Router不会自动解析请求体,因此是正确的用法。
app/api/polar/webhook/route.tsrequest.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-bodyrequest.rawBodyHono:返回原始请求体,读取请求头。
await c.req.text()c.req.header("webhook-id")Elysia:在标准上使用。
Requestawait request.text()SvelteKit:在中使用。
+server.tsawait event.request.text()Astro:在中使用。
APIRouteawait request.text()Nuxt:使用h3的。
await readRawBody(event)- 请勿安装类包(如
@polar-sh/<framework>)——这些包已废弃。完成以下方案仅需@polar-sh/express即可。唯一例外是Next.js App Router可使用@polar-sh/sdk,Better Auth可使用@polar-sh/nextjs。@polar-sh/better-auth