Complete example of testing Stripe webhooks locally using Volley - No ngrok, no tunneling, no exposing your server.
This repository demonstrates how to test Stripe webhooks locally using Volley, a webhook-as-a-service platform. Unlike ngrok, Volley provides permanent webhook URLs and doesn't require tunneling.
- How to set up Stripe webhooks with Volley
- How to test webhooks locally without exposing your server
- How to handle Stripe webhook events in Node.js/Express
- Best practices for webhook security and verification
- Node.js 18+ installed
- A Volley account (free tier: 10K events/month)
- A Stripe account (test mode is fine)
- Volley CLI installed
-
Clone this repository:
git clone https://github.com/volleyhq/volley-stripe-example.git cd volley-stripe-example -
Install dependencies:
npm install
-
Set up environment variables:
cp .env.example .env # Edit .env with your Stripe secret key -
Create a Volley source:
- Go to Volley Dashboard
- Create a new organization and project
- Create a new source (e.g., "stripe-webhooks")
- Copy your ingestion ID (e.g.,
abc123xyz)
-
Configure Stripe webhook:
- Go to Stripe Dashboard
- Click "Add endpoint"
- Enter your Volley webhook URL:
https://api.volleyhooks.com/hook/YOUR_INGESTION_ID - Select events to listen to (e.g.,
payment_intent.succeeded,customer.created) - Click "Add endpoint"
-
Start your local server:
npm start
-
Forward webhooks to localhost:
volley listen --source YOUR_INGESTION_ID --forward-to http://localhost:3000/webhooks/stripe
-
Test it:
- Trigger a test event in Stripe Dashboard
- Watch it appear in your local server logs!
volley-stripe-example/
βββ src/
β βββ server.js # Express server setup
β βββ webhookHandler.js # Stripe webhook handler
β βββ handlers/ # Event-specific handlers
β βββ paymentIntent.js
β βββ customer.js
β βββ invoice.js
βββ .env.example # Environment variables template
βββ package.json
βββ README.md
// src/webhookHandler.js
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const router = express.Router();
// Stripe webhook endpoint
router.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
// Verify webhook signature
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Handle the event
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentIntentSucceeded(event.data.object);
break;
case 'customer.created':
await handleCustomerCreated(event.data.object);
break;
case 'invoice.payment_succeeded':
await handleInvoicePaymentSucceeded(event.data.object);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
// Return a response to acknowledge receipt
res.json({ received: true });
});
module.exports = router;// src/handlers/paymentIntent.js
async function handlePaymentIntentSucceeded(paymentIntent) {
console.log('Payment succeeded:', paymentIntent.id);
// Your business logic here
// e.g., update database, send confirmation email, etc.
}
// src/handlers/customer.js
async function handleCustomerCreated(customer) {
console.log('Customer created:', customer.id);
// Your business logic here
}Always verify Stripe webhook signatures to ensure requests are from Stripe:
const event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);Never commit secrets to version control:
# .env
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...Handle duplicate webhooks gracefully:
// Use Stripe event IDs for idempotency
const eventId = event.id;
if (await isEventProcessed(eventId)) {
return; // Already processed
}
await markEventAsProcessed(eventId);-
Use Stripe CLI to send test webhooks:
stripe trigger payment_intent.succeeded
-
Or use Stripe Dashboard:
- Go to Webhooks β Your endpoint β Send test webhook
// test/webhook.test.js
const request = require('supertest');
const app = require('../src/server');
describe('Stripe Webhooks', () => {
it('should handle payment_intent.succeeded', async () => {
const event = {
id: 'evt_test',
type: 'payment_intent.succeeded',
data: { object: { id: 'pi_test' } }
};
const response = await request(app)
.post('/webhooks/stripe')
.send(JSON.stringify(event))
.set('stripe-signature', 'test-signature');
expect(response.status).toBe(200);
});
});| Feature | Volley | ngrok |
|---|---|---|
| Webhook URLs | β Permanent, never change | β Change on restart |
| Tunneling | β Not required | β Required |
| Local Server Privacy | β Completely private | |
| Built-in Retry | β Automatic | β No |
| Monitoring | β Full dashboard | β Limited |
| Production Ready | β Same URL for dev/prod | β Dev tool only |
Learn more: Volley vs ngrok
Found a bug or have a suggestion? Please open an issue or submit a pull request!
MIT License - See LICENSE file for details