這是一個 side project,我沒有開公司。前半寫這個前提下,收信用卡訂閱有哪幾條路,以及為什麼是 Paddle。後半是錢進來之後我這邊的程式:一條 webhook。
使用者按下付款、填卡、完成交易,那些全部發生在 Paddle 那邊;我要做的只是收到通知,然後把某個工作區標記成付費。
但那個端點的每一行都有理由,而且大部分理由來自同一件事:你不能假設這個通知只會來一次、會照順序來、或者一定來得到。
要收的是全球使用者的信用卡訂閱,一個月六美元。我沒有公司,所以先看不用開公司也能走的路,再看為了這件事值不值得去開一間。
第一條是 Stripe,但開戶這關過不了。Stripe 的支援國家清單上沒有台灣。亞太列了日本、新加坡、香港、馬來西亞、泰國。台灣人要用,實務上得先在美國或新加坡有一間公司。為了一個 side project 的六美元訂閱先去開海外公司,順序反了。這條先放下。
第二條是國內金流,個人就能申請,不必開公司。以藍新為例,個人會員申請得到,也有定期定額,技術上收得了信用卡。它的設計對象是台灣買家付台幣:國外卡要另外申請開啟,多幣別支援有限,個人會員的信用卡三十天交易額度是二十萬台幣。賣方還是我。賣到德國就要處理德國的數位消費稅,賣到五十個國家就有五十個地方要申報。國內金流連稅都不算,因為它本來就不是為了向全球收費設計的。
第三條是 Merchant of Record。法律上是使用者跟那家公司買軟體,那家公司再把錢結算給我。全球的稅務申報是他們的責任,我每個月收到一筆結算款。一樣不必先開公司。我實際比過四家:
| 方案 | 沒有公司能不能用 | 賣方 | 稅 | 這次 |
|---|---|---|---|---|
| Stripe | 不能。要先在美國或新加坡開公司 | 你 | 你自己向各國申報 | 沒走 |
| 藍新 | 能。個人會員 | 你 | 你自己申報。國外的數位稅他們不算 | 沒走。對象是台灣買家付台幣 |
| Lemon Squeezy | 不必先開公司 | 他們 | 他們申報 | 沒走。有遷移風險 |
| Polar | 不必先開公司 | 他們 | 他們申報 | 沒走。年輕 |
| FastSpring | 不必先開公司 | 他們 | 他們申報 | 沒走。偏中大型客戶 |
| Paddle | 能。台灣個人送審 | 他們 | 他們申報 | 選這家 |
最後選 Paddle,是因為它同時涵蓋這幾個條件:個人從台灣送審就行、訂閱是他們的主場、文件一個人看得完,而且稅不用我向每個國家申報。Lemon Squeezy 有遷移風險,Polar 太早,FastSpring 不是這種規模的客戶。代價是手續費從約 3% 變成 5% 加一筆固定額。如果把開公司、自己做訂閱管理、自己算稅、跨境卡費全部加回去,差距沒有表面上大。
定價:訂閱不含模型費。 使用者用自己的 API key,我只收軟體服務費。理由很現實:編纂跟健檢都很吃 token,如果我包在訂閱裡,一個重度使用者就能把利潤吃光。而且自帶 key 已經是這類工具的常態。
要做的是把這件事講清楚:定價頁明寫「模型費另計,一般使用者每月大約一到三美元」,並且產品裡有真實的費用統計佐證。含糊其辭的定價會在使用者收到帳單那天變成客訴。
後台要先過網站審核與身份驗證,才能收真的卡。我沒有公司,送審的是個人身份。Paddle 的開通說明寫在他們那邊,畫面會改,這裡不逐步截。審核過了之後,我用真卡走完一次結帳,才確定 webhook 真的會打進來。
app.post('/api/billing/webhook/paddle', byIp, express.raw({ type: '*/*', limit: '1mb' }), async (req, res) => {
const secret = process.env.PADDLE_WEBHOOK_SECRET?.trim();
if (!secret) { res.status(503).json({ error: 'NOT_CONFIGURED', … }); return; }
const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.from('');
if (!verifySignature(raw, req.header('paddle-signature'), secret)) {
ops()?.noteSigFail();
res.status(401).json({ error: 'UNAUTHORIZED', message: 'bad signature' }); return;
}
let evt: PaddleWebhook;
try { evt = JSON.parse(raw.toString('utf8')); }
catch { res.status(400).json({ error: 'BAD_REQUEST', message: 'invalid JSON' }); return; }
const c = ops()?.counters; if (c) c.paddleLastWebhook = { at: new Date().toISOString(), type: evt.event_type };
const ev = translateWebhook(evt);
if (!ev) { res.json({ ok: true, ignored: evt.event_type }); return; }
try {
const sub = await applySubscriptionEvent(ev);
res.json({ ok: true, status: sub.status });
} catch (e) {
console.error('paddle webhook failed:', e);
res.status(500).json({ error: 'INTERNAL', message: 'could not apply event' });
}
});
app.use(express.json({ limit: '2mb' }));
整個端點十幾行,底下一節一節講每一行擋的是什麼。
express.json 要掛在後面express.raw 掛在這條路由上,而 express.json 掛在它後面。順序不能反。
驗簽驗的是 Paddle 送出去的那一串位元組。如果 JSON 中介層先跑過,req.body 會變成物件,你手上就沒有原始的位元組了,重新 JSON.stringify 出來的字串,鍵的順序、空白、Unicode 跳脫都可能跟對方簽的不一樣,驗簽必定失敗,而且你會花很久才想到問題出在這裡。
Paddle 的標頭長這樣:Paddle-Signature: ts=1725000000;h1=abc123…
export function verifySignature(raw: string | Buffer, header: string | undefined, secret: string,
now = Date.now(), maxSkewMs = 5 * 60_000): boolean {
if (!header || !secret) return false;
const parts = Object.create(null) as Record<string, string[]>;
for (const kv of header.split(';')) {
const i = kv.indexOf('='); if (i < 0) continue;
(parts[kv.slice(0, i).trim()] ??= []).push(kv.slice(i + 1).trim());
}
const ts = Number(parts.ts?.[0]);
if (!Number.isFinite(ts) || Math.abs(now - ts * 1000) > maxSkewMs) return false;
const expected = createHmac('sha256', secret).update(`${ts}:`).update(raw).digest();
return (parts.h1 ?? []).some(h => {
const b = Buffer.from(h, 'hex');
return b.length === expected.length && timingSafeEqual(b, expected);
});
}
三個細節:
簽的是 ${ts}: 加上原始 body。 時間戳要一起進雜湊,否則攻擊者可以改時間戳重放。
時間戳有五分鐘的容許範圍。 過期的請求直接拒絕,重放攻擊的窗口就只有五分鐘。
比對用 timingSafeEqual,而且 h1 可以有多個。 前者防時序攻擊,用 === 比較雜湊會因為提早返回而洩漏「你猜對了幾個位元組」。後者是為了輪替金鑰:換 secret 的期間對方會同時送舊新兩個簽章,兩個對上任何一個就算通過,你才有機會在不中斷的情況下換掉。
Object.create(null) 也是刻意的,用普通物件當字典,標頭裡出現 __proto__=… 會讓你拿到不該拿的東西。
格式與欄位的定義以 Paddle 的簽章驗證文件為準,這段程式是照著它寫的。
這是整個設計的軸心。金流商判斷「這則通知有沒有成功送到」只看一件事:你有沒有回 2xx。
回 401、400、500、逾時、轉址,全部算失敗,全部會重試。Paddle 的重試規則寫得很清楚:正式環境的重試預算很大,而且處理超過五秒就當作沒送到。
兩個推論,而且它們違反直覺:
第一,簽章錯誤不要回 2xx。 直覺上「這是偽造的請求,我不想再收到」,所以想回 200 把它打發掉。但那會讓一個真實但暫時驗不過的事件被永久丟掉。最常見的情況是你剛輪替 secret 但還沒部署完,這段時間所有通知都驗不過。回 401 的話它們會在重試窗口內自己補回來;回 200 的話那幾筆訂閱狀態就永遠錯了。
第二,處理要快。這也是為什麼這個端點裡沒有寄信、沒有呼叫第三方、沒有去金流商的 API 反查。它只做一件事:把狀態寫進資料庫。任何比這更重的事情都應該丟到背景。
這是最容易寫出 bug 的地方。subscription.updated 可能比 subscription.created 先到,只要前者第一次送達失敗、進了重試佇列,後者就會超車。
如果你的處理是「收到什麼就寫什麼」,一個舊事件晚到就會把新狀態蓋回去,使用者剛升級完又變回免費。
解法寫在 upsert 的 WHERE 裡:
INSERT INTO subscriptions (workspace_id, provider, provider_customer_id, provider_subscription_id,
status, plan, current_period_end, raw, event_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (workspace_id) DO UPDATE SET provider = $2,
provider_customer_id = coalesce($3, subscriptions.provider_customer_id),
provider_subscription_id = coalesce($4, subscriptions.provider_subscription_id),
status = $5, plan = $6, current_period_end = $7, raw = $8,
event_at = coalesce($9, subscriptions.event_at), updated_at = now()
WHERE $9::timestamptz IS NULL
OR subscriptions.event_at IS NULL
OR $9::timestamptz >= subscriptions.event_at
RETURNING workspace_id, provider, status, plan, current_period_end, provider_subscription_id, updated_at
$9 是這一段的主角,它是事件自己宣稱的發生時間,不是你收到的時間。比現有的舊就不更新,RETURNING 回空的,上層據此知道這是一則遲到的事件:
if (rows.length === 0) return (await getSubscription(ev.workspace_id))!; // stale event: keep the newer state
注意它回的是目前的狀態而不是錯誤。遲到的事件不是失敗,它只是沒有新資訊。照樣回 2xx,對方就不會再送了。
金流商保證的是「至少送一次」,所以同一則事件會重複到達。常見的做法是開一張表記錄處理過的 event_id,收到重複的就跳過。
這裡不需要,因為這個 upsert 本身是收斂的:它寫的是「這個工作區現在的訂閱狀態」,不是「發生了一件事」。同一則事件處理十次,結果跟處理一次一樣。
分界線是:如果你的處理有非資料庫的副作用(寄一封收據信、發一次點數、呼叫一個按次計費的 API),那就得另外用 event_id 去重。狀態同步不用。
Paddle 那邊有 customer、subscription、transaction,我這邊只有工作區。兩邊要有一條線接起來,那條線是 checkout 時塞進去的 custom_data:
const PADDLE_STATUS: Record<string, SubscriptionStatus> =
{ active: 'active', trialing: 'trialing', past_due: 'past_due', paused: 'paused', canceled: 'canceled' };
export function translateWebhook(evt: PaddleWebhook): SubscriptionEvent | null {
if (!evt.event_type.startsWith('subscription.')) return null;
const custom = (evt.data.custom_data ?? {}) as Record<string, unknown>;
const workspace = typeof custom.workspace_id === 'string' ? custom.workspace_id : null;
if (!workspace) return null;
const status = PADDLE_STATUS[String(evt.data.status)];
if (!status) return null;
return { provider: 'paddle', workspace_id: workspace, status, plan: 'pro', … };
}
三個 return null 是三種「這則事件不干我的事」:不是訂閱事件(交易、客戶那些我不處理)、沒有帶工作區標記(不是從我的 checkout 來的)、狀態我不認得(對方加了新狀態)。
回 null 的事件照樣回 2xx,因為它確實送達了,只是我選擇不處理。訂閱了用不到的事件類型很正常,不要為此讓對方一直重試。
最後一步是把金流商的狀態翻譯成「這個人能不能用付費功能」:
const PAID = new Set<SubscriptionStatus>(['active', 'trialing', 'past_due']);
const next = PAID.has(ev.status) ? 'pro' : 'free';
出處:src/billing.ts:27 與 src/billing.ts:40
past_due 算付費,這是刻意的。那個狀態的意思是「這期的錢還沒收到,正在重試」,可能只是卡片過期。在這段期間把人降級,等於因為一張過期的卡就把他鎖在自己的資料外面,而金流商本來就會連續嘗試扣款好幾天。等它真的變成 canceled 或 paused 再降。
升級與降級都會記一筆事件,這樣漏斗上看得到轉換與流失。
端點裡有一行跟金流無關的東西:
ops()?.noteSigFail();
驗簽失敗會累加一個計數器,而之前我們已經講過,那個計數器會出現在 /healthz 的 degraded 陣列裡。
理由是這種壞法不會有人跟你反應。使用者付了錢、金流商那邊顯示成功、他回到產品卻還是免費方案,他會覺得是網站有問題,不見得會寫信給你。而你這邊什麼錯誤都沒有,因為請求根本沒進到業務邏輯。
一則簽章失敗可能是掃描器亂打,連續失敗幾乎一定是自己的 secret 設錯或忘了部署。
這是 side project,我沒有開公司。刷卡訂閱看過三條路:先開海外公司再用 Stripe、用藍新這種個人就能申請的國內金流、或交給 Merchant of Record。最後用 Paddle,個人從台灣送審,稅由他們申報,我每個月收一筆結算。手續費大約從 3% 變成 5% 加一筆固定額。訂閱只收軟體費,模型費用使用者自己的 key。