recur-skills 0.0.1
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/plugin.json +25 -0
- package/README.md +133 -0
- package/dist/cli.js +163 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +72 -0
- package/package.json +64 -0
- package/skills/recur-checkout/SKILL.md +248 -0
- package/skills/recur-entitlements/SKILL.md +389 -0
- package/skills/recur-quickstart/SKILL.md +114 -0
- package/skills/recur-quickstart/scripts/check-env.sh +57 -0
- package/skills/recur-webhooks/SKILL.md +349 -0
- package/skills/recur-webhooks/scripts/test-webhook.sh +111 -0
- package/skills/recur-webhooks/scripts/verify-signature.ts +59 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: recur-quickstart
|
|
3
|
+
description: Quick setup guide for Recur payment integration. Use when starting a new Recur integration, setting up API keys, installing the SDK, or when user mentions "integrate Recur", "setup Recur", "Recur 串接", "金流設定".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Recur Quickstart
|
|
7
|
+
|
|
8
|
+
You are helping a developer integrate Recur, Taiwan's subscription payment platform (similar to Stripe Billing).
|
|
9
|
+
|
|
10
|
+
## Step 1: Install SDK
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pnpm add recur-tw
|
|
14
|
+
# or
|
|
15
|
+
npm install recur-tw
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Step 2: Get API Keys
|
|
19
|
+
|
|
20
|
+
API keys are available in the Recur dashboard at `app.recur.tw` → Settings → Developers.
|
|
21
|
+
|
|
22
|
+
**Key formats:**
|
|
23
|
+
- `pk_test_xxx` - Publishable key (frontend, safe to expose)
|
|
24
|
+
- `sk_test_xxx` - Secret key (backend only, never expose)
|
|
25
|
+
- `pk_live_xxx` / `sk_live_xxx` - Production keys
|
|
26
|
+
|
|
27
|
+
**Environment variables to set:**
|
|
28
|
+
```bash
|
|
29
|
+
RECUR_PUBLISHABLE_KEY=pk_test_xxx
|
|
30
|
+
RECUR_SECRET_KEY=sk_test_xxx
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Step 3: Add Provider (React)
|
|
34
|
+
|
|
35
|
+
Wrap your app with `RecurProvider`:
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
import { RecurProvider } from 'recur-tw'
|
|
39
|
+
|
|
40
|
+
export default function App({ children }) {
|
|
41
|
+
return (
|
|
42
|
+
<RecurProvider
|
|
43
|
+
config={{
|
|
44
|
+
publishableKey: process.env.NEXT_PUBLIC_RECUR_PUBLISHABLE_KEY,
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
{children}
|
|
48
|
+
</RecurProvider>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Step 4: Create Your First Checkout
|
|
54
|
+
|
|
55
|
+
```tsx
|
|
56
|
+
import { useRecur } from 'recur-tw'
|
|
57
|
+
|
|
58
|
+
function PricingButton({ productId }: { productId: string }) {
|
|
59
|
+
const { checkout } = useRecur()
|
|
60
|
+
|
|
61
|
+
const handleCheckout = async () => {
|
|
62
|
+
await checkout({
|
|
63
|
+
productId,
|
|
64
|
+
onPaymentComplete: (subscription) => {
|
|
65
|
+
console.log('Payment successful!', subscription)
|
|
66
|
+
},
|
|
67
|
+
onPaymentFailed: (error) => {
|
|
68
|
+
console.error('Payment failed:', error)
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return <button onClick={handleCheckout}>Subscribe</button>
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Step 5: Set Up Webhooks
|
|
78
|
+
|
|
79
|
+
Create a webhook endpoint to receive payment notifications. See the `recur-webhooks` skill for detailed instructions.
|
|
80
|
+
|
|
81
|
+
## Quick Verification Checklist
|
|
82
|
+
|
|
83
|
+
- [ ] SDK installed (`pnpm list recur-tw`)
|
|
84
|
+
- [ ] Environment variables set
|
|
85
|
+
- [ ] RecurProvider wrapping app
|
|
86
|
+
- [ ] Test checkout works in sandbox
|
|
87
|
+
- [ ] Webhook endpoint configured
|
|
88
|
+
|
|
89
|
+
## Common Issues
|
|
90
|
+
|
|
91
|
+
### "Invalid API key"
|
|
92
|
+
- Check key format: must start with `pk_test_`, `sk_test_`, `pk_live_`, or `sk_live_`
|
|
93
|
+
- Ensure using publishable key for frontend, secret key for backend
|
|
94
|
+
|
|
95
|
+
### "Product not found"
|
|
96
|
+
- Verify product exists in Recur dashboard
|
|
97
|
+
- Check you're using correct environment (sandbox vs production)
|
|
98
|
+
|
|
99
|
+
### Checkout not appearing
|
|
100
|
+
- Ensure `RecurProvider` wraps your app
|
|
101
|
+
- Check browser console for errors
|
|
102
|
+
- Verify publishable key is correct
|
|
103
|
+
|
|
104
|
+
## Next Steps
|
|
105
|
+
|
|
106
|
+
- `/recur-checkout` - Learn checkout flow options
|
|
107
|
+
- `/recur-webhooks` - Set up payment notifications
|
|
108
|
+
- `/recur-entitlements` - Implement access control
|
|
109
|
+
|
|
110
|
+
## Resources
|
|
111
|
+
|
|
112
|
+
- [Recur Documentation](https://recur.tw/docs)
|
|
113
|
+
- [SDK on npm](https://www.npmjs.com/package/recur-tw)
|
|
114
|
+
- [API Reference](https://recur.tw/docs/api)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Check Recur environment setup
|
|
3
|
+
|
|
4
|
+
echo "🔍 Checking Recur environment..."
|
|
5
|
+
echo ""
|
|
6
|
+
|
|
7
|
+
# Check for recur-tw package
|
|
8
|
+
if [ -f "package.json" ]; then
|
|
9
|
+
if grep -q '"recur-tw"' package.json; then
|
|
10
|
+
VERSION=$(grep '"recur-tw"' package.json | head -1 | sed 's/.*: "\(.*\)".*/\1/')
|
|
11
|
+
echo "✅ recur-tw installed: $VERSION"
|
|
12
|
+
else
|
|
13
|
+
echo "❌ recur-tw not found in package.json"
|
|
14
|
+
echo " Run: pnpm add recur-tw"
|
|
15
|
+
fi
|
|
16
|
+
else
|
|
17
|
+
echo "⚠️ No package.json found in current directory"
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
echo ""
|
|
21
|
+
|
|
22
|
+
# Check environment variables
|
|
23
|
+
check_env() {
|
|
24
|
+
local var_name=$1
|
|
25
|
+
local var_value=$(printenv "$var_name")
|
|
26
|
+
|
|
27
|
+
if [ -n "$var_value" ]; then
|
|
28
|
+
# Mask the value, show first 12 chars
|
|
29
|
+
local masked="${var_value:0:12}..."
|
|
30
|
+
echo "✅ $var_name: $masked"
|
|
31
|
+
else
|
|
32
|
+
echo "❌ $var_name: not set"
|
|
33
|
+
fi
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
echo "Environment Variables:"
|
|
37
|
+
check_env "RECUR_PUBLISHABLE_KEY"
|
|
38
|
+
check_env "NEXT_PUBLIC_RECUR_PUBLISHABLE_KEY"
|
|
39
|
+
check_env "RECUR_SECRET_KEY"
|
|
40
|
+
check_env "RECUR_WEBHOOK_SECRET"
|
|
41
|
+
|
|
42
|
+
echo ""
|
|
43
|
+
|
|
44
|
+
# Check .env files
|
|
45
|
+
echo "Configuration Files:"
|
|
46
|
+
for file in .env .env.local .env.development .env.production; do
|
|
47
|
+
if [ -f "$file" ]; then
|
|
48
|
+
if grep -q "RECUR" "$file"; then
|
|
49
|
+
echo "✅ $file contains RECUR variables"
|
|
50
|
+
else
|
|
51
|
+
echo "⚠️ $file exists but no RECUR variables"
|
|
52
|
+
fi
|
|
53
|
+
fi
|
|
54
|
+
done
|
|
55
|
+
|
|
56
|
+
echo ""
|
|
57
|
+
echo "Done! 🎉"
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: recur-webhooks
|
|
3
|
+
description: Set up and handle Recur webhook events for payment notifications. Use when implementing webhook handlers, verifying signatures, handling subscription events, or when user mentions "webhook", "付款通知", "訂閱事件", "payment notification".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Recur Webhook Integration
|
|
7
|
+
|
|
8
|
+
You are helping implement Recur webhooks to receive real-time payment and subscription events.
|
|
9
|
+
|
|
10
|
+
## Webhook Events
|
|
11
|
+
|
|
12
|
+
### Core Events (Most Common)
|
|
13
|
+
|
|
14
|
+
| Event | When Fired |
|
|
15
|
+
|-------|------------|
|
|
16
|
+
| `checkout.completed` | Payment successful, subscription/order created |
|
|
17
|
+
| `subscription.activated` | Subscription is now active |
|
|
18
|
+
| `subscription.cancelled` | Subscription was cancelled |
|
|
19
|
+
| `subscription.renewed` | Recurring payment successful |
|
|
20
|
+
| `subscription.past_due` | Payment failed, subscription at risk |
|
|
21
|
+
| `order.paid` | One-time purchase completed |
|
|
22
|
+
| `refund.created` | Refund initiated |
|
|
23
|
+
|
|
24
|
+
### All Supported Events
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
type WebhookEventType =
|
|
28
|
+
// Checkout
|
|
29
|
+
| 'checkout.created'
|
|
30
|
+
| 'checkout.completed'
|
|
31
|
+
// Orders
|
|
32
|
+
| 'order.paid'
|
|
33
|
+
| 'order.payment_failed'
|
|
34
|
+
// Subscription Lifecycle
|
|
35
|
+
| 'subscription.created'
|
|
36
|
+
| 'subscription.activated'
|
|
37
|
+
| 'subscription.cancelled'
|
|
38
|
+
| 'subscription.expired'
|
|
39
|
+
| 'subscription.trial_ending'
|
|
40
|
+
// Subscription Changes
|
|
41
|
+
| 'subscription.upgraded'
|
|
42
|
+
| 'subscription.downgraded'
|
|
43
|
+
| 'subscription.renewed'
|
|
44
|
+
| 'subscription.past_due'
|
|
45
|
+
// Scheduled Changes
|
|
46
|
+
| 'subscription.schedule_created'
|
|
47
|
+
| 'subscription.schedule_executed'
|
|
48
|
+
| 'subscription.schedule_cancelled'
|
|
49
|
+
// Invoices
|
|
50
|
+
| 'invoice.created'
|
|
51
|
+
| 'invoice.paid'
|
|
52
|
+
| 'invoice.payment_failed'
|
|
53
|
+
// Customer
|
|
54
|
+
| 'customer.created'
|
|
55
|
+
| 'customer.updated'
|
|
56
|
+
// Product
|
|
57
|
+
| 'product.created'
|
|
58
|
+
| 'product.updated'
|
|
59
|
+
// Refunds
|
|
60
|
+
| 'refund.created'
|
|
61
|
+
| 'refund.succeeded'
|
|
62
|
+
| 'refund.failed'
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Webhook Handler Implementation
|
|
66
|
+
|
|
67
|
+
### Next.js App Router
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
// app/api/webhooks/recur/route.ts
|
|
71
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
72
|
+
import crypto from 'crypto'
|
|
73
|
+
|
|
74
|
+
const WEBHOOK_SECRET = process.env.RECUR_WEBHOOK_SECRET!
|
|
75
|
+
|
|
76
|
+
function verifySignature(payload: string, signature: string): boolean {
|
|
77
|
+
const expected = crypto
|
|
78
|
+
.createHmac('sha256', WEBHOOK_SECRET)
|
|
79
|
+
.update(payload)
|
|
80
|
+
.digest('hex')
|
|
81
|
+
return crypto.timingSafeEqual(
|
|
82
|
+
Buffer.from(signature),
|
|
83
|
+
Buffer.from(expected)
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function POST(request: NextRequest) {
|
|
88
|
+
const payload = await request.text()
|
|
89
|
+
const signature = request.headers.get('x-recur-signature')
|
|
90
|
+
|
|
91
|
+
// Verify signature
|
|
92
|
+
if (!signature || !verifySignature(payload, signature)) {
|
|
93
|
+
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const event = JSON.parse(payload)
|
|
97
|
+
|
|
98
|
+
// Handle events
|
|
99
|
+
switch (event.type) {
|
|
100
|
+
case 'checkout.completed':
|
|
101
|
+
await handleCheckoutCompleted(event.data)
|
|
102
|
+
break
|
|
103
|
+
|
|
104
|
+
case 'subscription.activated':
|
|
105
|
+
await handleSubscriptionActivated(event.data)
|
|
106
|
+
break
|
|
107
|
+
|
|
108
|
+
case 'subscription.cancelled':
|
|
109
|
+
await handleSubscriptionCancelled(event.data)
|
|
110
|
+
break
|
|
111
|
+
|
|
112
|
+
case 'subscription.renewed':
|
|
113
|
+
await handleSubscriptionRenewed(event.data)
|
|
114
|
+
break
|
|
115
|
+
|
|
116
|
+
case 'subscription.past_due':
|
|
117
|
+
await handleSubscriptionPastDue(event.data)
|
|
118
|
+
break
|
|
119
|
+
|
|
120
|
+
case 'refund.created':
|
|
121
|
+
await handleRefundCreated(event.data)
|
|
122
|
+
break
|
|
123
|
+
|
|
124
|
+
default:
|
|
125
|
+
console.log(`Unhandled event type: ${event.type}`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return NextResponse.json({ received: true })
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Event handlers
|
|
132
|
+
async function handleCheckoutCompleted(data: any) {
|
|
133
|
+
const { customerId, subscriptionId, orderId, productId, amount } = data
|
|
134
|
+
|
|
135
|
+
// Update your database
|
|
136
|
+
// Grant access to the user
|
|
137
|
+
// Send confirmation email
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function handleSubscriptionActivated(data: any) {
|
|
141
|
+
const { subscriptionId, customerId, productId, status } = data
|
|
142
|
+
|
|
143
|
+
// Update user's subscription status in your database
|
|
144
|
+
// Enable premium features
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function handleSubscriptionCancelled(data: any) {
|
|
148
|
+
const { subscriptionId, customerId, cancelledAt, accessUntil } = data
|
|
149
|
+
|
|
150
|
+
// Mark subscription as cancelled
|
|
151
|
+
// User still has access until accessUntil date
|
|
152
|
+
// Send cancellation confirmation email
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function handleSubscriptionRenewed(data: any) {
|
|
156
|
+
const { subscriptionId, customerId, amount, nextBillingDate } = data
|
|
157
|
+
|
|
158
|
+
// Update billing records
|
|
159
|
+
// Extend access period
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function handleSubscriptionPastDue(data: any) {
|
|
163
|
+
const { subscriptionId, customerId, failureReason } = data
|
|
164
|
+
|
|
165
|
+
// Notify user of payment failure
|
|
166
|
+
// Consider sending dunning emails
|
|
167
|
+
// May want to restrict access after grace period
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function handleRefundCreated(data: any) {
|
|
171
|
+
const { refundId, orderId, amount, reason } = data
|
|
172
|
+
|
|
173
|
+
// Update order status
|
|
174
|
+
// Adjust user credits/access
|
|
175
|
+
// Send refund notification
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Express.js
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
import express from 'express'
|
|
183
|
+
import crypto from 'crypto'
|
|
184
|
+
|
|
185
|
+
const app = express()
|
|
186
|
+
|
|
187
|
+
// Important: Use raw body for signature verification
|
|
188
|
+
app.post(
|
|
189
|
+
'/api/webhooks/recur',
|
|
190
|
+
express.raw({ type: 'application/json' }),
|
|
191
|
+
(req, res) => {
|
|
192
|
+
const payload = req.body.toString()
|
|
193
|
+
const signature = req.headers['x-recur-signature'] as string
|
|
194
|
+
|
|
195
|
+
// Verify signature
|
|
196
|
+
const expected = crypto
|
|
197
|
+
.createHmac('sha256', process.env.RECUR_WEBHOOK_SECRET!)
|
|
198
|
+
.update(payload)
|
|
199
|
+
.digest('hex')
|
|
200
|
+
|
|
201
|
+
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
|
|
202
|
+
return res.status(401).json({ error: 'Invalid signature' })
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const event = JSON.parse(payload)
|
|
206
|
+
|
|
207
|
+
// Handle event...
|
|
208
|
+
console.log('Received event:', event.type)
|
|
209
|
+
|
|
210
|
+
res.json({ received: true })
|
|
211
|
+
}
|
|
212
|
+
)
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Event Payload Structure
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
interface WebhookEvent {
|
|
219
|
+
id: string // Event ID (for idempotency)
|
|
220
|
+
type: string // Event type
|
|
221
|
+
timestamp: string // ISO 8601 timestamp
|
|
222
|
+
data: {
|
|
223
|
+
// Varies by event type
|
|
224
|
+
customerId?: string
|
|
225
|
+
customerEmail?: string
|
|
226
|
+
subscriptionId?: string
|
|
227
|
+
orderId?: string
|
|
228
|
+
productId?: string
|
|
229
|
+
amount?: number
|
|
230
|
+
currency?: string
|
|
231
|
+
// ... more fields depending on event
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Webhook Configuration
|
|
237
|
+
|
|
238
|
+
1. Go to **Recur Dashboard** → **Settings** → **Webhooks**
|
|
239
|
+
2. Click **Add Endpoint**
|
|
240
|
+
3. Enter your endpoint URL (e.g., `https://yourapp.com/api/webhooks/recur`)
|
|
241
|
+
4. Select events to receive
|
|
242
|
+
5. Copy the **Webhook Secret** to your environment variables
|
|
243
|
+
|
|
244
|
+
## Testing Webhooks Locally
|
|
245
|
+
|
|
246
|
+
### Using ngrok
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
# Start ngrok tunnel
|
|
250
|
+
ngrok http 3000
|
|
251
|
+
|
|
252
|
+
# Use the ngrok URL in Recur dashboard
|
|
253
|
+
# https://xxxx.ngrok.io/api/webhooks/recur
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Using Recur CLI (if available)
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
# Forward webhooks to local server
|
|
260
|
+
recur webhooks forward --to localhost:3000/api/webhooks/recur
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## Best Practices
|
|
264
|
+
|
|
265
|
+
### 1. Always Verify Signatures
|
|
266
|
+
|
|
267
|
+
Never trust webhook payloads without verifying the signature.
|
|
268
|
+
|
|
269
|
+
### 2. Handle Idempotency
|
|
270
|
+
|
|
271
|
+
Webhooks may be delivered multiple times. Use the event `id` to deduplicate:
|
|
272
|
+
|
|
273
|
+
```typescript
|
|
274
|
+
async function handleEvent(event: WebhookEvent) {
|
|
275
|
+
// Check if already processed
|
|
276
|
+
const existing = await db.webhookEvent.findUnique({
|
|
277
|
+
where: { eventId: event.id }
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
if (existing) {
|
|
281
|
+
console.log('Event already processed:', event.id)
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Process event...
|
|
286
|
+
|
|
287
|
+
// Mark as processed
|
|
288
|
+
await db.webhookEvent.create({
|
|
289
|
+
data: { eventId: event.id, processedAt: new Date() }
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### 3. Return 200 Quickly
|
|
295
|
+
|
|
296
|
+
Process events asynchronously to avoid timeouts:
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
export async function POST(request: NextRequest) {
|
|
300
|
+
// Verify and parse...
|
|
301
|
+
|
|
302
|
+
// Queue for async processing
|
|
303
|
+
await queue.add('process-webhook', event)
|
|
304
|
+
|
|
305
|
+
// Return immediately
|
|
306
|
+
return NextResponse.json({ received: true })
|
|
307
|
+
}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### 4. Handle Retries Gracefully
|
|
311
|
+
|
|
312
|
+
Recur retries failed webhook deliveries. Ensure your handler is idempotent.
|
|
313
|
+
|
|
314
|
+
### 5. Log Everything
|
|
315
|
+
|
|
316
|
+
```typescript
|
|
317
|
+
console.log('Webhook received:', {
|
|
318
|
+
type: event.type,
|
|
319
|
+
id: event.id,
|
|
320
|
+
timestamp: event.timestamp,
|
|
321
|
+
})
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
## Debugging Webhooks
|
|
325
|
+
|
|
326
|
+
### Check Webhook Logs
|
|
327
|
+
|
|
328
|
+
In Recur Dashboard → Webhooks → Click endpoint → View delivery logs
|
|
329
|
+
|
|
330
|
+
### Common Issues
|
|
331
|
+
|
|
332
|
+
**401 Unauthorized**
|
|
333
|
+
- Check webhook secret is correct
|
|
334
|
+
- Ensure using raw body for signature verification
|
|
335
|
+
- Verify signature algorithm (HMAC SHA-256)
|
|
336
|
+
|
|
337
|
+
**Timeout (no response)**
|
|
338
|
+
- Return 200 before processing
|
|
339
|
+
- Use async processing for heavy operations
|
|
340
|
+
|
|
341
|
+
**Missing events**
|
|
342
|
+
- Check event types are selected in dashboard
|
|
343
|
+
- Verify endpoint URL is correct and accessible
|
|
344
|
+
|
|
345
|
+
## Related Skills
|
|
346
|
+
|
|
347
|
+
- `/recur-quickstart` - Initial SDK setup
|
|
348
|
+
- `/recur-checkout` - Implement payment flows
|
|
349
|
+
- `/recur-entitlements` - Check subscription access after webhook
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Send a test webhook to your local endpoint
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# ./test-webhook.sh [endpoint] [event_type]
|
|
6
|
+
#
|
|
7
|
+
# Example:
|
|
8
|
+
# ./test-webhook.sh http://localhost:3000/api/webhooks/recur checkout.completed
|
|
9
|
+
|
|
10
|
+
ENDPOINT="${1:-http://localhost:3000/api/webhooks/recur}"
|
|
11
|
+
EVENT_TYPE="${2:-checkout.completed}"
|
|
12
|
+
SECRET="${RECUR_WEBHOOK_SECRET:-test_secret}"
|
|
13
|
+
|
|
14
|
+
# Generate timestamp
|
|
15
|
+
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
16
|
+
|
|
17
|
+
# Create payload based on event type
|
|
18
|
+
case $EVENT_TYPE in
|
|
19
|
+
"checkout.completed")
|
|
20
|
+
PAYLOAD=$(cat <<EOF
|
|
21
|
+
{
|
|
22
|
+
"id": "evt_test_$(date +%s)",
|
|
23
|
+
"type": "checkout.completed",
|
|
24
|
+
"timestamp": "$TIMESTAMP",
|
|
25
|
+
"data": {
|
|
26
|
+
"checkoutId": "chk_test_123",
|
|
27
|
+
"customerId": "cus_test_456",
|
|
28
|
+
"customerEmail": "test@example.com",
|
|
29
|
+
"subscriptionId": "sub_test_789",
|
|
30
|
+
"productId": "prod_test_abc",
|
|
31
|
+
"amount": 29900,
|
|
32
|
+
"currency": "TWD"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
EOF
|
|
36
|
+
)
|
|
37
|
+
;;
|
|
38
|
+
"subscription.activated")
|
|
39
|
+
PAYLOAD=$(cat <<EOF
|
|
40
|
+
{
|
|
41
|
+
"id": "evt_test_$(date +%s)",
|
|
42
|
+
"type": "subscription.activated",
|
|
43
|
+
"timestamp": "$TIMESTAMP",
|
|
44
|
+
"data": {
|
|
45
|
+
"subscriptionId": "sub_test_789",
|
|
46
|
+
"customerId": "cus_test_456",
|
|
47
|
+
"productId": "prod_test_abc",
|
|
48
|
+
"status": "ACTIVE",
|
|
49
|
+
"currentPeriodStart": "$TIMESTAMP",
|
|
50
|
+
"currentPeriodEnd": "$(date -u -v+1m +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -d '+1 month' +"%Y-%m-%dT%H:%M:%SZ")"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
EOF
|
|
54
|
+
)
|
|
55
|
+
;;
|
|
56
|
+
"subscription.cancelled")
|
|
57
|
+
PAYLOAD=$(cat <<EOF
|
|
58
|
+
{
|
|
59
|
+
"id": "evt_test_$(date +%s)",
|
|
60
|
+
"type": "subscription.cancelled",
|
|
61
|
+
"timestamp": "$TIMESTAMP",
|
|
62
|
+
"data": {
|
|
63
|
+
"subscriptionId": "sub_test_789",
|
|
64
|
+
"customerId": "cus_test_456",
|
|
65
|
+
"cancelledAt": "$TIMESTAMP",
|
|
66
|
+
"accessUntil": "$(date -u -v+1m +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -d '+1 month' +"%Y-%m-%dT%H:%M:%SZ")"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
EOF
|
|
70
|
+
)
|
|
71
|
+
;;
|
|
72
|
+
*)
|
|
73
|
+
PAYLOAD=$(cat <<EOF
|
|
74
|
+
{
|
|
75
|
+
"id": "evt_test_$(date +%s)",
|
|
76
|
+
"type": "$EVENT_TYPE",
|
|
77
|
+
"timestamp": "$TIMESTAMP",
|
|
78
|
+
"data": {}
|
|
79
|
+
}
|
|
80
|
+
EOF
|
|
81
|
+
)
|
|
82
|
+
;;
|
|
83
|
+
esac
|
|
84
|
+
|
|
85
|
+
# Calculate signature
|
|
86
|
+
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
|
|
87
|
+
|
|
88
|
+
echo "📤 Sending test webhook..."
|
|
89
|
+
echo "Endpoint: $ENDPOINT"
|
|
90
|
+
echo "Event: $EVENT_TYPE"
|
|
91
|
+
echo "Signature: ${SIGNATURE:0:20}..."
|
|
92
|
+
echo ""
|
|
93
|
+
|
|
94
|
+
# Send request
|
|
95
|
+
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$ENDPOINT" \
|
|
96
|
+
-H "Content-Type: application/json" \
|
|
97
|
+
-H "x-recur-signature: $SIGNATURE" \
|
|
98
|
+
-d "$PAYLOAD")
|
|
99
|
+
|
|
100
|
+
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
|
101
|
+
BODY=$(echo "$RESPONSE" | sed '$d')
|
|
102
|
+
|
|
103
|
+
echo "Response: $HTTP_CODE"
|
|
104
|
+
echo "$BODY"
|
|
105
|
+
echo ""
|
|
106
|
+
|
|
107
|
+
if [ "$HTTP_CODE" = "200" ]; then
|
|
108
|
+
echo "✅ Webhook delivered successfully!"
|
|
109
|
+
else
|
|
110
|
+
echo "❌ Webhook delivery failed"
|
|
111
|
+
fi
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify Recur webhook signature
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* npx tsx verify-signature.ts <payload> <signature> <secret>
|
|
6
|
+
*
|
|
7
|
+
* Example:
|
|
8
|
+
* npx tsx verify-signature.ts '{"type":"checkout.completed"}' 'abc123...' 'whsec_xxx'
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import crypto from 'crypto'
|
|
12
|
+
|
|
13
|
+
function verifySignature(payload: string, signature: string, secret: string): boolean {
|
|
14
|
+
const expected = crypto.createHmac('sha256', secret).update(payload).digest('hex')
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
|
|
18
|
+
} catch {
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function main() {
|
|
24
|
+
const args = process.argv.slice(2)
|
|
25
|
+
|
|
26
|
+
if (args.length < 3) {
|
|
27
|
+
console.log('Usage: npx tsx verify-signature.ts <payload> <signature> <secret>')
|
|
28
|
+
console.log('')
|
|
29
|
+
console.log('Example:')
|
|
30
|
+
console.log(
|
|
31
|
+
" npx tsx verify-signature.ts '{\"type\":\"test\"}' 'abc123' 'whsec_xxx'"
|
|
32
|
+
)
|
|
33
|
+
process.exit(1)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const [payload, signature, secret] = args
|
|
37
|
+
|
|
38
|
+
console.log('Payload:', payload.substring(0, 50) + '...')
|
|
39
|
+
console.log('Signature:', signature.substring(0, 20) + '...')
|
|
40
|
+
console.log('')
|
|
41
|
+
|
|
42
|
+
const isValid = verifySignature(payload, signature, secret)
|
|
43
|
+
|
|
44
|
+
if (isValid) {
|
|
45
|
+
console.log('✅ Signature is VALID')
|
|
46
|
+
} else {
|
|
47
|
+
console.log('❌ Signature is INVALID')
|
|
48
|
+
|
|
49
|
+
// Show expected signature for debugging
|
|
50
|
+
const expected = crypto.createHmac('sha256', secret).update(payload).digest('hex')
|
|
51
|
+
console.log('')
|
|
52
|
+
console.log('Expected:', expected)
|
|
53
|
+
console.log('Received:', signature)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
process.exit(isValid ? 0 : 1)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
main()
|