Payment Webhooks#

Learn how to integrate payment processor webhooks for complete revenue attribution.

Overview#

The webhook integration flow:

  1. Frontend: Collect visitor fingerprint and session ID
  2. Checkout: Pass attribution data through payment metadata
  3. Webhook: Receive payment events with metadata
  4. 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#