Skip to main content

What are Webhooks?

Webhooks are HTTP POST requests that Loops sends to your application when important events occur (like a payment succeeds, a subscription is created, or a refund is processed). Think of it as: “Hey, something just happened! Here’s what you need to know.”
Security First: Always verify webhook signatures to ensure requests are actually from Loops. Never process unverified webhooks in production.

Complete Setup Guide

Step 1: Create a Webhook Endpoint in Your Dashboard

  1. Go to Dashboard → Webhooks
  2. Click “Add Webhook”
  3. Enter your endpoint URL (e.g., https://yourapp.com/api/webhooks/loops)
  4. Select which events you want to receive (e.g., charge.succeeded, charge.refunded)
  5. Click “Create”
  6. IMPORTANT: Click the menu (⋮) and select “View Secret” - copy this secret securely!
For local testing, use ngrok:
ngrok http 3000
Then use the ngrok URL as your webhook endpoint.

Step 2: Implement Your Webhook Endpoint

Implementation Examples

Node.js / Express

const express = require('express');
const crypto = require('crypto');
const app = express();

// IMPORTANT: Use raw body for signature verification
app.use('/api/webhooks/loops', express.raw({ type: 'application/json' }));

// Your webhook secret from the dashboard
const WEBHOOK_SECRET = process.env.LOOPS_WEBHOOK_SECRET; // Get this from dashboard

function verifyWebhookSignature(payload, signature, secret) {
  // Parse the signature header
  const parts = signature.split(',');
  const timestamp = parts.find(p => p.startsWith('t=')).split('=')[1];
  const receivedSignature = parts.find(p => p.startsWith('v1=')).split('=')[1];
  
  // Prevent replay attacks - reject if timestamp is older than 5 minutes
  const currentTime = Math.floor(Date.now() / 1000);
  if (Math.abs(currentTime - parseInt(timestamp)) > 300) {
    throw new Error('Webhook timestamp too old');
  }
  
  // Compute the expected signature
  const signedPayload = `${timestamp}.${payload}`;
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');
  
  // Compare signatures (timing-safe comparison)
  if (!crypto.timingSafeEqual(
    Buffer.from(receivedSignature),
    Buffer.from(expectedSignature)
  )) {
    throw new Error('Invalid signature');
  }
  
  return true;
}

app.post('/api/webhooks/loops', async (req, res) => {
  const signature = req.headers['x-loops-signature'];
  const payload = req.body.toString('utf8'); // Raw body as string
  
  try {
    // Step 1: Verify the signature
    verifyWebhookSignature(payload, signature, WEBHOOK_SECRET);
    
    // Step 2: Parse the event
    const event = JSON.parse(payload);
    
    // Step 3: Handle the event based on type
    switch (event.type) {
      case 'charge.succeeded':
        await handlePaymentSuccess(event);
        break;
      
      case 'charge.refunded':
        await handleRefund(event);
        break;
      
      case 'checkout.session.completed':
        await handleCheckoutCompleted(event);
        break;
      
      case 'customer.subscription.created':
        await handleSubscriptionCreated(event);
        break;
      
      default:
        console.log(`Unhandled event type: ${event.type}`);
    }
    
    // Step 4: Return 200 to acknowledge receipt
    res.status(200).json({ received: true });
    
  } catch (error) {
    console.error('Webhook error:', error);
    res.status(400).json({ error: error.message });
  }
});

// Example: Handle successful payment
async function handlePaymentSuccess(event) {
  const charge = event.data.object;
  
  console.log('Payment succeeded:', {
    amount: charge.amount / 100, // Convert cents to dollars
    currency: charge.currency,
    customer: charge.billing_details.name,
    email: charge.billing_details.email,
    description: charge.description
  });
  
  // Your business logic here:
  // - Update order status in database
  // - Send confirmation email
  // - Trigger fulfillment process
  // - Update user account
  
  // Example:
  // await db.orders.update({
  //   where: { id: charge.metadata.order_id },
  //   data: { status: 'paid', paidAt: new Date() }
  // });
}

// Example: Handle refund
async function handleRefund(event) {
  const charge = event.data.object;
  
  console.log('Payment refunded:', {
    amount: charge.amount_refunded / 100,
    currency: charge.currency,
    customer: charge.billing_details.email
  });
  
  // Your business logic here:
  // - Update order status
  // - Send refund confirmation email
  // - Reverse any account credits
}

// Example: Handle checkout completed
async function handleCheckoutCompleted(event) {
  const session = event.data.object;
  
  console.log('Checkout completed:', {
    amount: session.amount_total / 100,
    customer: session.customer_details.email,
    status: session.payment_status
  });
  
  // Your business logic here:
  // - Provision access to product/service
  // - Send welcome email
  // - Create user account if needed
}

app.listen(3000, () => {
  console.log('Webhook endpoint listening on port 3000');
});

Next.js API Route

// app/api/webhooks/loops/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';

const WEBHOOK_SECRET = process.env.LOOPS_WEBHOOK_SECRET!;

function verifyWebhookSignature(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const parts = signature.split(',');
  const timestamp = parts.find(p => p.startsWith('t='))!.split('=')[1];
  const receivedSignature = parts.find(p => p.startsWith('v1='))!.split('=')[1];
  
  // Check timestamp (prevent replay attacks)
  const currentTime = Math.floor(Date.now() / 1000);
  if (Math.abs(currentTime - parseInt(timestamp)) > 300) {
    throw new Error('Webhook timestamp too old');
  }
  
  // Verify signature
  const signedPayload = `${timestamp}.${payload}`;
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');
  
  return crypto.timingSafeEqual(
    Buffer.from(receivedSignature),
    Buffer.from(expectedSignature)
  );
}

export async function POST(req: NextRequest) {
  try {
    // Get signature from headers
    const signature = req.headers.get('x-loops-signature');
    if (!signature) {
      return NextResponse.json(
        { error: 'Missing signature' },
        { status: 400 }
      );
    }
    
    // Get raw body
    const payload = await req.text();
    
    // Verify signature
    if (!verifyWebhookSignature(payload, signature, WEBHOOK_SECRET)) {
      return NextResponse.json(
        { error: 'Invalid signature' },
        { status: 401 }
      );
    }
    
    // Parse event
    const event = JSON.parse(payload);
    
    // Handle event
    switch (event.type) {
      case 'charge.succeeded':
        await handlePaymentSuccess(event);
        break;
      
      case 'charge.refunded':
        await handleRefund(event);
        break;
      
      case 'checkout.session.completed':
        await handleCheckoutCompleted(event);
        break;
      
      default:
        console.log(`Unhandled event type: ${event.type}`);
    }
    
    return NextResponse.json({ received: true });
    
  } catch (error: any) {
    console.error('Webhook error:', error);
    return NextResponse.json(
      { error: error.message },
      { status: 400 }
    );
  }
}

async function handlePaymentSuccess(event: any) {
  const charge = event.data.object;
  
  // Your business logic
  console.log('Payment received:', {
    amount: charge.amount / 100,
    customer: charge.billing_details.email,
    orderId: charge.metadata.order_id
  });
  
  // Update your database
  // await prisma.order.update({...});
  
  // Send confirmation email
  // await sendEmail({...});
}

async function handleRefund(event: any) {
  const charge = event.data.object;
  
  // Handle refund logic
  console.log('Refund processed:', {
    amount: charge.amount_refunded / 100,
    customer: charge.billing_details.email
  });
}

async function handleCheckoutCompleted(event: any) {
  const session = event.data.object;
  
  // Provision access
  console.log('Checkout completed:', {
    amount: session.amount_total / 100,
    customer: session.customer_details.email
  });
}

Python / Flask

from flask import Flask, request, jsonify
import hmac
import hashlib
import time
import json
import os

app = Flask(__name__)

WEBHOOK_SECRET = os.environ.get('LOOPS_WEBHOOK_SECRET')

def verify_webhook_signature(payload, signature, secret):
    # Parse signature header
    parts = dict(item.split('=') for item in signature.split(','))
    timestamp = parts['t']
    received_signature = parts['v1']
    
    # Check timestamp (prevent replay attacks)
    current_time = int(time.time())
    if abs(current_time - int(timestamp)) > 300:
        raise ValueError('Webhook timestamp too old')
    
    # Verify signature
    signed_payload = f"{timestamp}.{payload}"
    expected_signature = hmac.new(
        secret.encode('utf-8'),
        signed_payload.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()
    
    if not hmac.compare_digest(received_signature, expected_signature):
        raise ValueError('Invalid signature')
    
    return True

@app.route('/api/webhooks/loops', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-Loops-Signature')
    payload = request.get_data(as_text=True)
    
    try:
        # Verify signature
        verify_webhook_signature(payload, signature, WEBHOOK_SECRET)
        
        # Parse event
        event = json.loads(payload)
        
        # Handle event
        if event['type'] == 'charge.succeeded':
            handle_payment_success(event)
        elif event['type'] == 'charge.refunded':
            handle_refund(event)
        elif event['type'] == 'checkout.session.completed':
            handle_checkout_completed(event)
        else:
            print(f"Unhandled event type: {event['type']}")
        
        return jsonify({'received': True}), 200
        
    except Exception as e:
        print(f"Webhook error: {e}")
        return jsonify({'error': str(e)}), 400

def handle_payment_success(event):
    charge = event['data']['object']
    
    print(f"Payment succeeded: {charge['amount'] / 100} {charge['currency']}")
    print(f"Customer: {charge['billing_details']['email']}")
    
    # Your business logic:
    # - Update database
    # - Send confirmation email
    # - Trigger fulfillment

def handle_refund(event):
    charge = event['data']['object']
    
    print(f"Refund processed: {charge['amount_refunded'] / 100}")
    
    # Handle refund logic

def handle_checkout_completed(event):
    session = event['data']['object']
    
    print(f"Checkout completed: {session['customer_details']['email']}")
    
    # Provision access, send welcome email, etc.

if __name__ == '__main__':
    app.run(port=3000)

PHP

<?php
// webhook.php

$webhookSecret = getenv('LOOPS_WEBHOOK_SECRET');

function verifyWebhookSignature($payload, $signature, $secret) {
    // Parse signature header
    $parts = [];
    foreach (explode(',', $signature) as $part) {
        list($key, $value) = explode('=', $part, 2);
        $parts[$key] = $value;
    }
    
    $timestamp = $parts['t'];
    $receivedSignature = $parts['v1'];
    
    // Check timestamp (prevent replay attacks)
    $currentTime = time();
    if (abs($currentTime - intval($timestamp)) > 300) {
        throw new Exception('Webhook timestamp too old');
    }
    
    // Verify signature
    $signedPayload = $timestamp . '.' . $payload;
    $expectedSignature = hash_hmac('sha256', $signedPayload, $secret);
    
    if (!hash_equals($receivedSignature, $expectedSignature)) {
        throw new Exception('Invalid signature');
    }
    
    return true;
}

// Get raw POST body
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_LOOPS_SIGNATURE'];

try {
    // Verify signature
    verifyWebhookSignature($payload, $signature, $webhookSecret);
    
    // Parse event
    $event = json_decode($payload, true);
    
    // Handle event
    switch ($event['type']) {
        case 'charge.succeeded':
            handlePaymentSuccess($event);
            break;
        
        case 'charge.refunded':
            handleRefund($event);
            break;
        
        case 'checkout.session.completed':
            handleCheckoutCompleted($event);
            break;
        
        default:
            error_log("Unhandled event type: " . $event['type']);
    }
    
    http_response_code(200);
    echo json_encode(['received' => true]);
    
} catch (Exception $e) {
    error_log("Webhook error: " . $e->getMessage());
    http_response_code(400);
    echo json_encode(['error' => $e->getMessage()]);
}

function handlePaymentSuccess($event) {
    $charge = $event['data']['object'];
    
    $amount = $charge['amount'] / 100;
    $email = $charge['billing_details']['email'];
    
    // Your business logic:
    // - Update order in database
    // - Send confirmation email
    // - Trigger fulfillment
    
    error_log("Payment succeeded: $amount for $email");
}

function handleRefund($event) {
    $charge = $event['data']['object'];
    
    // Handle refund logic
}

function handleCheckoutCompleted($event) {
    $session = $event['data']['object'];
    
    // Provision access
}
?>

Step 3: Test Your Endpoint

  1. In the dashboard, click the Test button next to your webhook
  2. Select an event type (e.g., charge.succeeded)
  3. Click Send Test Event
  4. Check your logs to see if the webhook was received and verified

Event Types

charge.succeeded

When it fires: A payment is successfully processed What to do:
  • Update order status to “paid”
  • Send confirmation email to customer
  • Trigger product delivery/fulfillment
  • Update user account balance or credits

charge.refunded

When it fires: A payment is refunded What to do:
  • Update order status to “refunded”
  • Send refund confirmation email
  • Reverse any account credits or access
  • Log the refund for accounting

checkout.session.completed

When it fires: A checkout session is successfully completed What to do:
  • Create user account if needed
  • Provision access to product/service
  • Send welcome email
  • Record purchase in analytics

customer.subscription.created

When it fires: A new subscription is created What to do:
  • Grant subscription access
  • Send subscription welcome email
  • Update user’s subscription status

customer.subscription.deleted

When it fires: A subscription is canceled What to do:
  • Revoke subscription access
  • Send cancellation confirmation
  • Update user’s subscription status

invoice.payment_succeeded

When it fires: A recurring invoice payment succeeds What to do:
  • Extend subscription period
  • Send payment receipt
  • Log successful billing

invoice.payment_failed

When it fires: A recurring invoice payment fails What to do:
  • Send payment failure notification
  • Update subscription to “past_due”
  • Request payment method update

Best Practices

1. Always Verify Signatures

app.post('/webhooks', (req, res) => {
  const event = req.body;
  processEvent(event); // No verification!
});

2. Use Raw Body for Verification

The signature is computed on the raw request body, not the parsed JSON.
app.use('/webhooks', express.raw({ type: 'application/json' }));

3. Return 200 Quickly

Process the webhook asynchronously and return 200 immediately.
app.post('/webhooks', async (req, res) => {
  verifySignature(req.body, req.headers['x-loops-signature']);
  
  // Return 200 immediately
  res.status(200).json({ received: true });
  
  // Process asynchronously
  processEventAsync(JSON.parse(req.body));
});

4. Handle Idempotency

Webhooks may be sent multiple times. Use the event ID to prevent duplicate processing.
async function handleEvent(event) {
  // Check if already processed
  const existing = await db.processedEvents.findUnique({
    where: { eventId: event.id }
  });
  
  if (existing) {
    console.log('Event already processed:', event.id);
    return;
  }
  
  // Process event
  await processEvent(event);
  
  // Mark as processed
  await db.processedEvents.create({
    data: { eventId: event.id, processedAt: new Date() }
  });
}

5. Implement Retry Logic

If processing fails, return a non-200 status so the webhook can be retried.
app.post('/webhooks', async (req, res) => {
  try {
    verifySignature(req.body, req.headers['x-loops-signature']);
    await processEvent(JSON.parse(req.body));
    res.status(200).json({ received: true });
  } catch (error) {
    console.error('Processing failed:', error);
    res.status(500).json({ error: 'Processing failed, will retry' });
  }
});

Troubleshooting

”Invalid signature” error

  • Make sure you’re using the raw request body, not parsed JSON
  • Verify you have the correct webhook secret from the dashboard
  • Check that the timestamp isn’t too old (> 5 minutes)

Webhook not being received

  • Test your endpoint with the “Test” button in the dashboard
  • Check your firewall/security rules allow incoming requests
  • Verify your endpoint URL is publicly accessible
  • Check your server logs for errors

Duplicate events

  • Implement idempotency using event IDs
  • Store processed event IDs in your database

Security Checklist

  • ✅ Always verify webhook signatures
  • ✅ Use HTTPS for your webhook endpoint
  • ✅ Store webhook secrets securely (environment variables)
  • ✅ Validate timestamp to prevent replay attacks
  • ✅ Return 200 only after successful verification
  • ✅ Implement idempotency to handle duplicates
  • ✅ Log webhook events for debugging
  • ✅ Use timing-safe comparison for signatures

Need Help?

  • View webhook logs in the dashboard: Dashboard → Webhooks → [Your Webhook] → Logs
  • Test webhooks using the built-in test feature in the dashboard
  • Contact support if you’re experiencing issues

Next Steps

I