Payment Webhooks
Learn how to integrate payment processor webhooks for complete revenue attribution.
Overview
The webhook integration flow:
- Frontend: Collect visitor fingerprint and session ID
- Checkout: Pass attribution data through payment metadata
- Webhook: Receive payment events with metadata
- Track: Send revenue data to Pulsora with attribution
Stripe Integration
Frontend Setup
// Collect attribution data before checkout
import { Pulsora } from '@pulsora/core';
const pulsora = new Pulsora();
pulsora.init({ apiToken: 'pub_your_public_token' });
async function createCheckoutSession(priceId) {
// Get attribution data
const fingerprint = await pulsora.getVisitorFingerprint();
const sessionId = pulsora.getSessionId();
// Create checkout session with metadata
const response = await fetch('/api/create-checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
priceId,
visitor_fingerprint: fingerprint,
session_id: sessionId,
}),
});
const { url } = await response.json();
window.location.href = url;
}
Backend - Create Checkout Session
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
app.post('/api/create-checkout', async (req, res) => {
const { priceId, visitor_fingerprint, session_id } = req.body;
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: 'payment', // or 'subscription'
success_url:
'https://yoursite.com/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'https://yoursite.com/cancel',
// Pass attribution data in metadata
metadata: {
visitor_fingerprint,
session_id,
},
});
res.json({ url: session.url });
});
Webhook Handler
const { Revenue } = require('@pulsora/revenue');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const revenue = new Revenue({
apiToken: process.env.PULSORA_SECRET_TOKEN,
});
app.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);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Handle different event types
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutCompleted(event.data.object);
break;
case 'customer.subscription.created':
await handleSubscriptionCreated(event.data.object);
break;
case 'customer.subscription.updated':
await handleSubscriptionUpdated(event.data.object);
break;
case 'customer.subscription.deleted':
await handleSubscriptionDeleted(event.data.object);
break;
case 'charge.refunded':
await handleRefund(event.data.object);
break;
}
res.json({ received: true });
},
);
// Handle one-time payment
async function handleCheckoutCompleted(session) {
// Skip if subscription (handled separately)
if (session.mode === 'subscription') return;
await revenue.track({
visitor_fingerprint: session.metadata.visitor_fingerprint,
session_id: session.metadata.session_id,
customer_id: session.customer,
amount: session.amount_total / 100,
currency: session.currency.toUpperCase(),
transaction_id: session.payment_intent,
payment_processor: 'stripe',
event_type: 'purchase',
metadata: {
checkout_session_id: session.id,
customer_email: session.customer_details?.email,
},
});
}
// Handle new subscription
async function handleSubscriptionCreated(subscription) {
const amount = subscription.items.data[0].price.unit_amount / 100;
await revenue.track({
visitor_fingerprint: subscription.metadata.visitor_fingerprint,
session_id: subscription.metadata.session_id,
customer_id: subscription.customer,
amount: amount,
currency: subscription.currency.toUpperCase(),
transaction_id: subscription.id,
payment_processor: 'stripe',
event_type: 'subscription',
customer_subscription_id: subscription.id,
mrr_impact: amount,
is_recurring: true,
metadata: {
plan_id: subscription.items.data[0].price.id,
plan_name: subscription.items.data[0].price.nickname,
interval: subscription.items.data[0].price.recurring.interval,
},
});
}
Subscription Lifecycle
// Handle subscription updates (upgrades/downgrades)
async function handleSubscriptionUpdated(subscription) {
const previousAttributes = subscription.previous_attributes;
// Check if price changed
if (previousAttributes.items) {
const oldAmount = previousAttributes.items.data[0].price.unit_amount / 100;
const newAmount = subscription.items.data[0].price.unit_amount / 100;
const mrrImpact = newAmount - oldAmount;
if (mrrImpact !== 0) {
await revenue.track({
visitor_fingerprint: subscription.metadata.visitor_fingerprint,
session_id: subscription.metadata.session_id,
customer_id: subscription.customer,
amount: newAmount,
currency: subscription.currency.toUpperCase(),
transaction_id: `${subscription.id}_update_${Date.now()}`,
payment_processor: 'stripe',
event_type: mrrImpact > 0 ? 'upgrade' : 'downgrade',
customer_subscription_id: subscription.id,
mrr_impact: mrrImpact,
is_recurring: true,
metadata: {
old_amount: oldAmount,
new_amount: newAmount,
old_plan: previousAttributes.items.data[0].price.id,
new_plan: subscription.items.data[0].price.id,
},
});
}
}
}
// Handle subscription cancellation
async function handleSubscriptionDeleted(subscription) {
const amount = subscription.items.data[0].price.unit_amount / 100;
await revenue.track({
visitor_fingerprint: subscription.metadata.visitor_fingerprint,
session_id: subscription.metadata.session_id,
customer_id: subscription.customer,
amount: 0,
currency: subscription.currency.toUpperCase(),
transaction_id: `${subscription.id}_churn_${Date.now()}`,
payment_processor: 'stripe',
event_type: 'churn',
customer_subscription_id: subscription.id,
mrr_impact: -amount,
metadata: {
canceled_at: subscription.canceled_at,
cancellation_reason: subscription.cancellation_details?.reason,
churned_mrr: amount,
},
});
}
// Handle refunds
async function handleRefund(charge) {
await revenue.track({
visitor_fingerprint: charge.metadata.visitor_fingerprint,
session_id: charge.metadata.session_id,
customer_id: charge.customer,
amount: -(charge.amount_refunded / 100),
currency: charge.currency.toUpperCase(),
transaction_id: `refund_${charge.id}_${Date.now()}`,
payment_processor: 'stripe',
event_type: 'refund',
metadata: {
original_charge: charge.id,
refund_reason: charge.refunds.data[0]?.reason,
partial_refund: charge.amount_refunded < charge.amount,
},
});
}
Paddle Integration
Frontend Setup
// Initialize Paddle with custom data
Paddle.Setup({ vendor: YOUR_VENDOR_ID });
async function openCheckout(productId) {
const fingerprint = await pulsora.getVisitorFingerprint();
const sessionId = pulsora.getSessionId();
Paddle.Checkout.open({
product: productId,
passthrough: JSON.stringify({
visitor_fingerprint: fingerprint,
session_id: sessionId,
}),
});
}
Webhook Handler
const crypto = require('crypto');
app.post('/webhooks/paddle', async (req, res) => {
// Verify webhook signature
const signature = req.body.p_signature;
delete req.body.p_signature;
const keys = Object.keys(req.body)
.filter((k) => k !== 'p_signature')
.sort();
const message = keys.map((k) => `${k}=${req.body[k]}`).join('&');
const hash = crypto
.createHmac('sha1', process.env.PADDLE_PUBLIC_KEY)
.update(message)
.digest('hex');
if (hash !== signature) {
return res.status(400).send('Invalid signature');
}
// Parse custom data
const passthrough = JSON.parse(req.body.passthrough || '{}');
// Handle different event types
switch (req.body.alert_name) {
case 'payment_succeeded':
await handlePaddlePayment(req.body, passthrough);
break;
case 'subscription_created':
await handlePaddleSubscription(req.body, passthrough);
break;
case 'subscription_cancelled':
await handlePaddleCancellation(req.body, passthrough);
break;
case 'payment_refunded':
await handlePaddleRefund(req.body, passthrough);
break;
}
res.sendStatus(200);
});
async function handlePaddlePayment(data, passthrough) {
await revenue.track({
visitor_fingerprint: passthrough.visitor_fingerprint,
session_id: passthrough.session_id,
customer_id: data.user_id || data.email,
amount: parseFloat(data.sale_gross),
currency: data.currency,
transaction_id: data.order_id,
payment_processor: 'paddle',
event_type: 'purchase',
metadata: {
product_id: data.product_id,
product_name: data.product_name,
country: data.country,
coupon: data.coupon,
},
});
}
PayPal Integration
Frontend Setup
// PayPal button with custom data
paypal
.Buttons({
createOrder: async function (data, actions) {
const fingerprint = await pulsora.getVisitorFingerprint();
const sessionId = pulsora.getSessionId();
const response = await fetch('/api/create-paypal-order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
visitor_fingerprint: fingerprint,
session_id: sessionId,
}),
});
const order = await response.json();
return order.id;
},
onApprove: function (data, actions) {
return fetch('/api/capture-paypal-order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orderID: data.orderID }),
});
},
})
.render('#paypal-button');
Webhook Handler
app.post('/webhooks/paypal', async (req, res) => {
const webhookEvent = req.body;
// Verify webhook (implement PayPal webhook verification)
if (!verifyPayPalWebhook(req)) {
return res.status(400).send('Invalid webhook');
}
switch (webhookEvent.event_type) {
case 'PAYMENT.CAPTURE.COMPLETED':
const capture = webhookEvent.resource;
const customData = capture.custom || {};
await revenue.track({
visitor_fingerprint: customData.visitor_fingerprint,
session_id: customData.session_id,
customer_id: capture.payer.payer_id,
amount: parseFloat(capture.amount.value),
currency: capture.amount.currency_code,
transaction_id: capture.id,
payment_processor: 'paypal',
event_type: 'purchase',
metadata: {
paypal_order_id: capture.supplementary_data.related_ids.order_id,
payer_email: capture.payer.email_address,
},
});
break;
}
res.sendStatus(200);
});
Custom Payment Processor
Generic Webhook Pattern
class PaymentWebhookHandler {
constructor(revenue) {
this.revenue = revenue;
}
async handleWebhook(processor, event) {
// Extract common fields
const { eventType, transactionId, customerId, amount, currency, metadata } =
this.normalizeEvent(processor, event);
// Map to Pulsora event types
const pulsoraEventType = this.mapEventType(eventType);
// Track revenue
await this.revenue.track({
visitor_fingerprint: metadata.visitor_fingerprint,
session_id: metadata.session_id,
customer_id: customerId,
amount: amount,
currency: currency,
transaction_id: transactionId,
payment_processor: processor,
event_type: pulsoraEventType,
metadata: metadata,
});
}
normalizeEvent(processor, event) {
// Normalize based on processor
switch (processor) {
case 'custom_processor':
return {
eventType: event.type,
transactionId: event.transaction.id,
customerId: event.customer.id,
amount: event.transaction.amount,
currency: event.transaction.currency,
metadata: event.custom_fields,
};
// Add more processors as needed
}
}
mapEventType(processorEventType) {
const eventMap = {
'payment.completed': 'purchase',
'subscription.created': 'subscription',
'subscription.upgraded': 'upgrade',
'subscription.downgraded': 'downgrade',
'subscription.cancelled': 'churn',
'payment.refunded': 'refund',
};
return eventMap[processorEventType] || 'purchase';
}
}
Best Practices
1. Always Verify Webhooks
// Stripe signature verification
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
try {
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
} catch (err) {
console.error('Webhook signature verification failed');
return res.status(400).send(`Webhook Error: ${err.message}`);
}
2. Idempotent Processing
// Prevent duplicate processing
const processedEvents = new Map();
async function processWebhook(eventId, handler) {
if (processedEvents.has(eventId)) {
console.log('Event already processed:', eventId);
return;
}
try {
await handler();
processedEvents.set(eventId, Date.now());
// Clean up old entries (older than 24 hours)
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
for (const [id, timestamp] of processedEvents) {
if (timestamp < cutoff) {
processedEvents.delete(id);
}
}
} catch (error) {
console.error('Failed to process webhook:', error);
throw error;
}
}
3. Handle Missing Metadata
// Gracefully handle missing attribution data
function getAttributionData(metadata) {
// Try to get from metadata
if (metadata?.visitor_fingerprint && metadata?.session_id) {
return {
visitor_fingerprint: metadata.visitor_fingerprint,
session_id: metadata.session_id,
};
}
// Fallback values
console.warn('Missing attribution data, using fallbacks');
return {
visitor_fingerprint: 'unknown',
session_id: 'unknown',
};
}
4. Queue Failed Events
// Queue for retry
const failedEvents = [];
async function processWithRetry(event) {
try {
await revenue.track(event);
} catch (error) {
console.error('Failed to track revenue:', error);
// Queue for retry
failedEvents.push({
event,
attempts: 1,
lastAttempt: Date.now(),
});
}
}
// Periodic retry job
setInterval(async () => {
const toRetry = failedEvents.splice(0, 10); // Process 10 at a time
for (const item of toRetry) {
try {
await revenue.track(item.event);
console.log('Successfully retried event');
} catch (error) {
item.attempts++;
item.lastAttempt = Date.now();
// Re-queue if under max attempts
if (item.attempts < 5) {
failedEvents.push(item);
} else {
console.error('Event failed after max retries:', item.event);
}
}
}
}, 60000); // Every minute
Testing Webhooks
Local Development
Use ngrok or similar for local webhook testing:
# Install ngrok
npm install -g ngrok
# Start your server
npm run dev
# In another terminal, expose your local server
ngrok http 3000
# Use the ngrok URL for webhook configuration
# https://abc123.ngrok.io/webhooks/stripe
Webhook Testing Tools
// Test webhook endpoint
app.post('/test/webhook/:processor', async (req, res) => {
if (process.env.NODE_ENV !== 'development') {
return res.status(404).send('Not found');
}
// Simulate webhook data
const testData = {
stripe: {
type: 'checkout.session.completed',
data: {
object: {
id: 'cs_test_' + Date.now(),
customer: 'cus_test_123',
amount_total: 9999,
currency: 'usd',
payment_intent: 'pi_test_123',
metadata: {
visitor_fingerprint: 'fp_test_123',
session_id: 'session_test_123',
},
},
},
},
};
// Process test webhook
const processor = req.params.processor;
const event = testData[processor];
if (!event) {
return res.status(400).send('Unknown processor');
}
// Handle as real webhook
await handleWebhook(processor, event);
res.json({ success: true, message: 'Test webhook processed' });
});
Webhook Logs
// Log all webhooks for debugging
app.use('/webhooks/*', (req, res, next) => {
console.log('Webhook received:', {
path: req.path,
headers: req.headers,
body: req.body,
});
// Store in database for analysis
WebhookLog.create({
processor: req.path.split('/').pop(),
event_type: req.body.type || req.body.alert_name,
payload: req.body,
headers: req.headers,
timestamp: new Date(),
});
next();
});
Security Considerations
1. Secure Webhook Endpoints
// IP allowlisting for webhooks
const allowedIPs = {
stripe: ['3.18.12.63', '3.130.192.231' /* ... */],
paddle: ['34.194.127.46', '54.174.109.81' /* ... */],
};
app.use('/webhooks/:processor', (req, res, next) => {
const processor = req.params.processor;
const clientIP = req.ip;
if (allowedIPs[processor] && !allowedIPs[processor].includes(clientIP)) {
console.warn(`Rejected webhook from unauthorized IP: ${clientIP}`);
return res.status(403).send('Forbidden');
}
next();
});
2. Rate Limiting
const rateLimit = require('express-rate-limit');
const webhookLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1 minute
max: 100, // 100 requests per minute
message: 'Too many webhook requests',
});
app.use('/webhooks/*', webhookLimiter);
3. Webhook Secret Rotation
// Support multiple secrets during rotation
const webhookSecrets = {
current: process.env.WEBHOOK_SECRET,
previous: process.env.WEBHOOK_SECRET_OLD,
};
function verifyWebhookSignature(payload, signature) {
// Try current secret
if (verifyWithSecret(payload, signature, webhookSecrets.current)) {
return true;
}
// Try previous secret during rotation period
if (
webhookSecrets.previous &&
verifyWithSecret(payload, signature, webhookSecrets.previous)
) {
console.log('Webhook verified with old secret - update sender');
return true;
}
return false;
}
Next Steps
- Attribution Guide - Understand visitor attribution
- API Reference - Complete method documentation
- Examples - Real-world implementations
- Stripe Integration Guide - Detailed Stripe setup