recur-portal
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseRecur Customer Portal Integration
Recur 客户门户集成
You are helping implement Recur's Customer Portal, which allows subscribers to self-manage their subscriptions without contacting support.
您将帮助实现Recur的客户门户,该门户允许订阅者无需联系支持人员即可自助管理其订阅。
What is Customer Portal?
什么是客户门户?
Customer Portal is a hosted page where your customers can:
- View active subscriptions and billing history
- Update payment methods
- Cancel or reactivate subscriptions
- Switch between plans (upgrade/downgrade)
客户门户是一个托管页面,您的客户可以在此:
- 查看有效订阅和账单记录
- 更新付款方式
- 取消或重新激活订阅
- 切换方案(升级/降级)
When to Use
适用场景
| Scenario | Solution |
|---|---|
| "Add account management page" | Create portal session and redirect |
| "Let users update their card" | Portal handles payment method updates |
| "Users need to cancel subscription" | Portal provides self-service cancellation |
| "Show billing history" | Portal displays invoices and payments |
| 场景 | 解决方案 |
|---|---|
| "添加账户管理页面" | 创建门户会话并跳转 |
| "让用户更新他们的银行卡" | 门户处理付款方式更新 |
| "用户需要取消订阅" | 门户提供自助取消功能 |
| "显示账单记录" | 门户展示发票和付款记录 |
Quick Start: Create Portal Session
快速开始:创建门户会话
Portal sessions are created server-side (requires Secret Key).
门户会话需在服务器端创建(需要Secret Key)。
Using Server SDK
使用Server SDK
typescript
import { Recur } from 'recur-tw/server'
const recur = new Recur(process.env.RECUR_SECRET_KEY!)
// Create portal session - identify customer by email, ID, or externalId
const session = await recur.portal.sessions.create({
email: 'customer@example.com', // or customer: 'cus_xxx' or externalId: 'user_123'
returnUrl: 'https://yourapp.com/account',
})
// Redirect customer to portal
redirect(session.url)typescript
import { Recur } from 'recur-tw/server'
const recur = new Recur(process.env.RECUR_SECRET_KEY!)
// Create portal session - identify customer by email, ID, or externalId
const session = await recur.portal.sessions.create({
email: 'customer@example.com', // or customer: 'cus_xxx' or externalId: 'user_123'
returnUrl: 'https://yourapp.com/account',
})
// Redirect customer to portal
redirect(session.url)Customer Identification
客户身份识别
You can identify customers using one of these methods (in priority order):
typescript
// By Recur customer ID (highest priority)
await recur.portal.sessions.create({
customer: 'cus_xxx',
returnUrl: 'https://yourapp.com/account',
})
// By your system's user ID
await recur.portal.sessions.create({
externalId: 'user_123',
returnUrl: 'https://yourapp.com/account',
})
// By email (lowest priority)
await recur.portal.sessions.create({
email: 'customer@example.com',
returnUrl: 'https://yourapp.com/account',
})您可以通过以下方式之一识别客户(按优先级排序):
typescript
// By Recur customer ID (highest priority)
await recur.portal.sessions.create({
customer: 'cus_xxx',
returnUrl: 'https://yourapp.com/account',
})
// By your system's user ID
await recur.portal.sessions.create({
externalId: 'user_123',
returnUrl: 'https://yourapp.com/account',
})
// By email (lowest priority)
await recur.portal.sessions.create({
email: 'customer@example.com',
returnUrl: 'https://yourapp.com/account',
})Next.js Implementation
Next.js 实现
API Route (App Router)
API路由(App Router)
typescript
// app/api/portal/route.ts
import { Recur } from 'recur-tw/server'
import { auth } from '@/lib/auth' // Your auth solution
import { NextResponse } from 'next/server'
const recur = new Recur(process.env.RECUR_SECRET_KEY!)
export async function POST() {
const session = await auth()
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const portalSession = await recur.portal.sessions.create({
email: session.user.email,
returnUrl: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
})
return NextResponse.json({ url: portalSession.url })
} catch (error) {
console.error('Portal session error:', error)
return NextResponse.json(
{ error: 'Failed to create portal session' },
{ status: 500 }
)
}
}typescript
// app/api/portal/route.ts
import { Recur } from 'recur-tw/server'
import { auth } from '@/lib/auth' // Your auth solution
import { NextResponse } from 'next/server'
const recur = new Recur(process.env.RECUR_SECRET_KEY!)
export async function POST() {
const session = await auth()
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const portalSession = await recur.portal.sessions.create({
email: session.user.email,
returnUrl: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
})
return NextResponse.json({ url: portalSession.url })
} catch (error) {
console.error('Portal session error:', error)
return NextResponse.json(
{ error: 'Failed to create portal session' },
{ status: 500 }
)
}
}Server Action
Server Action
typescript
// app/actions/portal.ts
'use server'
import { Recur } from 'recur-tw/server'
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
const recur = new Recur(process.env.RECUR_SECRET_KEY!)
export async function openPortal() {
const session = await auth()
if (!session?.user?.email) {
throw new Error('Unauthorized')
}
const portalSession = await recur.portal.sessions.create({
email: session.user.email,
returnUrl: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
})
redirect(portalSession.url)
}typescript
// app/actions/portal.ts
'use server'
import { Recur } from 'recur-tw/server'
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
const recur = new Recur(process.env.RECUR_SECRET_KEY!)
export async function openPortal() {
const session = await auth()
if (!session?.user?.email) {
throw new Error('Unauthorized')
}
const portalSession = await recur.portal.sessions.create({
email: session.user.email,
returnUrl: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
})
redirect(portalSession.url)
}Portal Button Component
门户按钮组件
tsx
// components/portal-button.tsx
'use client'
import { useState } from 'react'
export function PortalButton() {
const [isLoading, setIsLoading] = useState(false)
const handleClick = async () => {
setIsLoading(true)
try {
const response = await fetch('/api/portal', { method: 'POST' })
const { url, error } = await response.json()
if (error) throw new Error(error)
window.location.href = url
} catch (error) {
console.error('Failed to open portal:', error)
alert('無法開啟帳戶管理頁面,請稍後再試')
} finally {
setIsLoading(false)
}
}
return (
<button onClick={handleClick} disabled={isLoading}>
{isLoading ? '載入中...' : '管理訂閱'}
</button>
)
}tsx
// components/portal-button.tsx
'use client'
import { useState } from 'react'
export function PortalButton() {
const [isLoading, setIsLoading] = useState(false)
const handleClick = async () => {
setIsLoading(true)
try {
const response = await fetch('/api/portal', { method: 'POST' })
const { url, error } = await response.json()
if (error) throw new Error(error)
window.location.href = url
} catch (error) {
console.error('Failed to open portal:', error)
alert('无法开启账户管理页面,请稍后再试')
} finally {
setIsLoading(false)
}
}
return (
<button onClick={handleClick} disabled={isLoading}>
{isLoading ? '加载中...' : '管理订阅'}
</button>
)
}Using Server Action with Button
结合Server Action的按钮
tsx
// components/portal-button-action.tsx
'use client'
import { openPortal } from '@/app/actions/portal'
export function PortalButton() {
return (
<form action={openPortal}>
<button type="submit">管理訂閱</button>
</form>
)
}tsx
// components/portal-button-action.tsx
'use client'
import { openPortal } from '@/app/actions/portal'
export function PortalButton() {
return (
<form action={openPortal}>
<button type="submit">管理订阅</button>
</form>
)
}Portal Session Response
门户会话响应
typescript
interface PortalSession {
id: string // Session ID (e.g., 'portal_sess_xxx')
object: 'portal.session'
url: string // URL to redirect customer to
customer: string // Customer ID
returnUrl: string // URL to return after portal exit
status: 'active' | 'expired'
expiresAt: string // ISO 8601 (sessions last 1 hour)
accessedAt: string | null
createdAt: string
}typescript
interface PortalSession {
id: string // Session ID (e.g., 'portal_sess_xxx')
object: 'portal.session'
url: string // URL to redirect customer to
customer: string // Customer ID
returnUrl: string // URL to return after portal exit
status: 'active' | 'expired'
expiresAt: string // ISO 8601 (sessions last 1 hour)
accessedAt: string | null
createdAt: string
}Using REST API Directly
直接使用REST API
If not using the SDK:
typescript
const response = await fetch('https://api.recur.tw/v1/portal/sessions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.RECUR_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'customer@example.com',
return_url: 'https://yourapp.com/account',
}),
})
const { url } = await response.json()
// Redirect to url如果不使用SDK:
typescript
const response = await fetch('https://api.recur.tw/v1/portal/sessions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.RECUR_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'customer@example.com',
return_url: 'https://yourapp.com/account',
}),
})
const { url } = await response.json()
// Redirect to urlCommon Patterns
常见模式
Account Page with Portal Link
带门户链接的账户页面
tsx
// app/account/page.tsx
import { auth } from '@/lib/auth'
import { PortalButton } from '@/components/portal-button'
export default async function AccountPage() {
const session = await auth()
return (
<div>
<h1>帳戶設定</h1>
<p>Email: {session?.user?.email}</p>
<section>
<h2>訂閱管理</h2>
<p>管理您的訂閱、更新付款方式、查看帳單記錄</p>
<PortalButton />
</section>
</div>
)
}tsx
// app/account/page.tsx
import { auth } from '@/lib/auth'
import { PortalButton } from '@/components/portal-button'
export default async function AccountPage() {
const session = await auth()
return (
<div>
<h1>账户设置</h1>
<p>Email: {session?.user?.email}</p>
<section>
<h2>订阅管理</h2>
<p>管理您的订阅、更新付款方式、查看账单记录</p>
<PortalButton />
</section>
</div>
)
}Conditional Portal Access
条件式门户访问
tsx
// Only show portal button if user has subscription
function SubscriptionSection({ hasSubscription }: { hasSubscription: boolean }) {
if (!hasSubscription) {
return (
<div>
<p>您目前沒有訂閱</p>
<a href="/pricing">查看方案</a>
</div>
)
}
return (
<div>
<p>您目前的訂閱:Pro 方案</p>
<PortalButton />
</div>
)
}tsx
// Only show portal button if user has subscription
function SubscriptionSection({ hasSubscription }: { hasSubscription: boolean }) {
if (!hasSubscription) {
return (
<div>
<p>您目前没有订阅</p>
<a href="/pricing">查看方案</a>
</div>
)
}
return (
<div>
<p>您目前的订阅:Pro 方案</p>
<PortalButton />
</div>
)
}Portal Configuration
门户配置
Configure portal behavior in Recur Dashboard → Settings → Customer Portal:
- Default Return URL: Where to redirect after leaving portal
- Allowed Actions: Enable/disable cancel, update payment, switch plan
- Branding: Custom logo and colors
在Recur控制台 → 设置 → 客户门户中配置门户行为:
- 默认返回URL:离开门户后跳转的地址
- 允许的操作:启用/禁用取消、更新付款、切换方案功能
- 品牌定制:自定义Logo和颜色
Security Notes
安全注意事项
- Server-side only: Portal sessions require Secret Key (sk_xxx)
- Short-lived: Sessions expire in 1 hour
- One-time use: Each session URL should only be used once
- Verify user: Always authenticate the user before creating a portal session
- 仅服务器端使用:创建门户会话需要Secret Key(sk_xxx)
- 短期有效:会话1小时后过期
- 单次使用:每个会话URL应仅使用一次
- 验证用户:创建门户会话前务必对用户进行身份验证
Error Handling
错误处理
typescript
try {
const session = await recur.portal.sessions.create({
email: userEmail,
returnUrl: returnUrl,
})
redirect(session.url)
} catch (error) {
if (error.code === 'customer_not_found') {
// Customer doesn't exist in Recur
// Maybe they haven't subscribed yet
redirect('/pricing')
}
if (error.code === 'missing_return_url') {
// returnUrl is required
console.error('Missing return URL')
}
throw error
}typescript
try {
const session = await recur.portal.sessions.create({
email: userEmail,
returnUrl: returnUrl,
})
redirect(session.url)
} catch (error) {
if (error.code === 'customer_not_found') {
// Customer doesn't exist in Recur
// Maybe they haven't subscribed yet
redirect('/pricing')
}
if (error.code === 'missing_return_url') {
// returnUrl is required
console.error('Missing return URL')
}
throw error
}Related Skills
相关技能
- - Initial SDK setup
/recur-quickstart - - Implement purchase flows
/recur-checkout - - Check subscription access
/recur-entitlements
- - 初始SDK设置
/recur-quickstart - - 实现购买流程
/recur-checkout - - 检查订阅权限
/recur-entitlements