Skip to content

Commit 1ee4597

Browse files
committed
Fix: Send API key via email instead of webhook response (closes #1)
1 parent 9549589 commit 1ee4597

1 file changed

Lines changed: 28 additions & 181 deletions

File tree

apps/web/api/webhook.ts

Lines changed: 28 additions & 181 deletions
Original file line numberDiff line numberDiff line change
@@ -1,196 +1,43 @@
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!;
64
const 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(/^sha256=/, ''), '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

Comments
 (0)