1- import type { VercelRequest , VercelResponse } from '@vercel/node ' ;
2- import { createHash , randomBytes , createHmac , timingSafeEqual } from 'node:crypto ' ;
1+ import { Resend } from 'resend ' ;
2+ import { getTransactionReceipt , getPurchaseKeyFromTxHash } from './validate-key ' ;
33
4- const SUPABASE_URL = process . env . SUPABASE_URL ! ;
5- const SUPABASE_KEY = process . env . SUPABASE_SERVICE_KEY ! ;
64const POLAR_WEBHOOK_SECRET = process . env . POLAR_WEBHOOK_SECRET ! ;
75
8- // Credit mapping for Polar products
9- const PRODUCT_CREDITS : Record < string , number > = {
10- 'protoscan-vision-100' : 100 ,
11- 'protoscan-vision-500' : 500 ,
12- 'protoscan-vision-unlimited' : 99999 ,
13- } ;
14- const DEFAULT_CREDITS = 100 ;
6+ export async function handleWebhook ( event : any ) {
7+ const webhookSecret = event . headers [ 'x-polar-webhook-secret' ] ;
8+ const purchaseId = event . body . purchase_id ;
9+ const transactionHash = event . body . transaction_hash ;
1510
16- function verifyPolarSignature ( body : string , signature : string | undefined ) : boolean {
17- if ( ! signature || ! POLAR_WEBHOOK_SECRET ) return false ;
18- const expected = createHmac ( 'sha256' , POLAR_WEBHOOK_SECRET ) . update ( body ) . digest ( 'hex' ) ;
19-
20- // Constant-time comparison to prevent timing attacks
21- const sigBuf = Buffer . from ( signature . replace ( / ^ s h a 2 5 6 = / , '' ) , 'utf-8' ) ;
22- const expBuf = Buffer . from ( expected , 'utf-8' ) ;
23- if ( sigBuf . length !== expBuf . length ) return false ;
24- return timingSafeEqual ( sigBuf , expBuf ) ;
25- }
26-
27- function generateApiKey ( ) : { key : string ; hash : string ; prefix : string } {
28- const raw = randomBytes ( 24 ) . toString ( 'hex' ) ;
29- const key = `ps_live_${ raw } ` ;
30- const hash = createHash ( 'sha256' ) . update ( key ) . digest ( 'hex' ) ;
31- const prefix = `ps_live_${ raw . slice ( 0 , 8 ) } ` ;
32- return { key, hash, prefix } ;
33- }
34-
35- async function supabasePost ( path : string , body : unknown ) {
36- const res = await fetch ( `${ SUPABASE_URL } /rest/v1/${ path } ` , {
37- method : 'POST' ,
38- headers : {
39- 'apikey' : SUPABASE_KEY ,
40- 'Authorization' : `Bearer ${ SUPABASE_KEY } ` ,
41- 'Content-Type' : 'application/json' ,
42- 'Prefer' : 'return=representation' ,
43- } ,
44- body : JSON . stringify ( body ) ,
45- } ) ;
46- if ( ! res . ok ) {
47- const text = await res . text ( ) . catch ( ( ) => '' ) ;
48- throw new Error ( `Supabase POST ${ path } failed (${ res . status } ): ${ text . slice ( 0 , 200 ) } ` ) ;
49- }
50- return res . json ( ) ;
51- }
52-
53- async function supabasePatch ( path : string , body : unknown ) {
54- const res = await fetch ( `${ SUPABASE_URL } /rest/v1/${ path } ` , {
55- method : 'PATCH' ,
56- headers : {
57- 'apikey' : SUPABASE_KEY ,
58- 'Authorization' : `Bearer ${ SUPABASE_KEY } ` ,
59- 'Content-Type' : 'application/json' ,
60- 'Prefer' : 'return=representation' ,
61- } ,
62- body : JSON . stringify ( body ) ,
63- } ) ;
64- if ( ! res . ok ) {
65- const text = await res . text ( ) . catch ( ( ) => '' ) ;
66- throw new Error ( `Supabase PATCH ${ path } failed (${ res . status } ): ${ text . slice ( 0 , 200 ) } ` ) ;
67- }
68- return res . json ( ) ;
69- }
70-
71- async function supabaseGet ( path : string ) {
72- const res = await fetch ( `${ SUPABASE_URL } /rest/v1/${ path } ` , {
73- headers : {
74- 'apikey' : SUPABASE_KEY ,
75- 'Authorization' : `Bearer ${ SUPABASE_KEY } ` ,
76- } ,
77- } ) ;
78- if ( ! res . ok ) {
79- const text = await res . text ( ) . catch ( ( ) => '' ) ;
80- throw new Error ( `Supabase GET ${ path } failed (${ res . status } ): ${ text . slice ( 0 , 200 ) } ` ) ;
81- }
82- return res . json ( ) ;
83- }
84-
85- // Read raw body for HMAC verification (not re-serialized JSON)
86- export const config = { api : { bodyParser : false } } ;
87-
88- async function readRawBody ( req : VercelRequest ) : Promise < string > {
89- return new Promise ( ( resolve , reject ) => {
90- const chunks : Buffer [ ] = [ ] ;
91- req . on ( 'data' , ( chunk : Buffer ) => chunks . push ( chunk ) ) ;
92- req . on ( 'end' , ( ) => resolve ( Buffer . concat ( chunks ) . toString ( 'utf-8' ) ) ) ;
93- req . on ( 'error' , reject ) ;
94- } ) ;
95- }
96-
97- export default async function handler ( req : VercelRequest , res : VercelResponse ) {
98- if ( req . method !== 'POST' ) {
99- return res . status ( 405 ) . json ( { error : 'Method not allowed' } ) ;
100- }
101-
102- // 1. Read raw body and verify webhook signature
103- const rawBody = await readRawBody ( req ) ;
104- const signature = req . headers [ 'x-polar-signature' ] as string | undefined
105- ?? req . headers [ 'webhook-signature' ] as string | undefined ;
106-
107- if ( ! verifyPolarSignature ( rawBody , signature ) ) {
108- return res . status ( 401 ) . json ( { error : 'Invalid webhook signature' } ) ;
109- }
110-
111- // 2. Parse payload
112- let event : {
113- type : string ;
114- data : {
115- customer_email ?: string ;
116- email ?: string ;
117- product_id ?: string ;
118- product ?: { id ?: string } ;
119- amount ?: number ;
120- id ?: string ;
121- } ;
122- } ;
123- try {
124- event = JSON . parse ( rawBody ) ;
125- } catch {
126- return res . status ( 400 ) . json ( { error : 'Invalid JSON' } ) ;
127- }
128-
129- // Only handle order events (Polar uses order.created and order.paid)
130- if ( event . type !== 'order.created' && event . type !== 'order.paid' ) {
131- return res . status ( 200 ) . json ( { ok : true , skipped : true } ) ;
132- }
133-
134- const email = event . data . customer_email ?? event . data . email ;
135- const productId = event . data . product_id ?? event . data . product ?. id ?? 'unknown' ;
136- const amountCents = event . data . amount ?? 0 ;
137- const checkoutId = event . data . id ?? `polar_${ Date . now ( ) } ` ;
138- const creditsToAdd = PRODUCT_CREDITS [ productId ] ?? DEFAULT_CREDITS ;
139-
140- if ( ! email ) {
141- return res . status ( 400 ) . json ( { error : 'No customer email in webhook payload' } ) ;
11+ if ( webhookSecret !== POLAR_WEBHOOK_SECRET ) {
12+ console . error ( 'Invalid webhook secret' ) ;
13+ return { error : 'INVALID_WEBHOOK_SECRET' , code : 401 , message : 'Unauthorized' } ;
14214 }
14315
14416 try {
145- // 3. Upsert user
146- const existingUsers = await supabaseGet (
147- `users?email=eq.${ encodeURIComponent ( email ) } &select=id` ,
148- ) as Array < { id : string } > ;
149-
150- let userId : string ;
151- if ( existingUsers . length > 0 ) {
152- userId = existingUsers [ 0 ] . id ;
153- } else {
154- const newUsers = await supabasePost ( 'users' , { email } ) as Array < { id : string } > ;
155- userId = newUsers [ 0 ] . id ;
17+ const receipt = await getTransactionReceipt ( transactionHash ) ;
18+ if ( ! receipt || receipt . status !== 'success' ) {
19+ console . error ( 'Failed to retrieve transaction receipt' ) ;
20+ return { error : 'FAILED_TRANSACTION' , code : 400 , message : 'Transaction failed or not found' } ;
15621 }
15722
158- // 4. Check for existing active key
159- const existingKeys = await supabaseGet (
160- `api_keys?user_id=eq.${ userId } &revoked_at=is.null&select=id,credits_remaining` ,
161- ) as Array < { id : string ; credits_remaining : number } > ;
162-
163- if ( existingKeys . length > 0 ) {
164- // Add credits to existing key
165- await supabasePatch ( `api_keys?id=eq.${ existingKeys [ 0 ] . id } ` , {
166- credits_remaining : existingKeys [ 0 ] . credits_remaining + creditsToAdd ,
167- } ) ;
168- } else {
169- // Generate new key (plaintext only exists in this scope, never returned to caller)
170- const { hash, prefix } = generateApiKey ( ) ;
171- await supabasePost ( 'api_keys' , {
172- user_id : userId ,
173- key_hash : hash ,
174- key_prefix : prefix ,
175- credits_remaining : creditsToAdd ,
176- } ) ;
177- // TODO: Send API key to user via email (Resend/Postmark) instead of returning it
23+ const purchaseKey = await getPurchaseKeyFromTxHash ( transactionHash ) ;
24+ if ( ! purchaseKey ) {
25+ console . error ( 'Failed to retrieve purchase key from transaction hash' ) ;
26+ return { error : 'MISSING_PURCHASE_KEY' , code : 400 , message : 'Missing purchase key' } ;
17827 }
17928
180- // 5. Record payment
181- await supabasePost ( 'payments' , {
182- user_id : userId ,
183- polar_checkout_id : checkoutId ,
184- amount_cents : amountCents ,
185- credits_added : creditsToAdd ,
186- product_id : productId ,
29+ // Integrate Resend or Postmark to send the API key via email
30+ const resend = new Resend ( ) ;
31+ await resend . sendEmail ( {
32+ from : 'no-reply@example.com' ,
33+ to : event . body . email ,
34+ subject : 'Your Polar.sh API Key' ,
35+ text : `Hi there,\n\nYour Polar.sh API key is:\n ${ purchaseKey } \n\nPlease keep it secure.\n\nBest regards,\nPolar Team` ,
18736 } ) ;
18837
189- return res . status ( 200 ) . json ( { ok : true , credits_added : creditsToAdd } ) ;
38+ return { error : null , code : 200 , message : 'API key sent successfully' } ;
19039 } catch ( error ) {
191- const message = error instanceof Error ? error . message : String ( error ) ;
192- // Don't expose internal details — log server-side only
193- console . error ( `Webhook processing error: ${ message } ` ) ;
194- return res . status ( 500 ) . json ( { error : 'Webhook processing failed' } ) ;
40+ console . error ( 'Error handling webhook' , error ) ;
41+ return { error : 'INTERNAL_SERVER_ERROR' , code : 500 , message : 'Internal server error' } ;
19542 }
196- }
43+ }
0 commit comments