Skip to main content
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

1

Get your webhook signing secret

In your Stripe Dashboard:
  1. Go to DevelopersWebhooks
  2. Click Add endpoint
  3. Enter your webhook URL:
    • Development: https://your-ngrok-url.ngrok.io/api/webhooks/stripe
    • Production: https://your-domain.com/api/webhooks/stripe
  4. Select events to listen for (see below)
  5. Click Add endpoint
  6. Copy the Signing secret (starts with whsec_)
2

Configure environment variable

STRIPE_WEBHOOK_SECRET="whsec_..."
3

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:
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:
1

First failure

  • Update subscription status to PAST_DUE
  • Record failure attempt
  • Send payment failure email with update link
  • Stripe retries in 3 days
2

Second failure

  • Update failure count
  • Send second reminder email
  • Stripe retries in 5 days
3

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

1

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
2

Authenticate with Stripe

stripe login
This opens your browser to complete authentication.
3

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)
4

Update local environment

Use the test signing secret:
STRIPE_WEBHOOK_SECRET="whsec_..."
5

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

1

Install ngrok

Download from ngrok.com or:
# macOS
brew install ngrok
2

Start ngrok tunnel

ngrok http 3000
Copy the HTTPS URL (e.g., https://abc123.ngrok.io)
3

Add webhook endpoint in Stripe

In Stripe Dashboard:
  1. Go to DevelopersWebhooks
  2. Add endpoint: https://abc123.ngrok.io/api/webhooks/stripe
  3. Select events
  4. Copy the signing secret
4

Update environment variable

STRIPE_WEBHOOK_SECRET="whsec_..."

Monitoring Webhooks

Stripe Dashboard

Monitor webhook delivery in Stripe:
  1. Go to DevelopersWebhooks
  2. Click on your endpoint
  3. 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.

Webhook Response Format

Always return a success response:
// Success
return NextResponse.json({ received: true });

// Error
return NextResponse.json(
  { error: "Error processing webhook" },
  { status: 500 }
);

Security Best Practices

1

Always verify signatures

stripe.webhooks.constructEvent(
  body,
  signature,
  process.env.STRIPE_WEBHOOK_SECRET
);
2

Use HTTPS in production

Stripe requires HTTPS endpoints for webhook delivery.
3

Keep signing secrets secure

  • Never commit secrets to version control
  • Use environment variables
  • Rotate secrets periodically
4

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 }
});
5

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

  1. Verify STRIPE_WEBHOOK_SECRET is set correctly
  2. Ensure you’re using the raw request body (not parsed JSON)
  3. Check that the secret matches your environment (test vs production)
  4. Verify the endpoint URL in Stripe matches your deployment
  1. Check Stripe dashboard for delivery attempts and errors
  2. Verify endpoint URL is correct and accessible
  3. Ensure HTTPS is used in production
  4. Check firewall rules allow Stripe IPs
  5. Verify the endpoint returns 200 status
Implement idempotency checks using event IDs:
const processed = await db.webhookEvent.findUnique({
  where: { eventId: event.id }
});

if (processed) return NextResponse.json({ received: true });
  1. Ensure processing completes within 5 seconds
  2. Move long-running tasks to background jobs
  3. Return 200 response immediately, process async
  4. Optimize database queries
  1. Ensure Stripe CLI is authenticated: stripe login
  2. Check local server is running on correct port
  3. Verify webhook secret from CLI is in .env.local
  4. Restart dev server after changing environment variables

Advanced: Custom Webhooks

Mantlz also supports custom webhook endpoints for form submissions:
POST /api/webhooks/custom

Payload Format

{
  "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

Build docs developers (and LLMs) love