Webhooks allow Mantlz to receive real-time notifications from third-party services like Stripe. This guide covers webhook setup, handling, and security.
Overview
Mantlz uses webhooks for:
Stripe : Payment events, subscription updates, invoice status
Form Submissions : Real-time form submission notifications (optional)
Custom Integrations : User-defined webhook endpoints
Stripe Webhooks
Stripe webhooks handle subscription lifecycle events, payments, and billing.
Webhook Endpoint
Mantlz listens for Stripe webhooks at:
POST /api/webhooks/stripe
Setup
Get your webhook signing secret
In your Stripe Dashboard:
Go to Developers → Webhooks
Click Add endpoint
Enter your webhook URL:
Development: https://your-ngrok-url.ngrok.io/api/webhooks/stripe
Production: https://your-domain.com/api/webhooks/stripe
Select events to listen for (see below)
Click Add endpoint
Copy the Signing secret (starts with whsec_)
Configure environment variable
STRIPE_WEBHOOK_SECRET = "whsec_..."
Test the webhook
Use the Stripe CLI to test locally: # Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login to Stripe
stripe login
# Forward webhooks to local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
The CLI will provide a webhook signing secret for testing.
Events Handled
Mantlz listens for and handles these Stripe events:
checkout.session.completed
customer.subscription.created
customer.subscription.deleted
invoice.payment_succeeded
invoice.payment_failed
customer.subscription.resumed
case "checkout.session.completed" : {
const session = event . data . object as Stripe . Checkout . Session ;
// Create or update customer
// Create subscription record
// Update user plan and quota
// Send welcome email
await handleCheckoutSession ( session );
break;
}
Event Selection
When creating your webhook endpoint in Stripe, select these events:
checkout.session.completed
customer.subscription.created
customer.subscription.updated
customer.subscription.deleted
customer.subscription.resumed
invoice.payment_succeeded
invoice.payment_failed
Webhook Handler Implementation
The webhook route verifies and processes Stripe events:
src/app/api/webhooks/stripe/route.ts
import { stripe } from "@/lib/stripe" ;
import { headers } from "next/headers" ;
import Stripe from "stripe" ;
export async function POST ( req : Request ) {
const body = await req . text ();
const headersList = await headers ();
const signature = headersList . get ( "stripe-signature" );
let event : Stripe . Event ;
try {
// Verify webhook signature
event = stripe . webhooks . constructEvent (
body ,
signature ?? "" ,
process . env . STRIPE_WEBHOOK_SECRET ?? ""
);
} catch ( error ) {
console . error ( `Webhook signature verification failed` );
return new Response ( `Webhook Error` , { status: 400 });
}
// Process event
switch ( event . type ) {
case "checkout.session.completed" :
await handleCheckoutSession ( event . data . object );
break ;
// ... other cases
}
return NextResponse . json ({ received: true });
}
Webhook Security
Always verify webhook signatures to ensure requests are from Stripe.
Mantlz verifies webhooks using Stripe’s signature verification:
stripe . webhooks . constructEvent (
body ,
signature ,
process . env . STRIPE_WEBHOOK_SECRET
);
This prevents:
Replay attacks
Man-in-the-middle attacks
Unauthorized webhook calls
Failed Payment Handling
Mantlz implements a 3-attempt retry policy for failed payments:
First failure
Update subscription status to PAST_DUE
Record failure attempt
Send payment failure email with update link
Stripe retries in 3 days
Second failure
Update failure count
Send second reminder email
Stripe retries in 5 days
Third failure
Update subscription status to CANCELED
Downgrade user to FREE plan
Reset quota to free tier limits
Send cancellation email with reactivation link
if ( failedAttempts < 3 ) {
// Record failure
await db . paymentFailure . create ({
data: {
subscriptionId ,
invoiceId: invoice . id ,
attemptNumber: failedAttempts + 1
}
});
// Send reminder email
await sendPaymentFailureEmail ({
to: user . email ,
attemptNumber: failedAttempts + 1 ,
nextAttemptDate ,
updatePaymentUrl: ` ${ process . env . NEXT_PUBLIC_APP_URL } /pricing`
});
} else {
// Downgrade to FREE
await db . user . update ({
where: { id: userId },
data: {
plan: "FREE" ,
quotaLimit: FREE_QUOTA . maxSubmissionsPerMonth
}
});
}
Testing Webhooks Locally
Using Stripe CLI
Install Stripe CLI
# macOS
brew install stripe/stripe-cli/stripe
# Windows (with Scoop)
scoop bucket add stripe https://github.com/stripe/scoop-stripe-cli.git
scoop install stripe
# Linux
wget https://github.com/stripe/stripe-cli/releases/latest/download/stripe_1.19.4_linux_x86_64.tar.gz
tar -xvf stripe_1.19.4_linux_x86_64.tar.gz
Authenticate with Stripe
This opens your browser to complete authentication.
Forward webhooks to local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
This provides a webhook signing secret for testing: Your webhook signing secret is whsec_... (^C to quit)
Update local environment
Use the test signing secret: STRIPE_WEBHOOK_SECRET = "whsec_..."
Trigger test events
# Trigger a checkout.session.completed event
stripe trigger checkout.session.completed
# Trigger a payment succeeded event
stripe trigger invoice.payment_succeeded
Using ngrok for Remote Testing
Install ngrok
Download from ngrok.com or: # macOS
brew install ngrok
Start ngrok tunnel
Copy the HTTPS URL (e.g., https://abc123.ngrok.io)
Add webhook endpoint in Stripe
In Stripe Dashboard:
Go to Developers → Webhooks
Add endpoint: https://abc123.ngrok.io/api/webhooks/stripe
Select events
Copy the signing secret
Update environment variable
STRIPE_WEBHOOK_SECRET = "whsec_..."
Monitoring Webhooks
Stripe Dashboard
Monitor webhook delivery in Stripe:
Go to Developers → Webhooks
Click on your endpoint
View:
Recent deliveries
Success/failure rate
Response times
Error details
Application Logs
Mantlz logs all webhook events:
console . log ( `Processing webhook: ${ event . type } ` );
console . log ( `Webhook data:` , event . data . object );
In production, these logs are sent to your logging service (e.g., Sentry).
Failed Webhook Retry
Stripe automatically retries failed webhooks:
Immediate retry
1 hour later
2 hours later
Up to 3 days of retries
Ensure your webhook endpoint returns a 200 status code within 5 seconds to prevent retries.
Always return a success response:
// Success
return NextResponse . json ({ received: true });
// Error
return NextResponse . json (
{ error: "Error processing webhook" },
{ status: 500 }
);
Security Best Practices
Always verify signatures
stripe . webhooks . constructEvent (
body ,
signature ,
process . env . STRIPE_WEBHOOK_SECRET
);
Use HTTPS in production
Stripe requires HTTPS endpoints for webhook delivery.
Keep signing secrets secure
Never commit secrets to version control
Use environment variables
Rotate secrets periodically
Implement idempotency
Handle duplicate webhook deliveries: // Check if event already processed
const existing = await db . webhookEvent . findUnique ({
where: { eventId: event . id }
});
if ( existing ) {
return NextResponse . json ({ received: true });
}
// Process and record event
await processWebhook ( event );
await db . webhookEvent . create ({
data: { eventId: event . id }
});
Handle errors gracefully
try {
await handleWebhook ( event );
} catch ( error ) {
console . error ( 'Webhook error:' , error );
// Don't throw - return success to prevent retries
return NextResponse . json ({ received: true });
}
Troubleshooting
Webhook signature verification fails
Verify STRIPE_WEBHOOK_SECRET is set correctly
Ensure you’re using the raw request body (not parsed JSON)
Check that the secret matches your environment (test vs production)
Verify the endpoint URL in Stripe matches your deployment
Webhooks not being received
Check Stripe dashboard for delivery attempts and errors
Verify endpoint URL is correct and accessible
Ensure HTTPS is used in production
Check firewall rules allow Stripe IPs
Verify the endpoint returns 200 status
Duplicate webhook processing
Implement idempotency checks using event IDs: const processed = await db . webhookEvent . findUnique ({
where: { eventId: event . id }
});
if ( processed ) return NextResponse . json ({ received: true });
Ensure processing completes within 5 seconds
Move long-running tasks to background jobs
Return 200 response immediately, process async
Optimize database queries
Local testing not working with Stripe CLI
Ensure Stripe CLI is authenticated: stripe login
Check local server is running on correct port
Verify webhook secret from CLI is in .env.local
Restart dev server after changing environment variables
Advanced: Custom Webhooks
Mantlz also supports custom webhook endpoints for form submissions:
POST / api / webhooks / custom
{
"event" : "form.submission.created" ,
"timestamp" : "2025-03-04T12:00:00Z" ,
"data" : {
"formId" : "form_123" ,
"submissionId" : "sub_456" ,
"fields" : {
"email" : "[email protected] " ,
"name" : "John Doe"
}
}
}
Verification
Custom webhooks include an HMAC signature:
const signature = req . headers . get ( 'x-mantlz-signature' );
const expectedSignature = crypto
. createHmac ( 'sha256' , webhookSecret )
. update ( body )
. digest ( 'hex' );
if ( signature !== expectedSignature ) {
return new Response ( 'Invalid signature' , { status: 401 });
}
Next Steps
Integrations Learn about Stripe and other integrations
Environment Setup Configure environment variables
API Reference Explore the Mantlz API
Self-Hosting Deploy Mantlz with Docker