Author picture Phil Leggetter

How to Get Started with Chargebee Webhooks

Published


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:

Setting Up Your Local Development Server

Create a simple Node.js server to receive and process webhooks. Create a file named server.js:

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
app.py
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
server.go
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
server.rb
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
routes/web.php
<?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
ChargebeeWebhookServer.java
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
Program.cs
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.

  1. Log in to your Chargebee account, ensure you're within the Billing product section, and navigate to Settings > Configure Chargebee > Webhooks.
  2. Click Add Webhook.
  3. Give your webhook a name (e.g., "My App Integration").
  4. For the Webhook URL, paste the Hookdeck source URL you copied from the CLI.
  5. Under Events to send, select specific events. For this tutorial, select subscription_created and subscription_cancelled.
  6. 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:

  1. Navigate to Settings > Configure Chargebee > Webhooks and edit your webhook.
  2. In the webhook configuration, look for the Basic Authentication section.
  3. Enter a username and password.
  4. 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:

server.js
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}`);
});
app.py
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)
server.go
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))
}
server.rb
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
routes/web.php
<?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);
});
ChargebeeWebhookServer.java
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");
    }
}
Program.cs
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:

server.js
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}`);
});
app.py
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)
server.go
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))
}
server.rb
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
routes/web.php
<?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);
});
ChargebeeWebhookServer.java
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");
    }
}
Program.cs
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 SET with EX for TTL-based expiration
  • PostgreSQL: Create a table with event_id and processed_at columns, with an index on event_id
  • MongoDB: Store documents with eventId and processedAt fields, 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:

FeatureChargebee DefaultWith Hookdeck Event Gateway
AcknowledgmentRequires 2XX response from your endpointHookdeck acknowledges immediately; your app has 60 seconds
Retry AttemptsUp to 7 retries with exponential backoffConfigurable retries up to 30 days; no limit on attempts
Timeout HandlingYour endpoint must respond within 30 secondsHookdeck buffers the event; your app has 60-second processing window
ObservabilityBasic delivery logs in Chargebee dashboardFull event tracking, searchable logs, delivery attempts, error details
Failed Event RecoveryManual API reconciliation requiredOne-click manual retry or bulk retry from dashboard
Rate LimitingNo built-in controlConfigurable rate limiting per destination
Event TransformationNot supportedTransform payloads using JavaScript before delivery
Multiple DestinationsRequires separate webhook subscriptionsFan-out to multiple endpoints from single event

Deploying to Production with Hookdeck

When you're ready to deploy your webhook handler to production:

  1. Update your Hookdeck destination to point to your production endpoint URL
  2. Configure authentication in Hookdeck to secure delivery to your production endpoint
  3. Set up retry policies that match your application's availability and recovery time
  4. Enable rate limiting if your endpoint has throughput constraints
  5. Configure issue notifications to alert your team when events fail repeatedly

Hookdeck provides production-grade webhook infrastructure without requiring you to build and maintain:

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.


Author picture

Phil Leggetter

Head of Developer Relations

Phil is Head of Developer Relations at Hookdeck with expertise in real-time web technologies, co-author of 'Realtime Web Apps', and creator of the AAARRRP Developer Relations strategy framework.