Skip to content

Commit 7398965

Browse files
committed
Wrap webhook handler in try-catch
1 parent 9db450b commit 7398965

1 file changed

Lines changed: 79 additions & 61 deletions

File tree

apps/license-server/src/index.ts

Lines changed: 79 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -167,82 +167,100 @@ app.post('/webhook/paddle', async (c) => {
167167
return c.json({ error: 'Invalid signature' }, 401)
168168
}
169169

170-
const payload = JSON.parse(body) as PaddleWebhookPayload
170+
let payload: PaddleWebhookPayload
171+
try {
172+
payload = JSON.parse(body) as PaddleWebhookPayload
173+
} catch {
174+
console.error('Failed to parse webhook body as JSON')
175+
return c.json({ error: 'Invalid JSON' }, 400)
176+
}
171177
console.log('Received webhook:', payload.event_type)
172178

173179
// Only handle completed purchases
174180
if (payload.event_type !== 'transaction.completed') {
175181
return c.json({ status: 'ignored', event: payload.event_type })
176182
}
177183

178-
// Extract purchase data from webhook
179-
const purchaseData = extractPurchaseData(payload)
180-
if (!purchaseData) {
181-
console.error('Missing customer_id or transaction ID in webhook payload')
182-
return c.json({ error: 'Missing customer_id or transaction ID' }, 400)
183-
}
184+
try {
185+
// Extract purchase data from webhook
186+
const purchaseData = extractPurchaseData(payload)
187+
if (!purchaseData) {
188+
console.error('Missing customer_id or transaction ID in webhook payload')
189+
return c.json({ error: 'Missing customer_id or transaction ID' }, 400)
190+
}
184191

185-
// Idempotency: skip if this transaction was already processed
186-
const idempotencyKey = `transaction:${purchaseData.transactionId}`
187-
const alreadyProcessed = await c.env.LICENSE_CODES.get(idempotencyKey)
188-
if (alreadyProcessed) {
189-
console.log('Transaction already processed:', purchaseData.transactionId)
190-
return c.json({ status: 'already_processed', transactionId: purchaseData.transactionId })
191-
}
192+
// Idempotency: skip if this transaction was already processed
193+
const idempotencyKey = `transaction:${purchaseData.transactionId}`
194+
const alreadyProcessed = await c.env.LICENSE_CODES.get(idempotencyKey)
195+
if (alreadyProcessed) {
196+
console.log('Transaction already processed:', purchaseData.transactionId)
197+
return c.json({ status: 'already_processed', transactionId: purchaseData.transactionId })
198+
}
192199

193-
console.log('Processing transaction:', purchaseData.transactionId, 'for customer:', purchaseData.customerId)
200+
console.log('Processing transaction:', purchaseData.transactionId, 'for customer:', purchaseData.customerId)
194201

195-
// Determine Paddle API config (sandbox vs live based on transaction ID)
196-
const paddleConfig = getPaddleConfig(purchaseData.transactionId, c.env)
197-
if (!paddleConfig) {
198-
console.error('No Paddle API key configured')
199-
return c.json({ error: 'Server configuration error' }, 500)
200-
}
202+
// Determine Paddle API config (sandbox vs live based on transaction ID)
203+
const paddleConfig = getPaddleConfig(purchaseData.transactionId, c.env)
204+
if (!paddleConfig) {
205+
console.error('No Paddle API key configured')
206+
return c.json({ error: 'Server configuration error' }, 500)
207+
}
201208

202-
// Fetch customer details from Paddle API
203-
const customer = await getCustomerDetails(purchaseData.customerId, paddleConfig)
204-
if (!customer) {
205-
console.error('Failed to fetch customer details for:', purchaseData.customerId)
206-
return c.json({ error: 'Failed to fetch customer details' }, 500)
207-
}
209+
// Fetch customer details from Paddle API
210+
const customer = await getCustomerDetails(purchaseData.customerId, paddleConfig)
211+
if (!customer) {
212+
console.error('Failed to fetch customer details for:', purchaseData.customerId)
213+
return c.json({ error: 'Failed to fetch customer details' }, 500)
214+
}
208215

209-
console.log('Customer email:', customer.email, 'business:', customer.businessName)
216+
console.log('Customer email:', customer.email, 'business:', customer.businessName)
210217

211-
// Determine license type from price ID
212-
const priceIds: PriceIdMapping = {
213-
supporter: c.env.PRICE_ID_SUPPORTER,
214-
commercialSubscription: c.env.PRICE_ID_COMMERCIAL_SUBSCRIPTION,
215-
commercialPerpetual: c.env.PRICE_ID_COMMERCIAL_PERPETUAL,
216-
}
217-
const licenseType = purchaseData.priceId
218-
? getLicenseTypeFromPriceId(purchaseData.priceId, priceIds)
219-
: 'commercial_subscription'
220-
221-
// Get organization name: prefer customer's business name, fall back to custom_data
222-
const organizationName =
223-
licenseType !== 'supporter' ? (customer.businessName ?? purchaseData.organizationName) : undefined
224-
225-
// Generate and send license(s) - one per quantity
226-
const result = await generateAndSendLicenses({
227-
customerEmail: customer.email,
228-
customerName: customer.name ?? 'there',
229-
transactionId: purchaseData.transactionId,
230-
quantity: purchaseData.quantity,
231-
licenseType: licenseType ?? 'commercial_subscription',
232-
organizationName,
233-
privateKey: c.env.ED25519_PRIVATE_KEY,
234-
productName: c.env.PRODUCT_NAME,
235-
supportEmail: c.env.SUPPORT_EMAIL,
236-
resendApiKey: c.env.RESEND_API_KEY,
237-
kv: c.env.LICENSE_CODES,
238-
})
218+
// Determine license type from price ID
219+
const priceIds: PriceIdMapping = {
220+
supporter: c.env.PRICE_ID_SUPPORTER,
221+
commercialSubscription: c.env.PRICE_ID_COMMERCIAL_SUBSCRIPTION,
222+
commercialPerpetual: c.env.PRICE_ID_COMMERCIAL_PERPETUAL,
223+
}
224+
const licenseType = purchaseData.priceId
225+
? getLicenseTypeFromPriceId(purchaseData.priceId, priceIds)
226+
: 'commercial_subscription'
227+
228+
// Get organization name: prefer customer's business name, fall back to custom_data
229+
const organizationName =
230+
licenseType !== 'supporter' ? (customer.businessName ?? purchaseData.organizationName) : undefined
231+
232+
// Generate and send license(s) - one per quantity
233+
// If email fails after KV writes, we intentionally don't mark the transaction as processed
234+
// so the next Paddle retry will re-generate and re-send the licenses.
235+
const result = await generateAndSendLicenses({
236+
customerEmail: customer.email,
237+
customerName: customer.name ?? 'there',
238+
transactionId: purchaseData.transactionId,
239+
quantity: purchaseData.quantity,
240+
licenseType: licenseType ?? 'commercial_subscription',
241+
organizationName,
242+
privateKey: c.env.ED25519_PRIVATE_KEY,
243+
productName: c.env.PRODUCT_NAME,
244+
supportEmail: c.env.SUPPORT_EMAIL,
245+
resendApiKey: c.env.RESEND_API_KEY,
246+
kv: c.env.LICENSE_CODES,
247+
})
239248

240-
// Mark transaction as processed (7-day TTL)
241-
const sevenDaysInSeconds = 604_800
242-
await c.env.LICENSE_CODES.put(idempotencyKey, 'processed', { expirationTtl: sevenDaysInSeconds })
249+
// Mark transaction as processed (7-day TTL)
250+
const sevenDaysInSeconds = 604_800
251+
await c.env.LICENSE_CODES.put(idempotencyKey, 'processed', { expirationTtl: sevenDaysInSeconds })
243252

244-
console.log('Licenses sent to:', customer.email, 'type:', result.licenseType, 'quantity:', result.quantity)
245-
return c.json({ status: 'ok', email: customer.email, licenseType: result.licenseType, quantity: result.quantity })
253+
console.log('Licenses sent to:', customer.email, 'type:', result.licenseType, 'quantity:', result.quantity)
254+
return c.json({
255+
status: 'ok',
256+
email: customer.email,
257+
licenseType: result.licenseType,
258+
quantity: result.quantity,
259+
})
260+
} catch (error) {
261+
console.error('Webhook processing failed:', error instanceof Error ? error.message : String(error))
262+
return c.json({ error: 'Internal server error' }, 500)
263+
}
246264
})
247265

248266
/** Extract purchase data from webhook payload (customer fetched separately via API) */

0 commit comments

Comments
 (0)