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
Go to Dashboard → Webhooks
Click “Add Webhook”
Enter your endpoint URL (e.g., https://yourapp.com/api/webhooks/loops
)
Select which events you want to receive (e.g., charge.succeeded
, charge.refunded
)
Click “Create”
IMPORTANT : Click the menu (⋮) and select “View Secret” - copy this secret securely!
For local testing, use ngrok : 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
In the dashboard, click the Test button next to your webhook
Select an event type (e.g., charge.succeeded
)
Click Send Test Event
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
❌ NEVER do this (insecure)
✅ ALWAYS do this (secure)
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