recur-skills 0.0.11 → 0.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/recur-checkout/SKILL.md +2 -2
- package/skills/recur-entitlements/SKILL.md +140 -21
- package/skills/recur-help/SKILL.md +1 -1
- package/skills/recur-portal/SKILL.md +1 -1
- package/skills/recur-quickstart/SKILL.md +1 -1
- package/skills/recur-webhooks/SKILL.md +47 -12
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
|
3
3
|
"name": "recur-skills",
|
|
4
4
|
"description": "Claude Code skills for integrating Recur — Taiwan's subscription payment platform",
|
|
5
|
-
"version": "0.0.
|
|
5
|
+
"version": "0.0.13",
|
|
6
6
|
"owner": {
|
|
7
7
|
"name": "Recur",
|
|
8
8
|
"email": "hi@recur.tw",
|
|
@@ -10,14 +10,14 @@
|
|
|
10
10
|
},
|
|
11
11
|
"metadata": {
|
|
12
12
|
"description": "Claude Code skills for integrating Recur - Taiwan's subscription payment platform",
|
|
13
|
-
"version": "0.0.
|
|
13
|
+
"version": "0.0.13"
|
|
14
14
|
},
|
|
15
15
|
"plugins": [
|
|
16
16
|
{
|
|
17
17
|
"name": "recur-skills",
|
|
18
18
|
"displayName": "Recur Skills",
|
|
19
19
|
"description": "Skills for Recur SDK integration (quickstart, checkout, webhooks, entitlements, portal) and the Recur MCP server for signup, API keys, and products",
|
|
20
|
-
"version": "0.0.
|
|
20
|
+
"version": "0.0.13",
|
|
21
21
|
"source": "./",
|
|
22
22
|
"category": "development",
|
|
23
23
|
"homepage": "https://docs.recur.tw/guides/skills",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "recur-skills",
|
|
4
4
|
"displayName": "Recur Skills",
|
|
5
|
-
"version": "0.0.
|
|
5
|
+
"version": "0.0.13",
|
|
6
6
|
"description": "Skills for Recur SDK integration (quickstart, checkout, webhooks, entitlements, customer portal) plus the Recur MCP server (https://mcp.recur.tw/) for account signup, API keys, and products.",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "Recur",
|
package/package.json
CHANGED
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "recur-skills",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.13",
|
|
5
5
|
"description": "Skills for Recur SDK integration: quickstart, checkout, webhooks, entitlements, and customer portal. Taiwan subscription billing via PAYUNi.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Recur",
|
|
@@ -4,7 +4,7 @@ description: Implement Recur checkout flows including embedded, modal, and redir
|
|
|
4
4
|
license: MIT
|
|
5
5
|
metadata:
|
|
6
6
|
author: recur
|
|
7
|
-
version: "0.0.
|
|
7
|
+
version: "0.0.13"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# Recur Checkout Integration
|
|
@@ -261,7 +261,7 @@ function PricingPage() {
|
|
|
261
261
|
|
|
262
262
|
`Product` fields: `id`, `name`, `slug`, `description`, `type`, `billingPeriod`
|
|
263
263
|
(`'MONTHLY' | 'YEARLY' | ... | null`), `price` (whole TWD), `currency`, `trialDays`,
|
|
264
|
-
`metadata`. There is no `priceFormatted` on the SDK type — format with
|
|
264
|
+
`metadata`, `productFamily`, `displayOrder`. There is no `priceFormatted` on the SDK type — format with
|
|
265
265
|
`` `NT$${product.price.toLocaleString()}` ``.
|
|
266
266
|
|
|
267
267
|
## Payment Failed Handling
|
|
@@ -4,7 +4,7 @@ description: Implement access control and permission checking with Recur entitle
|
|
|
4
4
|
license: MIT
|
|
5
5
|
metadata:
|
|
6
6
|
author: recur
|
|
7
|
-
version: "0.0.
|
|
7
|
+
version: "0.0.13"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# Recur Entitlements & Access Control
|
|
@@ -15,13 +15,18 @@ You are helping implement access control using Recur's entitlements system. Enti
|
|
|
15
15
|
|
|
16
16
|
```tsx
|
|
17
17
|
import { RecurProvider, useCustomer } from 'recur-tw'
|
|
18
|
+
// Your own hook, defined in "Knowing when the answer is real" below.
|
|
19
|
+
import { useEntitlementsReady } from './hooks/use-entitlements-ready'
|
|
20
|
+
|
|
21
|
+
// The identifier you hand the provider. Pass the same value to the readiness hook.
|
|
22
|
+
const customerEmail = 'user@example.com'
|
|
18
23
|
|
|
19
24
|
// 1. Wrap app with provider and identify customer
|
|
20
25
|
function App() {
|
|
21
26
|
return (
|
|
22
27
|
<RecurProvider
|
|
23
28
|
config={{ publishableKey: process.env.NEXT_PUBLIC_RECUR_PUBLISHABLE_KEY }}
|
|
24
|
-
customer={{ email:
|
|
29
|
+
customer={{ email: customerEmail }}
|
|
25
30
|
>
|
|
26
31
|
<MyApp />
|
|
27
32
|
</RecurProvider>
|
|
@@ -30,9 +35,10 @@ function App() {
|
|
|
30
35
|
|
|
31
36
|
// 2. Check access anywhere in your app
|
|
32
37
|
function PremiumFeature() {
|
|
33
|
-
const { check
|
|
38
|
+
const { check } = useCustomer()
|
|
39
|
+
const ready = useEntitlementsReady(customerEmail)
|
|
34
40
|
|
|
35
|
-
if (
|
|
41
|
+
if (!ready) return <div>Loading...</div>
|
|
36
42
|
|
|
37
43
|
const { allowed } = check('pro-plan')
|
|
38
44
|
|
|
@@ -44,6 +50,101 @@ function PremiumFeature() {
|
|
|
44
50
|
}
|
|
45
51
|
```
|
|
46
52
|
|
|
53
|
+
### Knowing when the answer is real
|
|
54
|
+
|
|
55
|
+
`useCustomer()` has no "initialized" flag. `isLoading` starts `false` because the request
|
|
56
|
+
only begins in an effect, and `customer` is `null` both before the request and when the
|
|
57
|
+
customer genuinely does not exist — so neither one can tell "not fetched yet" from "no such
|
|
58
|
+
customer". Gating on `isLoading` alone flashes an upgrade prompt on the first paint; gating
|
|
59
|
+
on `!customer` leaves a brand-new customer on the spinner forever.
|
|
60
|
+
|
|
61
|
+
Track the first completed load yourself:
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
// hooks/use-entitlements-ready.ts
|
|
65
|
+
import { useEffect, useRef, useState } from 'react'
|
|
66
|
+
import { useCustomer } from 'recur-tw'
|
|
67
|
+
|
|
68
|
+
// `customerKey` is the identifier you pass to RecurProvider (email, externalId or id).
|
|
69
|
+
export function useEntitlementsReady(customerKey: string | null | undefined) {
|
|
70
|
+
const { isLoading, error, customer } = useCustomer()
|
|
71
|
+
const [ready, setReady] = useState(false)
|
|
72
|
+
const [seenKey, setSeenKey] = useState(customerKey)
|
|
73
|
+
const pending = useRef(false)
|
|
74
|
+
const switched = useRef(false)
|
|
75
|
+
|
|
76
|
+
// Adjusted during render, not in an effect: RecurProvider kicks off the new request
|
|
77
|
+
// from an effect and leaves the old cache with `isLoading === false` until it does,
|
|
78
|
+
// so an effect-only guard would still pass for one render after a switch.
|
|
79
|
+
if (customerKey !== seenKey) {
|
|
80
|
+
setSeenKey(customerKey)
|
|
81
|
+
setReady(false)
|
|
82
|
+
switched.current = true
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (isLoading) {
|
|
87
|
+
pending.current = true
|
|
88
|
+
setReady(false) // a new fetch: the cache still holds the previous customer
|
|
89
|
+
} else if (pending.current) {
|
|
90
|
+
pending.current = false
|
|
91
|
+
setReady(!error) // ready only when that request actually succeeded
|
|
92
|
+
}
|
|
93
|
+
}, [isLoading, error])
|
|
94
|
+
|
|
95
|
+
// A customer already in the cache proves a request finished. Without this, a gate that
|
|
96
|
+
// first mounts AFTER the provider settled never sees isLoading go true, so it would wait
|
|
97
|
+
// forever. Only before a switch: afterwards the cache still holds the previous customer.
|
|
98
|
+
return ready || (!switched.current && !isLoading && customer !== null)
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The `!error` reset matters because the SDK restores its last successful cache on a failed
|
|
103
|
+
request and still clears `isLoading`; without it a failed fetch would open the guard over
|
|
104
|
+
whatever was cached before. Read `error` from `useCustomer()` to tell "still loading" from
|
|
105
|
+
"the fetch failed".
|
|
106
|
+
|
|
107
|
+
It stays `false` if you did not pass a `customer` to `RecurProvider`, because no request
|
|
108
|
+
ever runs — that is the case to catch in development.
|
|
109
|
+
|
|
110
|
+
### Mount the gate with the provider
|
|
111
|
+
|
|
112
|
+
Put the guard in one component directly under `RecurProvider`, not in every leaf:
|
|
113
|
+
|
|
114
|
+
```tsx
|
|
115
|
+
function EntitlementsGate({ children }: { children: React.ReactNode }) {
|
|
116
|
+
const ready = useEntitlementsReady(customerEmail)
|
|
117
|
+
if (!ready) return <Spinner />
|
|
118
|
+
return <>{children}</>
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
<RecurProvider config={{ publishableKey }} customer={{ email: customerEmail }}>
|
|
122
|
+
<EntitlementsGate>
|
|
123
|
+
<App /> {/* everything below can call check() freely */}
|
|
124
|
+
</EntitlementsGate>
|
|
125
|
+
</RecurProvider>
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
A gate that mounts with the provider watches the whole request, which is the only way the
|
|
129
|
+
hook sees the load at all. A gate that first mounts much later relies on the cache fallback
|
|
130
|
+
in the last line of the hook, and that cannot help when the customer does not exist in
|
|
131
|
+
Recur: the entitlements endpoint answers `200` with `customer: null` both for "no such
|
|
132
|
+
customer" and for "not fetched yet", so a late-mounted gate for an unknown customer waits.
|
|
133
|
+
|
|
134
|
+
### What this hook cannot do
|
|
135
|
+
|
|
136
|
+
It does not make a customer switch safe. `RecurProvider` has no request sequencing: when
|
|
137
|
+
the identifier changes it starts a second request while the first is still in flight, and
|
|
138
|
+
whichever lands last wins. The previous customer's response can overwrite the new one's
|
|
139
|
+
cache and clear the shared `isLoading` flag, at which point this hook reports ready over the
|
|
140
|
+
wrong data. The render-time reset narrows the window but cannot close it from outside the
|
|
141
|
+
SDK.
|
|
142
|
+
|
|
143
|
+
So if one session can switch between customers in place, do not treat the client cache as an
|
|
144
|
+
authorization decision. Confirm on the server, where you hold the secret key and read the
|
|
145
|
+
customer you actually mean. Remounting the app on a switch also avoids the overlap.
|
|
146
|
+
|
|
147
|
+
|
|
47
148
|
## Customer Identification
|
|
48
149
|
|
|
49
150
|
Identify customers using one of these methods:
|
|
@@ -82,20 +183,28 @@ if (allowed) {
|
|
|
82
183
|
|
|
83
184
|
### Async Check (Live)
|
|
84
185
|
|
|
85
|
-
|
|
186
|
+
Refetches before answering. Useful, but **not authoritative** — see the warning below.
|
|
86
187
|
|
|
87
188
|
```tsx
|
|
88
189
|
const { check } = useCustomer()
|
|
89
190
|
|
|
90
|
-
//
|
|
191
|
+
// Refetches, then answers
|
|
91
192
|
const { allowed, entitlement } = await check('pro-plan', { live: true })
|
|
92
193
|
|
|
93
194
|
// Good for:
|
|
94
|
-
// -
|
|
95
|
-
// -
|
|
96
|
-
// - When cached data might be stale
|
|
195
|
+
// - Refreshing the UI after checkout
|
|
196
|
+
// - When cached data is probably stale
|
|
97
197
|
```
|
|
98
198
|
|
|
199
|
+
> **`{ live: true }` can answer from the previous snapshot.** In the current SDK it awaits
|
|
200
|
+
> the refetch and then calls the synchronous check captured by the render it was called
|
|
201
|
+
> from, so the fresh data is in the cache but the answer is not computed from it. Right
|
|
202
|
+
> after a purchase it can still say `false`.
|
|
203
|
+
>
|
|
204
|
+
> Never use it as the gate on something that matters — granting access, releasing a
|
|
205
|
+
> download, starting paid work. Check on the server with the secret key instead. On the
|
|
206
|
+
> client, `{ live: true }` is a way to refresh what the user sees, not a decision.
|
|
207
|
+
|
|
99
208
|
### Manual Refetch
|
|
100
209
|
|
|
101
210
|
```tsx
|
|
@@ -168,7 +277,7 @@ async function checkAccess(userEmail: string) {
|
|
|
168
277
|
### Using REST API Directly
|
|
169
278
|
|
|
170
279
|
```typescript
|
|
171
|
-
// GET /
|
|
280
|
+
// GET /v1/customers/entitlements
|
|
172
281
|
const response = await fetch(
|
|
173
282
|
`https://api.recur.tw/v1/customers/entitlements?email=${encodeURIComponent(email)}`,
|
|
174
283
|
{
|
|
@@ -192,15 +301,19 @@ const { customer, subscription, entitlements } = await response.json()
|
|
|
192
301
|
function Paywall({
|
|
193
302
|
children,
|
|
194
303
|
product,
|
|
304
|
+
customerKey,
|
|
195
305
|
fallback
|
|
196
306
|
}: {
|
|
197
307
|
children: React.ReactNode
|
|
198
308
|
product: string
|
|
309
|
+
/** The identifier passed to RecurProvider, so the guard closes on a customer switch. */
|
|
310
|
+
customerKey: string | null | undefined
|
|
199
311
|
fallback?: React.ReactNode
|
|
200
312
|
}) {
|
|
201
|
-
const { check
|
|
313
|
+
const { check } = useCustomer()
|
|
314
|
+
const ready = useEntitlementsReady(customerKey)
|
|
202
315
|
|
|
203
|
-
if (
|
|
316
|
+
if (!ready) {
|
|
204
317
|
return <div>Loading...</div>
|
|
205
318
|
}
|
|
206
319
|
|
|
@@ -214,7 +327,7 @@ function Paywall({
|
|
|
214
327
|
}
|
|
215
328
|
|
|
216
329
|
// Usage
|
|
217
|
-
<Paywall product="pro-plan">
|
|
330
|
+
<Paywall product="pro-plan" customerKey={customerEmail}>
|
|
218
331
|
<PremiumDashboard />
|
|
219
332
|
</Paywall>
|
|
220
333
|
```
|
|
@@ -222,11 +335,14 @@ function Paywall({
|
|
|
222
335
|
### Feature Flag Style
|
|
223
336
|
|
|
224
337
|
```tsx
|
|
225
|
-
function useFeature(featureProduct: string) {
|
|
226
|
-
const { check,
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
338
|
+
function useFeature(featureProduct: string, customerKey: string | null | undefined) {
|
|
339
|
+
const { check, error } = useCustomer()
|
|
340
|
+
const ready = useEntitlementsReady(customerKey)
|
|
341
|
+
|
|
342
|
+
// `isLoading` alone is not enough here either: it starts false, so the first paint
|
|
343
|
+
// would read an empty cache and report the feature as off.
|
|
344
|
+
if (!ready) {
|
|
345
|
+
return { enabled: false, loading: !error, error, entitlement: undefined }
|
|
230
346
|
}
|
|
231
347
|
|
|
232
348
|
const { allowed, entitlement } = check(featureProduct)
|
|
@@ -234,6 +350,7 @@ function useFeature(featureProduct: string) {
|
|
|
234
350
|
return {
|
|
235
351
|
enabled: allowed,
|
|
236
352
|
loading: false,
|
|
353
|
+
error: null,
|
|
237
354
|
entitlement,
|
|
238
355
|
isTrial: entitlement?.status === 'trialing',
|
|
239
356
|
isPastDue: entitlement?.status === 'past_due',
|
|
@@ -242,7 +359,7 @@ function useFeature(featureProduct: string) {
|
|
|
242
359
|
|
|
243
360
|
// Usage
|
|
244
361
|
function MyComponent() {
|
|
245
|
-
const { enabled, isTrial } = useFeature('pro-plan')
|
|
362
|
+
const { enabled, isTrial } = useFeature('pro-plan', customerEmail)
|
|
246
363
|
|
|
247
364
|
if (!enabled) return <UpgradeButton />
|
|
248
365
|
|
|
@@ -337,7 +454,7 @@ const { entitlement } = check('pro-plan')
|
|
|
337
454
|
|
|
338
455
|
if (entitlement?.status === 'trialing') {
|
|
339
456
|
const trialEnds = new Date(entitlement.expiresAt!)
|
|
340
|
-
const daysLeft = Math.ceil((trialEnds - Date.now()) / (1000 * 60 * 60 * 24))
|
|
457
|
+
const daysLeft = Math.ceil((trialEnds.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
|
341
458
|
|
|
342
459
|
return <TrialBanner daysLeft={daysLeft} />
|
|
343
460
|
}
|
|
@@ -393,7 +510,9 @@ if (!allowed) {
|
|
|
393
510
|
## Best Practices
|
|
394
511
|
|
|
395
512
|
1. **Use cached checks for UI** - Fast rendering, good UX
|
|
396
|
-
2. **
|
|
513
|
+
2. **Decide on the server** - Anything that grants access, releases a file or starts paid
|
|
514
|
+
work is checked with the secret key. `{ live: true }` refreshes the UI; it can still
|
|
515
|
+
answer from the previous snapshot, so it is not an authorization decision
|
|
397
516
|
3. **Handle all statuses** - active, trialing, past_due, canceled
|
|
398
517
|
4. **Refetch after checkout** - Ensure UI updates after purchase
|
|
399
518
|
5. **Implement graceful degradation** - Show upgrade prompts, not errors
|
|
@@ -4,7 +4,7 @@ description: Set up and handle Recur webhook events for payment notifications. U
|
|
|
4
4
|
license: MIT
|
|
5
5
|
metadata:
|
|
6
6
|
author: recur
|
|
7
|
-
version: "0.0.
|
|
7
|
+
version: "0.0.13"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# Recur Webhook Integration
|
|
@@ -22,8 +22,8 @@ You are helping implement Recur webhooks to receive real-time payment and subscr
|
|
|
22
22
|
| `subscription.cancelled` | Subscription cancelled — check `data.status` / `data.current_period_end`: period-end cancellation keeps access until that date, while dashboard or refund cancellation revokes it immediately |
|
|
23
23
|
| `subscription.renewed` | Recurring payment successful |
|
|
24
24
|
| `subscription.past_due` | Payment failed, subscription at risk |
|
|
25
|
-
| `invoice.paid` / `invoice.payment_failed` | Invoice outcome
|
|
26
|
-
| `order.paid` |
|
|
25
|
+
| `invoice.paid` / `invoice.payment_failed` | Invoice outcome. Invoices exist only for renewals and plan switches, so `billing_reason` is `subscription_cycle` or `subscription_update` and **never** `subscription_create` — a first payment produces no invoice. A paid renewal also sends `subscription.renewed`; a failed charge also sends `subscription.past_due` only with a grace period. A renewal with no usable card sends NO invoice event — see `subscription.payment_method_required` / `subscription.revoked` |
|
|
26
|
+
| `order.paid` / `order.payment_failed` | An order was paid or failed — **not one-time purchases only**. A subscription's first payment is an order, not an invoice, so both events also carry `billing_reason: 'subscription_create'`, and these are the ONLY events that report that first charge. Handle both branches: `purchase` is the one-time buy, `subscription_create` the signup. An empty `subscription_create` branch silently loses every new subscriber |
|
|
27
27
|
| `refund.created` | Refund initiated |
|
|
28
28
|
|
|
29
29
|
### All Supported Events (`WEBHOOK_EVENT_TYPES` in `recur-tw/server`)
|
|
@@ -97,13 +97,32 @@ export async function POST(request: Request) {
|
|
|
97
97
|
// subscription.payment_method_required (grace) or subscription.revoked (no grace).
|
|
98
98
|
// Handle those below, or you will miss those failures entirely.
|
|
99
99
|
case 'invoice.paid':
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
100
|
+
// Invoices exist only for renewals and plan switches, so billing_reason here is
|
|
101
|
+
// 'subscription_cycle' or 'subscription_update' — never 'subscription_create'.
|
|
102
|
+
// A subscription's FIRST payment produces no invoice at all; it arrives as
|
|
103
|
+
// order.paid. One-time purchases produce no invoice either.
|
|
103
104
|
if (event.data.billing_reason === 'subscription_cycle') {
|
|
104
105
|
await handleRenewal(event.data) // extend access, record recurring revenue
|
|
105
106
|
} else {
|
|
106
|
-
await
|
|
107
|
+
await handleSwitchInvoice(event.data) // 'subscription_update': the plan switch
|
|
108
|
+
}
|
|
109
|
+
break
|
|
110
|
+
case 'order.paid':
|
|
111
|
+
// Orders cover BOTH one-time buys and a subscription's first payment. Nothing else
|
|
112
|
+
// reports that first payment, so both branches have to do real work.
|
|
113
|
+
if (event.data.billing_reason === 'purchase') {
|
|
114
|
+
await handleOneTimePurchase(event.data)
|
|
115
|
+
} else {
|
|
116
|
+
await handleFirstSubscriptionPayment(event.data) // 'subscription_create'
|
|
117
|
+
}
|
|
118
|
+
break
|
|
119
|
+
case 'order.payment_failed':
|
|
120
|
+
// Same split. A failed signup emits ONLY this event — no invoice.payment_failed
|
|
121
|
+
// follows it — so an empty branch here loses the failure entirely.
|
|
122
|
+
if (event.data.billing_reason === 'purchase') {
|
|
123
|
+
await handlePurchaseFailed(event.data)
|
|
124
|
+
} else {
|
|
125
|
+
await handleSignupFailed(event.data) // 'subscription_create'
|
|
107
126
|
}
|
|
108
127
|
break
|
|
109
128
|
case 'subscription.renewed':
|
|
@@ -143,8 +162,12 @@ async function handleSubscriptionCancelled(data: Payload) {
|
|
|
143
162
|
// Period-end cancellation: keep access until current_period_end.
|
|
144
163
|
// Immediate cancellation (dashboard, refund): status is already "canceled" — revoke now.
|
|
145
164
|
}
|
|
146
|
-
async function handleRenewal(data: Payload) {}
|
|
147
|
-
async function
|
|
165
|
+
async function handleRenewal(data: Payload) {} // invoice, 'subscription_cycle'
|
|
166
|
+
async function handleSwitchInvoice(data: Payload) {} // invoice, 'subscription_update'
|
|
167
|
+
async function handleOneTimePurchase(data: Payload) {} // order, 'purchase'
|
|
168
|
+
async function handleFirstSubscriptionPayment(data: Payload) {} // order, 'subscription_create'
|
|
169
|
+
async function handlePurchaseFailed(data: Payload) {} // order failure, 'purchase'
|
|
170
|
+
async function handleSignupFailed(data: Payload) {} // order failure, 'subscription_create'
|
|
148
171
|
async function handlePaymentFailure(data: Payload) {}
|
|
149
172
|
async function handleRefundCreated(data: Payload) {}
|
|
150
173
|
```
|
|
@@ -154,12 +177,14 @@ async function handleRefundCreated(data: Payload) {}
|
|
|
154
177
|
```typescript
|
|
155
178
|
import express from 'express'
|
|
156
179
|
import { Recur } from 'recur-tw/server'
|
|
180
|
+
// Your own dispatch — the claim-first version is in "Idempotency" below.
|
|
181
|
+
import { handleEvent } from './handle-event'
|
|
157
182
|
|
|
158
183
|
const app = express()
|
|
159
184
|
const recur = new Recur(process.env.RECUR_SECRET_KEY!)
|
|
160
185
|
|
|
161
186
|
// Use the raw body for signature verification
|
|
162
|
-
app.post('/api/webhooks/recur', express.raw({ type: 'application/json' }), (req, res) => {
|
|
187
|
+
app.post('/api/webhooks/recur', express.raw({ type: 'application/json' }), async (req, res) => {
|
|
163
188
|
const payload = req.body.toString()
|
|
164
189
|
const signature = req.header('x-recur-signature') ?? null
|
|
165
190
|
|
|
@@ -170,7 +195,14 @@ app.post('/api/webhooks/recur', express.raw({ type: 'application/json' }), (req,
|
|
|
170
195
|
return res.status(401).json({ error: 'Invalid signature' })
|
|
171
196
|
}
|
|
172
197
|
|
|
173
|
-
|
|
198
|
+
// Dispatch before answering: a 2xx tells Recur the event is handled.
|
|
199
|
+
try {
|
|
200
|
+
await handleEvent(event)
|
|
201
|
+
} catch (err) {
|
|
202
|
+
console.error('webhook handler failed', event.id, err)
|
|
203
|
+
return res.status(500).json({ error: 'handler failed' }) // Recur retries
|
|
204
|
+
}
|
|
205
|
+
|
|
174
206
|
res.json({ received: true })
|
|
175
207
|
})
|
|
176
208
|
```
|
|
@@ -233,6 +265,8 @@ interface WebhookEvent {
|
|
|
233
265
|
|
|
234
266
|
// Enum-derived fields (status, type, billing_reason, switch_type, reason) are lowercased
|
|
235
267
|
// before delivery: "active", "canceled", "complete" — compare against lowercase values.
|
|
268
|
+
// The REST API does NOT do this: the same field reads "SUBSCRIPTION_UPDATE" / "ACTIVE"
|
|
269
|
+
// there (see openapi.json). Only webhook payloads are lowercased.
|
|
236
270
|
|
|
237
271
|
// Example: checkout.completed
|
|
238
272
|
{
|
|
@@ -290,7 +324,8 @@ MCP: `test_webhook` sends a test event to your endpoint; `get_webhook_events` sh
|
|
|
290
324
|
|
|
291
325
|
### 1. Always Verify Signatures
|
|
292
326
|
|
|
293
|
-
Never trust webhook
|
|
327
|
+
Never trust a webhook payload you have not verified — with `recur.webhooks.verify()`, or
|
|
328
|
+
with one of the constant-time verifiers above if you are not using the SDK.
|
|
294
329
|
|
|
295
330
|
### 2. Handle Idempotency
|
|
296
331
|
|