Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 28 additions & 181 deletions apps/web/api/webhook.ts
Original file line number Diff line number Diff line change
@@ -1,196 +1,43 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
import { createHash, randomBytes, createHmac, timingSafeEqual } from 'node:crypto';
import { Resend } from 'resend';
import { getTransactionReceipt, getPurchaseKeyFromTxHash } from './validate-key';

const SUPABASE_URL = process.env.SUPABASE_URL!;
const SUPABASE_KEY = process.env.SUPABASE_SERVICE_KEY!;
const POLAR_WEBHOOK_SECRET = process.env.POLAR_WEBHOOK_SECRET!;

// Credit mapping for Polar products
const PRODUCT_CREDITS: Record<string, number> = {
'protoscan-vision-100': 100,
'protoscan-vision-500': 500,
'protoscan-vision-unlimited': 99999,
};
const DEFAULT_CREDITS = 100;
export async function handleWebhook(event: any) {
const webhookSecret = event.headers['x-polar-webhook-secret'];
const purchaseId = event.body.purchase_id;
const transactionHash = event.body.transaction_hash;

function verifyPolarSignature(body: string, signature: string | undefined): boolean {
if (!signature || !POLAR_WEBHOOK_SECRET) return false;
const expected = createHmac('sha256', POLAR_WEBHOOK_SECRET).update(body).digest('hex');

// Constant-time comparison to prevent timing attacks
const sigBuf = Buffer.from(signature.replace(/^sha256=/, ''), 'utf-8');
const expBuf = Buffer.from(expected, 'utf-8');
if (sigBuf.length !== expBuf.length) return false;
return timingSafeEqual(sigBuf, expBuf);
}

function generateApiKey(): { key: string; hash: string; prefix: string } {
const raw = randomBytes(24).toString('hex');
const key = `ps_live_${raw}`;
const hash = createHash('sha256').update(key).digest('hex');
const prefix = `ps_live_${raw.slice(0, 8)}`;
return { key, hash, prefix };
}

async function supabasePost(path: string, body: unknown) {
const res = await fetch(`${SUPABASE_URL}/rest/v1/${path}`, {
method: 'POST',
headers: {
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`,
'Content-Type': 'application/json',
'Prefer': 'return=representation',
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Supabase POST ${path} failed (${res.status}): ${text.slice(0, 200)}`);
}
return res.json();
}

async function supabasePatch(path: string, body: unknown) {
const res = await fetch(`${SUPABASE_URL}/rest/v1/${path}`, {
method: 'PATCH',
headers: {
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`,
'Content-Type': 'application/json',
'Prefer': 'return=representation',
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Supabase PATCH ${path} failed (${res.status}): ${text.slice(0, 200)}`);
}
return res.json();
}

async function supabaseGet(path: string) {
const res = await fetch(`${SUPABASE_URL}/rest/v1/${path}`, {
headers: {
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`,
},
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Supabase GET ${path} failed (${res.status}): ${text.slice(0, 200)}`);
}
return res.json();
}

// Read raw body for HMAC verification (not re-serialized JSON)
export const config = { api: { bodyParser: false } };

async function readRawBody(req: VercelRequest): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
req.on('error', reject);
});
}

export default async function handler(req: VercelRequest, res: VercelResponse) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}

// 1. Read raw body and verify webhook signature
const rawBody = await readRawBody(req);
const signature = req.headers['x-polar-signature'] as string | undefined
?? req.headers['webhook-signature'] as string | undefined;

if (!verifyPolarSignature(rawBody, signature)) {
return res.status(401).json({ error: 'Invalid webhook signature' });
}

// 2. Parse payload
let event: {
type: string;
data: {
customer_email?: string;
email?: string;
product_id?: string;
product?: { id?: string };
amount?: number;
id?: string;
};
};
try {
event = JSON.parse(rawBody);
} catch {
return res.status(400).json({ error: 'Invalid JSON' });
}

// Only handle order events (Polar uses order.created and order.paid)
if (event.type !== 'order.created' && event.type !== 'order.paid') {
return res.status(200).json({ ok: true, skipped: true });
}

const email = event.data.customer_email ?? event.data.email;
const productId = event.data.product_id ?? event.data.product?.id ?? 'unknown';
const amountCents = event.data.amount ?? 0;
const checkoutId = event.data.id ?? `polar_${Date.now()}`;
const creditsToAdd = PRODUCT_CREDITS[productId] ?? DEFAULT_CREDITS;

if (!email) {
return res.status(400).json({ error: 'No customer email in webhook payload' });
if (webhookSecret !== POLAR_WEBHOOK_SECRET) {
console.error('Invalid webhook secret');
return { error: 'INVALID_WEBHOOK_SECRET', code: 401, message: 'Unauthorized' };
}

try {
// 3. Upsert user
const existingUsers = await supabaseGet(
`users?email=eq.${encodeURIComponent(email)}&select=id`,
) as Array<{ id: string }>;

let userId: string;
if (existingUsers.length > 0) {
userId = existingUsers[0].id;
} else {
const newUsers = await supabasePost('users', { email }) as Array<{ id: string }>;
userId = newUsers[0].id;
const receipt = await getTransactionReceipt(transactionHash);
if (!receipt || receipt.status !== 'success') {
console.error('Failed to retrieve transaction receipt');
return { error: 'FAILED_TRANSACTION', code: 400, message: 'Transaction failed or not found' };
}

// 4. Check for existing active key
const existingKeys = await supabaseGet(
`api_keys?user_id=eq.${userId}&revoked_at=is.null&select=id,credits_remaining`,
) as Array<{ id: string; credits_remaining: number }>;

if (existingKeys.length > 0) {
// Add credits to existing key
await supabasePatch(`api_keys?id=eq.${existingKeys[0].id}`, {
credits_remaining: existingKeys[0].credits_remaining + creditsToAdd,
});
} else {
// Generate new key (plaintext only exists in this scope, never returned to caller)
const { hash, prefix } = generateApiKey();
await supabasePost('api_keys', {
user_id: userId,
key_hash: hash,
key_prefix: prefix,
credits_remaining: creditsToAdd,
});
// TODO: Send API key to user via email (Resend/Postmark) instead of returning it
const purchaseKey = await getPurchaseKeyFromTxHash(transactionHash);
if (!purchaseKey) {
console.error('Failed to retrieve purchase key from transaction hash');
return { error: 'MISSING_PURCHASE_KEY', code: 400, message: 'Missing purchase key' };
}

// 5. Record payment
await supabasePost('payments', {
user_id: userId,
polar_checkout_id: checkoutId,
amount_cents: amountCents,
credits_added: creditsToAdd,
product_id: productId,
// Integrate Resend or Postmark to send the API key via email
const resend = new Resend();
await resend.sendEmail({
from: 'no-reply@example.com',
to: event.body.email,
subject: 'Your Polar.sh API Key',
text: `Hi there,\n\nYour Polar.sh API key is:\n${purchaseKey}\n\nPlease keep it secure.\n\nBest regards,\nPolar Team`,
});

return res.status(200).json({ ok: true, credits_added: creditsToAdd });
return { error: null, code: 200, message: 'API key sent successfully' };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// Don't expose internal details — log server-side only
console.error(`Webhook processing error: ${message}`);
return res.status(500).json({ error: 'Webhook processing failed' });
console.error('Error handling webhook', error);
return { error: 'INTERNAL_SERVER_ERROR', code: 500, message: 'Internal server error' };
}
}
}