Files
plunk/apps/api/src/controllers/Webhooks.ts
T

791 lines
32 KiB
TypeScript

import {Controller, Post} from '@overnightjs/core';
import type {Prisma} from '@plunk/db';
import {EmailSourceType, EmailStatus} from '@plunk/db';
import type {Request, Response} from 'express';
import {simpleParser} from 'mailparser';
import sanitizeHtml from 'sanitize-html';
import signale from 'signale';
import type Stripe from 'stripe';
import {ProjectDisabledPaymentEmail, sendPlatformEmail} from '@plunk/email';
import React from 'react';
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js';
import {stripe} from '../app/stripe.js';
import {prisma} from '../database/prisma.js';
import {BillingLimitService} from '../services/BillingLimitService.js';
import {ContactService} from '../services/ContactService.js';
import {EventService} from '../services/EventService.js';
import {MembershipService} from '../services/MembershipService.js';
import {MeterService} from '../services/MeterService.js';
import {NtfyService} from '../services/NtfyService.js';
import {SecurityService} from '../services/SecurityService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
/**
* Webhooks Controller
* Handles incoming webhooks from external services (AWS SNS/SES)
*/
@Controller('webhooks')
export class Webhooks {
/**
* Receive SNS webhook notifications from AWS SES
* Handles outbound email events: delivery, open, click, bounce, complaint
* Handles inbound email notifications: received emails via SES receiving
*/
@Post('sns')
@CatchAsync
public async receiveSNSWebhook(req: Request, res: Response) {
try {
// Verify SNS message signature before processing anything
const signatureValid = await SecurityService.verifySnsSignature(req.body as Record<string, string>);
if (!signatureValid) {
signale.warn('[WEBHOOK] SNS signature verification failed — request rejected');
return res.status(403).json({success: false, message: 'Invalid SNS signature'});
}
// Handle SNS subscription confirmation FIRST (before parsing Message field)
if (req.body.Type === 'SubscriptionConfirmation') {
signale.info('SNS Subscription Confirmation received');
// Validate SubscribeURL to prevent SSRF: must be HTTPS and from an official AWS SNS host.
// Legitimate URLs look like:
// https://sns.<region>.amazonaws.com/?Action=ConfirmSubscription&...
const subscribeURL: unknown = req.body.SubscribeURL;
if (typeof subscribeURL !== 'string') {
signale.warn('SNS SubscriptionConfirmation missing SubscribeURL');
return res.status(400).json({success: false, message: 'Invalid SubscribeURL'});
}
let parsedURL: URL;
try {
parsedURL = new URL(subscribeURL);
} catch {
signale.warn('SNS SubscriptionConfirmation has unparseable SubscribeURL');
return res.status(400).json({success: false, message: 'Invalid SubscribeURL'});
}
// Only allow HTTPS requests to official AWS SNS endpoints.
// The hostname must be exactly sns.<region>.amazonaws.com or sns.<region>.amazonaws.eu
const SNS_HOST_RE = /^sns\.[a-z0-9-]+\.amazonaws\.(com|eu)$/;
if (parsedURL.protocol !== 'https:' || !SNS_HOST_RE.test(parsedURL.hostname)) {
signale.warn(`SNS SubscriptionConfirmation rejected — disallowed SubscribeURL host: ${parsedURL.hostname}`);
return res.status(400).json({success: false, message: 'Invalid SubscribeURL'});
}
// Automatically confirm the subscription
try {
const confirmResponse = await fetch(subscribeURL);
if (confirmResponse.ok) {
signale.success('SNS subscription confirmed successfully');
return res.status(200).json({
success: true,
message: 'Subscription confirmed',
});
} else {
signale.error('Failed to confirm SNS subscription:', confirmResponse.statusText);
return res.status(200).json({
success: false,
message: 'Failed to confirm subscription',
});
}
} catch (confirmError) {
signale.error('Error confirming SNS subscription:', confirmError);
return res.status(200).json({
success: false,
message: 'Error confirming subscription',
});
}
}
// Handle SNS notification messages - parse the Message field
if (req.body.Type !== 'Notification') {
signale.warn('[WEBHOOK] Unknown SNS message type:', req.body.Type);
return res.status(200).json({success: false, message: 'Unknown message type'});
}
// Parse the nested SES event from the Message field
const body = JSON.parse(req.body.Message);
// Check if this is an inbound email notification (SES Receiving)
if (body.notificationType === 'Received') {
signale.info('[WEBHOOK] Received inbound email notification from SES');
try {
// Extract recipient addresses from the inbound email
const recipients = body.receipt?.recipients || [];
if (recipients.length === 0) {
signale.warn('[WEBHOOK] No recipients found in inbound email');
return res.status(200).json({success: true, message: 'No recipients found'});
}
// For each recipient, identify the domain and create events
for (const recipient of recipients) {
const recipientEmail = recipient as string;
const domain = recipientEmail.split('@')[1];
if (!domain) {
signale.warn('[WEBHOOK] Invalid recipient email format:', recipientEmail);
continue;
}
// Find ALL projects that have this domain verified
// A domain can be shared across multiple projects if users are members of both
const domainRecords = await prisma.domain.findMany({
where: {
domain,
verified: true, // Only process emails for verified domains
},
include: {
project: true,
},
});
if (domainRecords.length === 0) {
signale.info(`[WEBHOOK] No verified domain found for: ${domain}`);
continue;
}
signale.info(
`[WEBHOOK] Found ${domainRecords.length} project(s) with verified domain ${domain}. Processing inbound email for all.`,
);
// Extract sender information (same for all projects)
const senderEmail = body.mail?.source;
const senderFromHeader = body.mail?.commonHeaders?.from?.[0] || senderEmail;
// Parse email content if available
let htmlBody: string | undefined;
if (body.content && typeof body.content === 'string') {
try {
const isBase64 = body.receipt?.action?.encoding === 'BASE64';
const emailBuffer = isBase64
? Buffer.from(body.content, 'base64')
: Buffer.from(body.content);
const parsed = await simpleParser(emailBuffer);
const raw =
(parsed.html ? String(parsed.html) : undefined) ??
parsed.textAsHtml ??
parsed.text ??
undefined;
if (raw) {
htmlBody = sanitizeHtml(raw, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
img: ['src', 'alt', 'width', 'height'],
'*': ['style'],
},
allowedSchemes: ['http', 'https', 'mailto'],
});
}
} catch (parseError) {
signale.error('[WEBHOOK] Failed to parse email content:', parseError);
}
}
// Process inbound email for each project that has this domain verified
for (const domainRecord of domainRecords) {
signale.info(`[WEBHOOK] Processing inbound email for project: ${domainRecord.project.name}`);
// Check billing limits before processing inbound email
const limitCheck = await BillingLimitService.checkLimit(domainRecord.projectId, EmailSourceType.INBOUND);
if (!limitCheck.allowed) {
signale.warn(
`[WEBHOOK] Inbound email blocked for project ${domainRecord.project.name}: ${limitCheck.message}`,
);
continue; // Skip this project but continue processing for other projects
}
// Find or create a contact for the sender in this project
let contact;
if (senderEmail) {
contact = await ContactService.upsert(
domainRecord.projectId,
senderEmail,
undefined, // No additional data
true, // Subscribe by default for inbound email senders
);
}
// Create an Email record for tracking with parsed content
const inboundEmail = await prisma.email.create({
data: {
projectId: domainRecord.projectId,
contactId: contact!.id,
subject: body.mail?.commonHeaders?.subject || '(No subject)',
body: htmlBody || '', // Store HTML body in the body field
from: recipientEmail, // The recipient address that received the email
sourceType: EmailSourceType.INBOUND,
status: EmailStatus.RECEIVED, // Inbound emails use RECEIVED status
deliveredAt: new Date(body.mail?.timestamp || new Date()),
},
});
// Increment usage counter in cache
await BillingLimitService.incrementUsage(domainRecord.projectId, EmailSourceType.INBOUND);
// Record Stripe metering if project has customer
if (domainRecord.project.customer) {
await MeterService.recordEmailSent(
domainRecord.project.customer,
1, // Inbound emails count as 1 credit
`email_${inboundEmail.id}`,
);
}
// Prepare event data with all inbound email details including body content
const eventData = {
messageId: body.mail?.messageId,
from: senderEmail,
fromHeader: senderFromHeader,
to: recipientEmail,
subject: body.mail?.commonHeaders?.subject,
timestamp: body.mail?.timestamp,
recipients: body.receipt?.recipients,
hasContent: !!body.content,
// Email body content
body: htmlBody,
// Security verdicts
spamVerdict: body.receipt?.spamVerdict?.status,
virusVerdict: body.receipt?.virusVerdict?.status,
spfVerdict: body.receipt?.spfVerdict?.status,
dkimVerdict: body.receipt?.dkimVerdict?.status,
dmarcVerdict: body.receipt?.dmarcVerdict?.status,
// Processing metadata
processingTimeMillis: body.receipt?.processingTimeMillis,
};
// Create the email.received event (this will trigger workflows)
await EventService.trackEvent(
domainRecord.projectId,
'email.received',
contact?.id,
inboundEmail.id, // Link the event to the inbound email record
eventData,
);
signale.success(
`[WEBHOOK] Created email.received event for ${senderEmail}${recipientEmail} (project: ${domainRecord.project.name})`,
);
}
}
return res.status(200).json({success: true, message: 'Inbound email processed'});
} catch (inboundError) {
signale.error('[WEBHOOK] Error processing inbound email:', inboundError);
// Return 200 to acknowledge receipt even if processing failed
return res.status(200).json({success: true, message: 'Error processing inbound email'});
}
}
// Handle outbound email event notifications (existing logic)
const eventType = body.eventType as 'Bounce' | 'Delivery' | 'Open' | 'Complaint' | 'Click';
const messageId = body.mail?.messageId;
if (!messageId) {
signale.warn('[WEBHOOK] No messageId found in SNS notification');
return res.status(400).json({success: false, error: 'No messageId found'});
}
// Look up email by SES messageId
const email = await prisma.email.findUnique({
where: {messageId},
include: {
contact: true,
project: true,
},
});
if (!email) {
signale.warn(`[WEBHOOK] Email not found for messageId: ${messageId}`);
return res.status(404).json({success: false, error: 'Email not found'});
}
const now = new Date();
const updateData: Prisma.EmailUpdateInput = {};
const eventName = `email.${eventType.toLowerCase()}`;
// Base event data with email metadata
const baseEventData = {
subject: email.subject,
from: email.from,
fromName: email.fromName,
messageId: email.messageId,
emailId: email.id,
templateId: email.templateId,
campaignId: email.campaignId,
sourceType: email.sourceType,
};
let eventData: Record<string, unknown> = baseEventData;
// Process event based on type
switch (eventType) {
case 'Delivery':
signale.success(`[WEBHOOK] Delivery confirmed for ${email.contact.email} from ${email.project.name}`);
updateData.status = EmailStatus.DELIVERED;
updateData.deliveredAt = now;
eventData = {
...baseEventData,
deliveredAt: now.toISOString(),
};
break;
case 'Open':
signale.success(`[WEBHOOK] Open received for ${email.contact.email} from ${email.project.name}`);
// Only set openedAt on first open
if (!email.openedAt) {
updateData.openedAt = now;
}
updateData.opens = (email.opens || 0) + 1;
updateData.status = EmailStatus.OPENED;
eventData = {
...baseEventData,
openedAt: email.openedAt?.toISOString() || now.toISOString(),
opens: (email.opens || 0) + 1,
isFirstOpen: !email.openedAt,
};
break;
case 'Click': {
signale.success(`[WEBHOOK] Click received for ${email.contact.email} from ${email.project.name}`);
const clickedLink = body.click?.link;
// Only set clickedAt on first click
if (!email.clickedAt) {
updateData.clickedAt = now;
}
updateData.clicks = (email.clicks || 0) + 1;
updateData.status = EmailStatus.CLICKED;
eventData = {
...baseEventData,
link: clickedLink,
clickedAt: email.clickedAt?.toISOString() || now.toISOString(),
clicks: (email.clicks || 0) + 1,
isFirstClick: !email.clickedAt,
};
break;
}
case 'Bounce': {
const bounceType = body.bounce?.bounceType;
const isPermanentBounce = bounceType === 'Permanent';
const isTransientBounce = bounceType === 'Transient';
if (isPermanentBounce) {
// Hard bounce - counts toward bounce rate and unsubscribes contact
signale.warn(`[WEBHOOK] Permanent bounce received for ${email.contact.email} from ${email.project.name}`);
updateData.status = EmailStatus.BOUNCED;
updateData.bouncedAt = now;
// Unsubscribe contact on permanent bounce
await prisma.contact.update({
where: {id: email.contactId},
data: {subscribed: false},
});
eventData = {
...baseEventData,
bounceType,
bouncedAt: now.toISOString(),
};
// Send notification about permanent bounce
await NtfyService.notifyEmailBounce(email.project.name, email.projectId, email.contact.email, bounceType);
} else if (isTransientBounce) {
// Soft bounce (e.g., out-of-office, mailbox full) - don't count toward bounce rate
signale.info(
`[WEBHOOK] Transient bounce received for ${email.contact.email} from ${email.project.name} (not counted toward bounce rate)`,
);
// Don't update email status or unsubscribe contact
// Just track the event for visibility
eventData = {
...baseEventData,
bounceType,
transientBounce: true,
};
} else {
// Unknown bounce type - treat as permanent to be safe
signale.warn(
`[WEBHOOK] Unknown bounce type (${bounceType}) received for ${email.contact.email} from ${email.project.name} - treating as permanent`,
);
updateData.status = EmailStatus.BOUNCED;
updateData.bouncedAt = now;
await prisma.contact.update({
where: {id: email.contactId},
data: {subscribed: false},
});
eventData = {
...baseEventData,
bounceType,
bouncedAt: now.toISOString(),
};
await NtfyService.notifyEmailBounce(email.project.name, email.projectId, email.contact.email, bounceType);
}
break;
}
case 'Complaint':
signale.warn(`[WEBHOOK] Complaint received for ${email.contact.email} from ${email.project.name}`);
updateData.status = EmailStatus.COMPLAINED;
updateData.complainedAt = now;
// Unsubscribe contact on complaint
await prisma.contact.update({
where: {id: email.contactId},
data: {subscribed: false},
});
eventData = {
...baseEventData,
complainedAt: now.toISOString(),
};
// Send notification about complaint
await NtfyService.notifyEmailComplaint(email.project.name, email.projectId, email.contact.email);
break;
default:
signale.warn(`[WEBHOOK] Unknown event type: ${eventType}`);
return res.status(200).json({success: true});
}
// Update email with new status and timestamps
await prisma.email.update({
where: {id: email.id},
data: updateData,
});
// Track event (this will trigger workflows)
await EventService.trackEvent(email.projectId, eventName, email.contactId, email.id, eventData);
// Check security limits only for permanent bounces and complaints
// Transient bounces (soft bounces) don't count toward bounce rate
const isPermanentBounce = eventType === 'Bounce' && body.bounce?.bounceType === 'Permanent';
if (isPermanentBounce || eventType === 'Complaint') {
await SecurityService.checkAndEnforceSecurityLimits(email.projectId);
}
signale.success(`[WEBHOOK] Processed ${eventType} event for email ${email.id}`);
return res.status(200).json({success: true});
} catch (error) {
signale.error('[WEBHOOK] Error processing SNS webhook:', error);
// Always return 200 to prevent SNS from retrying
return res.status(200).json({success: true});
}
}
/**
* Receive Stripe webhook notifications
* Handles subscription and payment events: checkout.session.completed, invoice.paid, etc.
*/
@Post('incoming/stripe')
@CatchAsync
public async receiveStripeWebhook(req: Request, res: Response) {
// Return 404 if billing is disabled
if (!STRIPE_ENABLED || !stripe) {
signale.warn('[WEBHOOK] Stripe webhook received but billing is disabled');
return res.status(404).json({success: false, error: 'Billing is disabled'});
}
try {
const sig = req.headers['stripe-signature'];
if (!sig) {
signale.warn('[WEBHOOK] Missing Stripe signature header');
return res.status(400).json({success: false, error: 'Missing signature'});
}
// Verify webhook signature using raw body
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, STRIPE_WEBHOOK_SECRET);
} catch (err) {
signale.error('[WEBHOOK] Stripe signature verification failed:', err);
return res.status(400).json({success: false, error: 'Invalid signature'});
}
signale.info(`[WEBHOOK] Received Stripe event: ${event.type}`);
// Handle different event types
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
const customerId = session.customer as string;
const subscriptionId = session.subscription as string;
const projectId = session.client_reference_id; // Assuming project ID is passed as reference
if (!projectId) {
signale.warn('[WEBHOOK] No client_reference_id in checkout session');
break;
}
// Update project with customer and subscription IDs
const updatedProject = await prisma.project.update({
where: {id: projectId},
data: {
customer: customerId,
subscription: subscriptionId,
},
});
// Base onboarding credit: refund the 1-unit card-verification charge
let creditBalance = -100;
// Switching-offer promo: 2 extra units of credit if the customer typed SWITCH
// into the Promo code custom field on Stripe Checkout.
const promoField = session.custom_fields?.find((f) => f.key === 'promo_code');
const promoCode = promoField?.text?.value?.trim().toUpperCase();
if (promoCode === 'SWITCH') {
creditBalance -= 200;
signale.success(`[WEBHOOK] SWITCH promo applied for project ${projectId}`);
} else if (promoCode) {
signale.info(`[WEBHOOK] Unknown promo code "${promoCode}" entered for project ${projectId}`);
}
// Update Stripe customer name to match project name and add credit for onboarding fee
await stripe.customers.update(customerId, {
name: updatedProject.name,
balance: creditBalance,
});
signale.success(`[WEBHOOK] Checkout completed for project ${projectId}`);
// Send notification about subscription started
await NtfyService.notifySubscriptionStarted(updatedProject.name, projectId, subscriptionId);
break;
}
case 'invoice.paid': {
const invoice = event.data.object;
const customerId = invoice.customer as string;
// Find project by customer ID
const project = await prisma.project.findUnique({
where: {customer: customerId},
});
if (!project) {
signale.warn(`[WEBHOOK] No project found for customer ${customerId}`);
break;
}
signale.success(`[WEBHOOK] Invoice paid for project ${project.name} (${project.id})`);
// Re-enable the project only if it was previously disabled for a failed payment.
// Projects disabled for other reasons (reputation, phishing, manual) must stay disabled.
if (project.disabled && project.disabledReason === 'PAYMENT_FAILED') {
await prisma.project.update({
where: {id: project.id},
data: {disabled: false, disabledReason: null},
});
signale.success(`[WEBHOOK] Project ${project.name} (${project.id}) re-enabled after payment`);
}
// Send notification about invoice payment
await NtfyService.notifyInvoicePaid(project.name, project.id);
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object;
const customerId = invoice.customer as string;
// Only disable projects that are already consuming (recurring billing).
// If billing_reason is 'subscription_create', this is a first-time payment
// attempt and the project has never had an active subscription — don't disable.
if (invoice.billing_reason === 'subscription_create') {
signale.info(
`[WEBHOOK] Payment failed on initial subscription attempt for customer ${customerId}, skipping disable`,
);
break;
}
// Find project by customer ID
const project = await prisma.project.findUnique({
where: {customer: customerId},
});
if (!project) {
signale.warn(`[WEBHOOK] No project found for customer ${customerId}`);
break;
}
signale.warn(`[WEBHOOK] Payment failed for project ${project.name} (${project.id}), disabling project`);
await prisma.project.update({
where: {id: project.id},
data: {disabled: true, disabledReason: 'PAYMENT_FAILED'},
});
await NtfyService.notifyProjectDisabledForPayment(project.name, project.id);
// Send email notification to project members
try {
const members = await MembershipService.getMembers(project.id);
const emails = members.map(m => m.email);
if (emails.length > 0) {
const template = React.createElement(ProjectDisabledPaymentEmail, {
projectName: project.name,
projectId: project.id,
dashboardUrl: DASHBOARD_URI,
landingUrl: LANDING_URI,
});
await Promise.all(
emails.map(email => sendPlatformEmail(email, 'Project Disabled - Payment Failed', template)),
);
}
} catch (emailError) {
signale.error(`[WEBHOOK] Failed to send project disabled email:`, emailError);
}
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object;
const subscriptionId = subscription.id;
// Find project by subscription ID
const project = await prisma.project.findUnique({
where: {subscription: subscriptionId},
});
if (!project) {
signale.warn(`[WEBHOOK] No project found for subscription ${subscriptionId}`);
break;
}
// Clear subscription from project
await prisma.project.update({
where: {id: project.id},
data: {
subscription: null,
},
});
signale.warn(`[WEBHOOK] Subscription deleted for project ${project.name} (${project.id})`);
// Send notification about subscription cancellation
await NtfyService.notifySubscriptionCancelled(project.name, project.id, subscriptionId);
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object;
const subscriptionId = subscription.id;
// Find project by subscription ID
const project = await prisma.project.findUnique({
where: {subscription: subscriptionId},
});
if (!project) {
signale.warn(`[WEBHOOK] No project found for subscription ${subscriptionId}`);
break;
}
signale.info(`[WEBHOOK] Subscription updated for project ${project.name} (${project.id})`);
signale.info(
`[WEBHOOK] Status: ${subscription.status}, Cancel at period end: ${subscription.cancel_at_period_end}`,
);
// Send notification about subscription update
await NtfyService.notifySubscriptionUpdated(project.name, project.id);
break;
}
case 'radar.early_fraud_warning.created': {
const warning = event.data.object as Stripe.Radar.EarlyFraudWarning;
const chargeId = typeof warning.charge === 'string' ? warning.charge : warning.charge?.id;
if (!chargeId) {
signale.warn('[WEBHOOK] radar.early_fraud_warning.created missing charge ID');
break;
}
signale.warn(`[WEBHOOK] Early fraud warning received for charge ${chargeId} (${warning.fraud_type})`);
// Retrieve the charge to get card fingerprint and customer email
const charge = await stripe.charges.retrieve(chargeId, {
expand: ['payment_method_details', 'billing_details'],
});
const cardFingerprint = charge.payment_method_details?.card?.fingerprint ?? null;
const customerEmail = charge.billing_details?.email ?? null;
// Refund the charge
try {
await stripe.refunds.create({charge: chargeId});
signale.success(`[WEBHOOK] Refunded charge ${chargeId} due to early fraud warning`);
} catch (refundError) {
signale.error(`[WEBHOOK] Failed to refund charge ${chargeId}:`, refundError);
}
// Add card fingerprint and email to Stripe Radar blocklist value lists
if (cardFingerprint) {
try {
const lists = await stripe.radar.valueLists.list({alias: 'blocked_card_fingerprints'});
let listId: string;
const existingList = lists.data[0];
if (existingList) {
listId = existingList.id;
} else {
const newList = await stripe.radar.valueLists.create({
alias: 'blocked_card_fingerprints',
name: 'Blocked Card Fingerprints',
item_type: 'card_fingerprint',
});
listId = newList.id;
}
await stripe.radar.valueListItems.create({value_list: listId, value: cardFingerprint});
signale.success(`[WEBHOOK] Added card fingerprint ${cardFingerprint} to Radar blocklist`);
} catch (blocklistError) {
signale.error(`[WEBHOOK] Failed to add card fingerprint to Radar blocklist:`, blocklistError);
}
}
if (customerEmail) {
try {
const emailLists = await stripe.radar.valueLists.list({alias: 'blocked_emails'});
let emailListId: string;
const existingEmailList = emailLists.data[0];
if (existingEmailList) {
emailListId = existingEmailList.id;
} else {
const newList = await stripe.radar.valueLists.create({
alias: 'blocked_emails',
name: 'Blocked Emails',
item_type: 'email',
});
emailListId = newList.id;
}
await stripe.radar.valueListItems.create({value_list: emailListId, value: customerEmail});
signale.success(`[WEBHOOK] Added email ${customerEmail} to Radar blocklist`);
} catch (blocklistError) {
signale.error(`[WEBHOOK] Failed to add email to Radar blocklist:`, blocklistError);
}
}
await NtfyService.notifyEarlyFraudWarning(chargeId, warning.fraud_type, cardFingerprint, customerEmail);
break;
}
// Unhandled events
default:
signale.info(`[WEBHOOK] Unhandled Stripe event type: ${event.type}`);
break;
}
return res.status(200).json({success: true, received: true});
} catch (error) {
signale.error('[WEBHOOK] Error processing Stripe webhook:', error);
return res.status(400).json({success: false, error: 'Webhook processing failed'});
}
}
}