@@ -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