How to Get Started with Chargebee Webhooks
Chargebee is a subscription management and revenue operations platform that helps businesses automate their recurring billing, invoicing, and payment processes. This guide shows you how to integrate webhooks from Chargebee Billing, the subscription management product within the Chargebee platform.
Webhooks are a critical feature of Chargebee, allowing you to receive real-time notifications about events in your billing lifecycle. Instead of constantly polling the Chargebee API for changes, Chargebee sends an HTTP POST request to your endpoint whenever an event occurs. This event-driven approach is more efficient and enables real-time integrations.
Common use cases for Chargebee webhooks include:
- Subscription Updates: Get notified when a subscription is created, updated, cancelled, or renewed.
- Payment Notifications: Receive alerts for successful payments, failures, and refunds.
- Invoice Generation: Trigger workflows when a new invoice is created or a payment is due.
- Customer Data Syncing: Keep your internal database in sync with customer information in Chargebee.
This guide will walk you through setting up your first Chargebee webhook, testing it locally with Hookdeck, and implementing best practices for a robust integration.
Prerequisites
Before you begin, you'll need:
- A Chargebee account
- A Hookdeck account (free tier available)
- Node.js installed on your local machine
- The Hookdeck CLI installed and authenticated (
hookdeck login)
Setting Up Your Local Development Server
Create a simple Node.js server to receive and process webhooks. Create a file named server.js:
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
const port = 3000;
app.use(bodyParser.json());
app.post('/webhooks/chargebee', (req, res) => {
console.log('Received Chargebee webhook:');
console.log(JSON.stringify(req.body, null, 2));
res.status(200).send('Webhook received');
});
app.listen(port, () => {
console.log(`Server listening at http://localhost:${port}`);
});
Install dependencies and run the server:
npm install express body-parser
node server.js
from flask import Flask, request
import json
app = Flask(__name__)
@app.route('/webhooks/chargebee', methods=['POST'])
def chargebee_webhook():
print('Received Chargebee webhook:')
print(json.dumps(request.get_json(), indent=2))
return 'Webhook received', 200
if __name__ == '__main__':
app.run(port=3000)
Install dependencies and run the server:
pip install flask
python app.py
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
)
func chargebeeWebhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer r.Body.Close()
var payload interface{}
if err := json.Unmarshal(body, &payload); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
fmt.Println("Received Chargebee webhook:")
prettyJSON, _ := json.MarshalIndent(payload, "", " ")
fmt.Println(string(prettyJSON))
w.WriteHeader(http.StatusOK)
w.Write([]byte("Webhook received"))
}
func main() {
http.HandleFunc("/webhooks/chargebee", chargebeeWebhook)
log.Println("Server listening at http://localhost:3000")
log.Fatal(http.ListenAndServe(":3000", nil))
}
Run the server:
go run server.go
require 'sinatra'
require 'json'
set :port, 3000
post '/webhooks/chargebee' do
request.body.rewind
payload = JSON.parse(request.body.read)
puts 'Received Chargebee webhook:'
puts JSON.pretty_generate(payload)
status 200
body 'Webhook received'
end
Install dependencies and run the server:
gem install sinatra
ruby server.rb
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Log;
Route::post('/webhooks/chargebee', function (Request $request) {
$event = $request->json()->all();
Log::info('Received Chargebee webhook:', $event);
return response('Webhook received', 200);
});
Add this route to your Laravel routes/web.php or routes/api.php file, then run:
php artisan serve --port=3000
import com.sun.net.httpserver.*;
import java.io.*;
import java.net.InetSocketAddress;
import com.google.gson.*;
public class ChargebeeWebhookServer {
public static void main(String[] args) throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress(3000), 0);
Gson gson = new GsonBuilder().setPrettyPrinting().create();
server.createContext("/webhooks/chargebee", exchange -> {
if ("POST".equals(exchange.getRequestMethod())) {
InputStreamReader isr = new InputStreamReader(exchange.getRequestBody());
BufferedReader br = new BufferedReader(isr);
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) {
sb.append(line);
}
JsonObject payload = JsonParser.parseString(sb.toString()).getAsJsonObject();
System.out.println("Received Chargebee webhook:");
System.out.println(gson.toJson(payload));
String response = "Webhook received";
exchange.sendResponseHeaders(200, response.length());
OutputStream os = exchange.getResponseBody();
os.write(response.getBytes());
os.close();
}
});
server.start();
System.out.println("Server listening at http://localhost:3000");
}
}
Compile and run (ensure you have Gson library in your classpath):
javac -cp .:gson.jar ChargebeeWebhookServer.java
java -cp .:gson.jar ChargebeeWebhookServer
using System;
using System.IO;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapPost("/webhooks/chargebee", async (HttpContext context) =>
{
using var reader = new StreamReader(context.Request.Body);
var body = await reader.ReadToEndAsync();
var payload = JsonSerializer.Deserialize<JsonElement>(body);
Console.WriteLine("Received Chargebee webhook:");
Console.WriteLine(JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }));
return Results.Ok("Webhook received");
});
app.Run("http://localhost:3000");
Run the application:
dotnet run
Creating a Hookdeck Connection
Hookdeck Event Gateway sits between Chargebee and your application, providing reliable webhook ingestion, delivery, and observability.
Start the Hookdeck CLI to create a connection to your local server:
hookdeck listen 3000 --path /webhooks/chargebee
The CLI will prompt you to create a connection and will provide you with a public webhook URL. This URL is what you'll register with Chargebee.
The output will look similar to:
$ hookdeck listen 3000 --path /webhooks/chargebee
●── HOOKDECK CLI ──●
Listening on 1 source • 1 connection • [i] Collapse
chargebee
│ Requests to → https://hkdk.events/src_xxx
└─ Forwards to → http://localhost:3000/webhooks/chargebee (chargebee-cli)
Copy the Hookdeck source URL shown in the output (e.g., https://hkdk.events/src_xxx).
Local Development Workflow ->
Learn how to efficiently build and debug webhook handlers using the CLI's interactive features with retry functionality and test event libraries.
Registering Your Webhook in Chargebee
Now you'll register your Hookdeck URL with Chargebee to start receiving events.
- Log in to your Chargebee account, ensure you're within the Billing product section, and navigate to Settings > Configure Chargebee > Webhooks.
- Click Add Webhook.
- Give your webhook a name (e.g., "My App Integration").
- For the Webhook URL, paste the Hookdeck source URL you copied from the CLI.
- Under Events to send, select specific events. For this tutorial, select
subscription_createdandsubscription_cancelled. - Click Create Webhook.
Chargebee will now send webhook events to Hookdeck, which will forward them to your local server.
Testing Your Webhook Integration
Trigger a test event from the Chargebee UI or create a test subscription. You'll see the webhook event appear in two places: the terminal running the Hookdeck CLI and the terminal running your Node.js server. You can also view it in the Hookdeck dashboard for additional details.
Both the Hookdeck CLI and dashboard provide full visibility into:
- Request headers and payload from Chargebee
- Delivery attempts to your local server
- Response status and timing
- Any errors that occurred
Securing Webhooks with Basic Authentication
To ensure that webhook requests are genuinely from Chargebee, you should implement authentication. Chargebee Billing webhooks support basic authentication for webhook endpoints.
Configuring Basic Authentication in Chargebee
When creating or editing a webhook in the Chargebee dashboard:
- Navigate to Settings > Configure Chargebee > Webhooks and edit your webhook.
- In the webhook configuration, look for the Basic Authentication section.
- Enter a username and password.
- Click Save.
Chargebee will now send these credentials in the Authorization header with each webhook request.
Validating Basic Authentication in Your Endpoint
Here's how to modify your Node.js server to validate basic authentication:
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
const port = 3000;
// Store these securely in environment variables
const WEBHOOK_USERNAME = process.env.WEBHOOK_USERNAME || 'your_username';
const WEBHOOK_PASSWORD = process.env.WEBHOOK_PASSWORD || 'your_password';
app.use(bodyParser.json());
// Basic authentication middleware
function validateBasicAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Basic ')) {
return res.status(401).send('Unauthorized: Missing authentication');
}
// Decode base64 credentials
const base64Credentials = authHeader.split(' ')[1];
const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8');
const [username, password] = credentials.split(':');
if (username !== WEBHOOK_USERNAME || password !== WEBHOOK_PASSWORD) {
return res.status(401).send('Unauthorized: Invalid credentials');
}
next();
}
app.post('/webhooks/chargebee', validateBasicAuth, (req, res) => {
console.log('Received and authenticated Chargebee webhook:');
console.log(JSON.stringify(req.body, null, 2));
res.status(200).send('Webhook received');
});
app.listen(port, () => {
console.log(`Server listening at http://localhost:${port}`);
});
from flask import Flask, request
import json
import base64
import os
app = Flask(__name__)
# Store these securely in environment variables
WEBHOOK_USERNAME = os.getenv('WEBHOOK_USERNAME', 'your_username')
WEBHOOK_PASSWORD = os.getenv('WEBHOOK_PASSWORD', 'your_password')
def validate_basic_auth():
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Basic '):
return False
try:
base64_credentials = auth_header.split(' ')[1]
credentials = base64.b64decode(base64_credentials).decode('utf-8')
username, password = credentials.split(':', 1)
return username == WEBHOOK_USERNAME and password == WEBHOOK_PASSWORD
except Exception:
return False
@app.route('/webhooks/chargebee', methods=['POST'])
def chargebee_webhook():
if not validate_basic_auth():
return 'Unauthorized: Invalid credentials', 401
print('Received and authenticated Chargebee webhook:')
print(json.dumps(request.get_json(), indent=2))
return 'Webhook received', 200
if __name__ == '__main__':
app.run(port=3000)
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
)
func validateBasicAuth(r *http.Request) bool {
authHeader := r.Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Basic ") {
return false
}
base64Credentials := strings.TrimPrefix(authHeader, "Basic ")
credentials, err := base64.StdEncoding.DecodeString(base64Credentials)
if err != nil {
return false
}
parts := strings.SplitN(string(credentials), ":", 2)
if len(parts) != 2 {
return false
}
username, password := parts[0], parts[1]
expectedUsername := os.Getenv("WEBHOOK_USERNAME")
expectedPassword := os.Getenv("WEBHOOK_PASSWORD")
if expectedUsername == "" {
expectedUsername = "your_username"
}
if expectedPassword == "" {
expectedPassword = "your_password"
}
return username == expectedUsername && password == expectedPassword
}
func chargebeeWebhook(w http.ResponseWriter, r *http.Request) {
if !validateBasicAuth(r) {
http.Error(w, "Unauthorized: Invalid credentials", http.StatusUnauthorized)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer r.Body.Close()
var payload interface{}
if err := json.Unmarshal(body, &payload); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
fmt.Println("Received and authenticated Chargebee webhook:")
prettyJSON, _ := json.MarshalIndent(payload, "", " ")
fmt.Println(string(prettyJSON))
w.WriteHeader(http.StatusOK)
w.Write([]byte("Webhook received"))
}
func main() {
http.HandleFunc("/webhooks/chargebee", chargebeeWebhook)
log.Println("Server listening at http://localhost:3000")
log.Fatal(http.ListenAndServe(":3000", nil))
}
require 'sinatra'
require 'json'
require 'base64'
set :port, 3000
# Store these securely in environment variables
WEBHOOK_USERNAME = ENV['WEBHOOK_USERNAME'] || 'your_username'
WEBHOOK_PASSWORD = ENV['WEBHOOK_PASSWORD'] || 'your_password'
def validate_basic_auth(request)
auth_header = request.env['HTTP_AUTHORIZATION']
return false if auth_header.nil? || !auth_header.start_with?('Basic ')
begin
base64_credentials = auth_header.split(' ')[1]
credentials = Base64.decode64(base64_credentials)
username, password = credentials.split(':', 2)
username == WEBHOOK_USERNAME && password == WEBHOOK_PASSWORD
rescue
false
end
end
post '/webhooks/chargebee' do
unless validate_basic_auth(request)
halt 401, 'Unauthorized: Invalid credentials'
end
request.body.rewind
payload = JSON.parse(request.body.read)
puts 'Received and authenticated Chargebee webhook:'
puts JSON.pretty_generate(payload)
status 200
body 'Webhook received'
end
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Log;
Route::post('/webhooks/chargebee', function (Request $request) {
// Store these securely in environment variables
$webhookUsername = env('WEBHOOK_USERNAME', 'your_username');
$webhookPassword = env('WEBHOOK_PASSWORD', 'your_password');
// Validate basic authentication
$authHeader = $request->header('Authorization');
if (empty($authHeader) || !str_starts_with($authHeader, 'Basic ')) {
return response('Unauthorized: Missing authentication', 401);
}
$base64Credentials = substr($authHeader, 6);
$credentials = base64_decode($base64Credentials);
[$username, $password] = explode(':', $credentials, 2);
if ($username !== $webhookUsername || $password !== $webhookPassword) {
return response('Unauthorized: Invalid credentials', 401);
}
// Process the webhook
$event = $request->json()->all();
Log::info('Received and authenticated Chargebee webhook:', $event);
return response('Webhook received', 200);
});
import com.sun.net.httpserver.*;
import java.io.*;
import java.net.InetSocketAddress;
import java.util.Base64;
import com.google.gson.*;
public class ChargebeeWebhookServer {
private static final String WEBHOOK_USERNAME =
System.getenv("WEBHOOK_USERNAME") != null ?
System.getenv("WEBHOOK_USERNAME") : "your_username";
private static final String WEBHOOK_PASSWORD =
System.getenv("WEBHOOK_PASSWORD") != null ?
System.getenv("WEBHOOK_PASSWORD") : "your_password";
private static boolean validateBasicAuth(Headers headers) {
String authHeader = headers.getFirst("Authorization");
if (authHeader == null || !authHeader.startsWith("Basic ")) {
return false;
}
try {
String base64Credentials = authHeader.substring(6);
String credentials = new String(Base64.getDecoder().decode(base64Credentials));
String[] parts = credentials.split(":", 2);
if (parts.length != 2) {
return false;
}
String username = parts[0];
String password = parts[1];
return username.equals(WEBHOOK_USERNAME) && password.equals(WEBHOOK_PASSWORD);
} catch (Exception e) {
return false;
}
}
public static void main(String[] args) throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress(3000), 0);
Gson gson = new GsonBuilder().setPrettyPrinting().create();
server.createContext("/webhooks/chargebee", exchange -> {
if ("POST".equals(exchange.getRequestMethod())) {
if (!validateBasicAuth(exchange.getRequestHeaders())) {
String response = "Unauthorized: Invalid credentials";
exchange.sendResponseHeaders(401, response.length());
OutputStream os = exchange.getResponseBody();
os.write(response.getBytes());
os.close();
return;
}
InputStreamReader isr = new InputStreamReader(exchange.getRequestBody());
BufferedReader br = new BufferedReader(isr);
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) {
sb.append(line);
}
JsonObject payload = JsonParser.parseString(sb.toString()).getAsJsonObject();
System.out.println("Received and authenticated Chargebee webhook:");
System.out.println(gson.toJson(payload));
String response = "Webhook received";
exchange.sendResponseHeaders(200, response.length());
OutputStream os = exchange.getResponseBody();
os.write(response.getBytes());
os.close();
}
});
server.start();
System.out.println("Server listening at http://localhost:3000");
}
}
using System;
using System.IO;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Store these securely in environment variables
var webhookUsername = Environment.GetEnvironmentVariable("WEBHOOK_USERNAME") ?? "your_username";
var webhookPassword = Environment.GetEnvironmentVariable("WEBHOOK_PASSWORD") ?? "your_password";
bool ValidateBasicAuth(HttpContext context)
{
var authHeader = context.Request.Headers["Authorization"].ToString();
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Basic "))
{
return false;
}
try
{
var base64Credentials = authHeader.Substring(6);
var credentials = Encoding.UTF8.GetString(Convert.FromBase64String(base64Credentials));
var parts = credentials.Split(':', 2);
if (parts.Length != 2)
{
return false;
}
var username = parts[0];
var password = parts[1];
return username == webhookUsername && password == webhookPassword;
}
catch
{
return false;
}
}
app.MapPost("/webhooks/chargebee", async (HttpContext context) =>
{
if (!ValidateBasicAuth(context))
{
return Results.Text("Unauthorized: Invalid credentials", statusCode: 401);
}
using var reader = new StreamReader(context.Request.Body);
var body = await reader.ReadToEndAsync();
var payload = JsonSerializer.Deserialize<JsonElement>(body);
Console.WriteLine("Received and authenticated Chargebee webhook:");
Console.WriteLine(JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }));
return Results.Ok("Webhook received");
});
app.Run("http://localhost:3000");
This code validates the Authorization header sent by Chargebee. If the credentials don't match or are missing, the request is rejected with a 401 status.
Note: Chargebee Billing webhooks do not currently support HMAC signature verification. Basic authentication is the recommended method for securing webhook endpoints.
Best Practices for Working with Chargebee Webhooks
Automatic Response Handling with Hookdeck
When using Hookdeck Event Gateway, you don't need to worry about responding quickly to Chargebee. Hookdeck automatically acknowledges webhook deliveries from Chargebee with a 200 OK response, then queues the event for delivery to your application.
This means your application has up to 60 seconds to process each event (Hookdeck's delivery timeout), rather than being constrained by Chargebee's timeout requirements. Hookdeck handles the communication with Chargebee, so you can focus on processing the event data.
Understanding Hookdeck's Retry Mechanism
Since Hookdeck ingests webhooks from Chargebee immediately, you no longer need to worry about Chargebee's 7-retry limit. Hookdeck guarantees that events are captured and queued, even if your application is temporarily unavailable.
Hookdeck provides configurable automatic retries for delivering events to your application:
- Flexible retry strategies: Configure exponential or linear backoff with custom intervals
- Extended retry windows: Retry for up to 30 days depending on your plan
- Persistent storage: Events are stored and can be manually retried even after automatic retries are exhausted
Configure your retry strategy in the Hookdeck dashboard under your connection settings to match your application's needs.
Implement Idempotency
Because Hookdeck guarantees at-least-once delivery, your endpoint may receive the same webhook event multiple times. Your application must handle duplicate events gracefully to prevent processing the same event twice (e.g., charging a customer multiple times or creating duplicate records).
Use the unique event id from the Chargebee webhook payload to track which events you've already processed. According to Chargebee's webhook documentation, each event has a unique id field.
Here's an example using a database abstraction layer for idempotency checking:
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
const port = 3000;
app.use(bodyParser.json());
// Database abstraction for checking processed events
// In production, implement this with Redis, PostgreSQL, MongoDB, etc.
class EventStore {
constructor() {
this.processedEvents = new Set(); // Replace with actual database
}
async hasProcessed(eventId) {
// Check if event ID exists in your database
return this.processedEvents.has(eventId);
}
async markAsProcessed(eventId) {
// Store event ID in your database with appropriate TTL
this.processedEvents.add(eventId);
}
}
const eventStore = new EventStore();
app.post('/webhooks/chargebee', async (req, res) => {
const event = req.body;
const eventId = event.id;
// Check if we've already processed this event
if (await eventStore.hasProcessed(eventId)) {
console.log(`Event ${eventId} already processed, skipping...`);
return res.status(200).send('Event already processed');
}
// Process the event
console.log('Processing new event:', eventId);
console.log(JSON.stringify(event, null, 2));
// Your business logic here
// await processSubscriptionEvent(event);
// Mark as processed
await eventStore.markAsProcessed(eventId);
res.status(200).send('Webhook received and processed');
});
app.listen(port, () => {
console.log(`Server listening at http://localhost:${port}`);
});
from flask import Flask, request
import json
app = Flask(__name__)
# Database abstraction for checking processed events
# In production, implement this with Redis, PostgreSQL, MongoDB, etc.
class EventStore:
def __init__(self):
self.processed_events = set() # Replace with actual database
async def has_processed(self, event_id):
# Check if event ID exists in your database
return event_id in self.processed_events
async def mark_as_processed(self, event_id):
# Store event ID in your database with appropriate TTL
self.processed_events.add(event_id)
event_store = EventStore()
@app.route('/webhooks/chargebee', methods=['POST'])
async def chargebee_webhook():
event = request.get_json()
event_id = event.get('id')
# Check if we've already processed this event
if await event_store.has_processed(event_id):
print(f'Event {event_id} already processed, skipping...')
return 'Event already processed', 200
# Process the event
print(f'Processing new event: {event_id}')
print(json.dumps(event, indent=2))
# Your business logic here
# await process_subscription_event(event)
# Mark as processed
await event_store.mark_as_processed(event_id)
return 'Webhook received and processed', 200
if __name__ == '__main__':
app.run(port=3000)
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"sync"
)
// Database abstraction for checking processed events
// In production, implement this with Redis, PostgreSQL, MongoDB, etc.
type EventStore struct {
mu sync.RWMutex
processedEvents map[string]bool // Replace with actual database
}
func NewEventStore() *EventStore {
return &EventStore{
processedEvents: make(map[string]bool),
}
}
func (es *EventStore) HasProcessed(eventID string) bool {
es.mu.RLock()
defer es.mu.RUnlock()
return es.processedEvents[eventID]
}
func (es *EventStore) MarkAsProcessed(eventID string) {
es.mu.Lock()
defer es.mu.Unlock()
es.processedEvents[eventID] = true
}
var eventStore = NewEventStore()
type ChargebeeEvent struct {
ID string `json:"id"`
}
func chargebeeWebhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer r.Body.Close()
var event map[string]interface{}
if err := json.Unmarshal(body, &event); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
eventID := event["id"].(string)
// Check if we've already processed this event
if eventStore.HasProcessed(eventID) {
fmt.Printf("Event %s already processed, skipping...\n", eventID)
w.WriteHeader(http.StatusOK)
w.Write([]byte("Event already processed"))
return
}
// Process the event
fmt.Printf("Processing new event: %s\n", eventID)
prettyJSON, _ := json.MarshalIndent(event, "", " ")
fmt.Println(string(prettyJSON))
// Your business logic here
// processSubscriptionEvent(event)
// Mark as processed
eventStore.MarkAsProcessed(eventID)
w.WriteHeader(http.StatusOK)
w.Write([]byte("Webhook received and processed"))
}
func main() {
http.HandleFunc("/webhooks/chargebee", chargebeeWebhook)
log.Println("Server listening at http://localhost:3000")
log.Fatal(http.ListenAndServe(":3000", nil))
}
require 'sinatra'
require 'json'
require 'set'
set :port, 3000
# Database abstraction for checking processed events
# In production, implement this with Redis, PostgreSQL, MongoDB, etc.
class EventStore
def initialize
@processed_events = Set.new # Replace with actual database
@mutex = Mutex.new
end
def has_processed?(event_id)
@mutex.synchronize { @processed_events.include?(event_id) }
end
def mark_as_processed(event_id)
@mutex.synchronize { @processed_events.add(event_id) }
end
end
$event_store = EventStore.new
post '/webhooks/chargebee' do
request.body.rewind
event = JSON.parse(request.body.read)
event_id = event['id']
# Check if we've already processed this event
if $event_store.has_processed?(event_id)
puts "Event #{event_id} already processed, skipping..."
status 200
body 'Event already processed'
return
end
# Process the event
puts "Processing new event: #{event_id}"
puts JSON.pretty_generate(event)
# Your business logic here
# process_subscription_event(event)
# Mark as processed
$event_store.mark_as_processed(event_id)
status 200
body 'Webhook received and processed'
end
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
Route::post('/webhooks/chargebee', function (Request $request) {
$event = $request->json()->all();
$eventId = $event['id'];
// Check if we've already processed this event
// In production, use Cache::has() with Redis or database-backed cache
if (Cache::has("chargebee_event_{$eventId}")) {
Log::info("Event {$eventId} already processed, skipping...");
return response('Event already processed', 200);
}
// Process the event
Log::info("Processing new event: {$eventId}", $event);
// Your business logic here
// processSubscriptionEvent($event);
// Mark as processed with 7-day TTL
// In production, adjust TTL based on your requirements
Cache::put("chargebee_event_{$eventId}", true, now()->addDays(7));
return response('Webhook received and processed', 200);
});
import com.sun.net.httpserver.*;
import java.io.*;
import java.net.InetSocketAddress;
import java.util.HashSet;
import java.util.Set;
import java.util.Collections;
import com.google.gson.*;
public class ChargebeeWebhookServer {
// Database abstraction for checking processed events
// In production, implement this with Redis, PostgreSQL, MongoDB, etc.
static class EventStore {
private final Set<String> processedEvents = Collections.synchronizedSet(new HashSet<>());
public boolean hasProcessed(String eventId) {
// Check if event ID exists in your database
return processedEvents.contains(eventId);
}
public void markAsProcessed(String eventId) {
// Store event ID in your database with appropriate TTL
processedEvents.add(eventId);
}
}
private static final EventStore eventStore = new EventStore();
private static final Gson gson = new GsonBuilder().setPrettyPrinting().create();
public static void main(String[] args) throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress(3000), 0);
server.createContext("/webhooks/chargebee", exchange -> {
if ("POST".equals(exchange.getRequestMethod())) {
InputStreamReader isr = new InputStreamReader(exchange.getRequestBody());
BufferedReader br = new BufferedReader(isr);
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) {
sb.append(line);
}
JsonObject event = JsonParser.parseString(sb.toString()).getAsJsonObject();
String eventId = event.get("id").getAsString();
// Check if we've already processed this event
if (eventStore.hasProcessed(eventId)) {
System.out.println("Event " + eventId + " already processed, skipping...");
String response = "Event already processed";
exchange.sendResponseHeaders(200, response.length());
OutputStream os = exchange.getResponseBody();
os.write(response.getBytes());
os.close();
return;
}
// Process the event
System.out.println("Processing new event: " + eventId);
System.out.println(gson.toJson(event));
// Your business logic here
// processSubscriptionEvent(event);
// Mark as processed
eventStore.markAsProcessed(eventId);
String response = "Webhook received and processed";
exchange.sendResponseHeaders(200, response.length());
OutputStream os = exchange.getResponseBody();
os.write(response.getBytes());
os.close();
}
});
server.start();
System.out.println("Server listening at http://localhost:3000");
}
}
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Database abstraction for checking processed events
// In production, implement this with Redis, PostgreSQL, MongoDB, etc.
class EventStore
{
private readonly HashSet<string> processedEvents = new(); // Replace with actual database
private readonly object lockObject = new();
public bool HasProcessed(string eventId)
{
// Check if event ID exists in your database
lock (lockObject)
{
return processedEvents.Contains(eventId);
}
}
public void MarkAsProcessed(string eventId)
{
// Store event ID in your database with appropriate TTL
lock (lockObject)
{
processedEvents.Add(eventId);
}
}
}
var eventStore = new EventStore();
app.MapPost("/webhooks/chargebee", async (HttpContext context) =>
{
using var reader = new StreamReader(context.Request.Body);
var body = await reader.ReadToEndAsync();
var eventData = JsonSerializer.Deserialize<JsonElement>(body);
var eventId = eventData.GetProperty("id").GetString();
// Check if we've already processed this event
if (eventStore.HasProcessed(eventId))
{
Console.WriteLine($"Event {eventId} already processed, skipping...");
return Results.Ok("Event already processed");
}
// Process the event
Console.WriteLine($"Processing new event: {eventId}");
Console.WriteLine(JsonSerializer.Serialize(eventData, new JsonSerializerOptions { WriteIndented = true }));
// Your business logic here
// await ProcessSubscriptionEvent(eventData);
// Mark as processed
eventStore.MarkAsProcessed(eventId);
return Results.Ok("Webhook received and processed");
});
app.Run("http://localhost:3000");
In production environments, implement the EventStore class using a persistent database:
- Redis: Use
SETwithEXfor TTL-based expiration - PostgreSQL: Create a table with
event_idandprocessed_atcolumns, with an index onevent_id - MongoDB: Store documents with
eventIdandprocessedAtfields, with a TTL index
Set an appropriate TTL (e.g., 7-30 days) to prevent unbounded growth while ensuring you can detect duplicates within a reasonable window.
Handle Timeout Requirements
When using Hookdeck Event Gateway, your application has a 60-second timeout to process each webhook delivery. This is significantly more generous than typical webhook timeout requirements and gives you time to perform necessary processing.
While Chargebee may have its own timeout requirements (typically shorter), Hookdeck handles the immediate acknowledgment to Chargebee. Your application simply needs to respond to Hookdeck within 60 seconds.
For processing that takes longer than 60 seconds, acknowledge the webhook immediately and queue the work for asynchronous processing in a background job system.
Chargebee vs Hookdeck Event Gateway
Understanding the differences between Chargebee's default webhook behavior and what Hookdeck provides helps you build more reliable integrations:
| Feature | Chargebee Default | With Hookdeck Event Gateway |
|---|---|---|
| Acknowledgment | Requires 2XX response from your endpoint | Hookdeck acknowledges immediately; your app has 60 seconds |
| Retry Attempts | Up to 7 retries with exponential backoff | Configurable retries up to 30 days; no limit on attempts |
| Timeout Handling | Your endpoint must respond within 30 seconds | Hookdeck buffers the event; your app has 60-second processing window |
| Observability | Basic delivery logs in Chargebee dashboard | Full event tracking, searchable logs, delivery attempts, error details |
| Failed Event Recovery | Manual API reconciliation required | One-click manual retry or bulk retry from dashboard |
| Rate Limiting | No built-in control | Configurable rate limiting per destination |
| Event Transformation | Not supported | Transform payloads using JavaScript before delivery |
| Multiple Destinations | Requires separate webhook subscriptions | Fan-out to multiple endpoints from single event |
Deploying to Production with Hookdeck
When you're ready to deploy your webhook handler to production:
- Update your Hookdeck destination to point to your production endpoint URL
- Configure authentication in Hookdeck to secure delivery to your production endpoint
- Set up retry policies that match your application's availability and recovery time
- Enable rate limiting if your endpoint has throughput constraints
- Configure issue notifications to alert your team when events fail repeatedly
Hookdeck provides production-grade webhook infrastructure without requiring you to build and maintain:
- Persistent queuing that survives application downtime
- Automatic retries with exponential backoff
- Event replay for recovering from incidents
- Searchable logs for debugging and auditing
- Rate limiting to protect your endpoints
- Transformations to normalize or enrich webhook data
- Fan-out to deliver events to multiple destinations
Next Steps
Now that you've set up your Chargebee webhook integration, you're ready to build and refine your webhook handlers.
Build, Test, and Debug Chargebee Webhooks on Localhost ->
Learn how to use the CLI's interactive mode for efficient local development with retry functionality and test event libraries.
Additional Resources:
Conclusion
Chargebee webhooks are a powerful tool for building real-time integrations with your subscription billing system. By following the steps in this guide, you can set up, test, and secure your webhooks for a reliable and robust integration.
Using Hookdeck Event Gateway from the start ensures you never miss critical billing events, even during system outages or maintenance windows. Hookdeck handles the complexity of webhook infrastructure—ingestion, queueing, retries, and observability—so you can focus on building your core application logic.
Get started with Hookdeck for free to build reliable Chargebee integrations without the infrastructure overhead.