Skip to content

Webhooks ​

Webhooks let you receive HTTP POST notifications when events happen in Bella Baxter — secret changes, lease expirations, API key rotation, and more.


Create a Webhook ​

sh
bella webhooks create \
  --url https://your-service.example.com/webhooks/bella \
  --events SecretCreated,SecretUpdated,SecretDeleted \
  --project my-api \
  --environment production \
  --name "Secret change notifications"

Or from the WebApp: Project → Settings → Webhooks → Add Webhook


Event Types ​

EventDescription
SecretCreatedA secret was created
SecretUpdatedA secret value was changed
SecretDeletedA secret was deleted
EnvironmentCreatedA new environment was created
EnvironmentDeletedAn environment was deleted
LeaseIssuedA new lease was issued
LeaseExpiredA lease expired
LeaseRevokedA lease was manually revoked
ApiKeyCreatedA new API key was created
ApiKeyRevokedAn API key was revoked
MemberAddedA user was added to a project or environment
MemberRemovedA user was removed
SecurityScanFailedA security intelligence scan detected an issue
secret.rotation.succeededA secret was rotated successfully by a rotation policy
secret.rotation.failedA rotation attempt failed (error details in metadata.errorMessage)
secret.expiry.warningA secret is expiring within the configured warning window (days in metadata.daysUntilExpiry)
secret.expiredA secret has passed its expiry date
certificate.rotation.succeededA certificate rotation completed — every eligible host is serving the new certificate
certificate.rotation.partialSome hosts are serving the new certificate and some are not; the target needs attention
certificate.rotation.failedA rotation failed. The previous certificate is untouched and still being served
certificate.rotation.missedA scheduled rotation came due but could not start (reason in metadata.missedReason)

Certificate rotation payloads ​

One event per rotation, never per host or per certificate — a bulk appliance run over fifty certificates is a single event carrying counts. Per-certificate detail stays in the rotation history and the rotation report.

metadata carries: outcome, targetId, rotationId (absent for .missed), primaryDomain, sanList, trigger, occurredAt; hostsSucceeded/hostsFailed for multi-host targets; certificatesEvaluated/certificatesDeployed/certificatesUnchanged/certificatesFailed for bulk appliance runs; failureCategory and failureDetail on failure; missedReason and occurrenceDueAt for a missed occurrence; notAfter (the new expiry) on success.

Payloads are metadata only. They never contain certificate material, private keys, passphrases, secret values or vault paths.

Retired in 2026-09. cert_rotation.completed and cert_rotation.failed appeared in the subscription picker between 2026-05-03 and 2026-09-15 but were never emitted by any part of the product — a subscription naming either has always received nothing. They have been removed from the picker. Existing subscriptions that name them remain valid and continue to receive nothing; subscribe to the certificate.rotation.* types above instead.

If you stream business events to a SIEM. An audit-stream destination with the business-event mirror enabled will now receive these four types in addition to the certificate rotation audit rows it already receives. The two describe the same rotation from different angles: an audit row is per host / per certificate, a business event is per rotation. No existing envelope changes; this is new volume on an existing feed, so rules that count events may need adjusting.


Webhook Payload ​

json
{
  "id": "evt-7e98d73e...",
  "type": "SecretUpdated",
  "timestamp": "2026-03-18T10:30:00Z",
  "tenantId": "...",
  "data": {
    "resourceId": "...",
    "projectId": "...",
    "projectSlug": "my-api",
    "environmentId": "...",
    "environmentSlug": "production",
    "key": "DATABASE_URL",
    "metadata": {}
  }
}

Secret values are never included

Webhook payloads contain metadata only — never secret values.


Webhook Security (HMAC Signature) ​

Every request includes an X-Bella-Signature header for authentication:

X-Bella-Signature: t=1710760200,v1=abc123def456...

Signature algorithm:

signing_input = "{t}.{rawBodyJson}"          # timestamp dot raw body (UTF-8)
hmac          = HMAC-SHA256(UTF8(secret), UTF8(signing_input))
v1            = lowercase_hex(hmac)

The HMAC key is the raw whsec-xxx string (UTF-8 encoded) — not hex-decoded.

A 5-minute replay window is enforced: reject requests where |now - t| > 300 seconds.

Always read the raw body bytes before any framework parsing — body parsers may re-serialise JSON differently, breaking the HMAC.


Receiving Webhooks ​

typescript
// Express — uses @bella-baxter/sdk for verified signature check
import express from 'express'
import { verifyWebhookSignature } from '@bella-baxter/sdk'

const app = express()
const secret = process.env.BELLA_WEBHOOK_SECRET ?? ''

app.post('/webhooks', express.raw({ type: '*/*' }), async (req, res) => {
  const rawBody = req.body as Buffer          // express.raw() gives us a Buffer
  const signature = req.headers['x-bella-signature'] as string ?? ''

  if (secret) {
    const valid = await verifyWebhookSignature(secret, signature, rawBody)
    if (!valid) return res.status(401).json({ error: 'Invalid signature' })
  }

  const payload = JSON.parse(rawBody.toString())
  console.log('Event received:', payload.type, payload.data?.key)

  res.json({ received: true })
})

app.listen(3000)
python
# FastAPI — uses bella_baxter.webhook_signature
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from bella_baxter.webhook_signature import verify_webhook_signature
import os, json

app = FastAPI()
SECRET = os.environ.get('BELLA_WEBHOOK_SECRET', '')

@app.post('/webhooks')
async def receive_webhook(request: Request):
    raw_body = await request.body()           # bytes — must read before any parsing
    signature = request.headers.get('x-bella-signature', '')

    if SECRET:
        valid = verify_webhook_signature(
            secret=SECRET,
            signature_header=signature,
            raw_body=raw_body,
        )
        if not valid:
            return JSONResponse({'error': 'Invalid signature'}, status_code=401)

    payload = json.loads(raw_body)
    print(f"Event: {payload.get('type')} / {payload.get('data', {}).get('key')}")

    return {'received': True}
go
// net/http — uses github.com/cosmic-chimps/bella-baxter-go/bellabaxter
package main

import (
    "encoding/json"
    "io"
    "net/http"
    "os"

    "github.com/cosmic-chimps/bella-baxter-go/bellabaxter"
)

var secret = os.Getenv("BELLA_WEBHOOK_SECRET")

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    rawBody, err := io.ReadAll(r.Body)        // read raw bytes before any parsing
    if err != nil {
        http.Error(w, "read error", http.StatusInternalServerError)
        return
    }

    if secret != "" {
        sig := r.Header.Get("X-Bella-Signature")
        if !bellabaxter.VerifyWebhookSignature(secret, sig, rawBody) {
            http.Error(w, `{"error":"Invalid signature"}`, http.StatusUnauthorized)
            return
        }
    }

    var payload map[string]any
    _ = json.Unmarshal(rawBody, &payload)
    // handle payload...

    w.Header().Set("Content-Type", "application/json")
    w.Write([]byte(`{"received":true}`))
}

func main() {
    http.HandleFunc("/webhooks", webhookHandler)
    http.ListenAndServe(":8080", nil)
}
csharp
// ASP.NET Core minimal API — uses BellaBaxter.Client.WebhookSignatureVerifier
using BellaBaxter.Client;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var secret = app.Configuration["BELLA_WEBHOOK_SECRET"]
             ?? Environment.GetEnvironmentVariable("BELLA_WEBHOOK_SECRET");

app.MapPost("/webhooks", async (HttpContext ctx) =>
{
    // Read raw bytes BEFORE any middleware processes the body
    using var ms = new MemoryStream();
    await ctx.Request.Body.CopyToAsync(ms);
    var rawBody = ms.ToArray();

    if (!string.IsNullOrEmpty(secret))
    {
        var signature = ctx.Request.Headers["X-Bella-Signature"].FirstOrDefault() ?? "";
        var valid = WebhookSignatureVerifier.Verify(secret, signature, rawBody);
        if (!valid) return Results.Json(new { error = "Invalid signature" }, statusCode: 401);
    }

    // Parse payload
    var payload = System.Text.Json.JsonSerializer.Deserialize<BellaWebhookPayload>(rawBody);
    Console.WriteLine($"Event: {payload?.Type} / {payload?.Data?.Key}");

    return Results.Json(new { received = true });
});

app.Run();
java
// Spring Boot — WebhookSignatureVerifier (copy from sample or use SDK when published)
@RestController
@RequestMapping("/webhooks")
public class WebhookController {

    private final String secret = System.getenv("BELLA_WEBHOOK_SECRET");

    @PostMapping(consumes = MediaType.APPLICATION_OCTET_STREAM_VALUE)
    public ResponseEntity<?> receive(
            @RequestBody byte[] rawBody,          // raw bytes — don't use String binding
            @RequestHeader("X-Bella-Signature") String signature) {

        if (secret != null && !secret.isEmpty()) {
            String rawBodyStr = new String(rawBody, StandardCharsets.UTF_8);
            boolean valid = WebhookSignatureVerifier.verify(secret, signature, rawBodyStr);
            if (!valid) {
                return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                        .body(Map.of("error", "Invalid signature"));
            }
        }

        // Parse and handle payload
        // ObjectMapper objectMapper = new ObjectMapper();
        // ReceivedEvent event = objectMapper.readValue(rawBody, ReceivedEvent.class);

        return ResponseEntity.ok(Map.of("received", true));
    }
}
ruby
# Rails controller — uses BellaBaxter::WebhookSignature from bella_baxter gem
class WebhooksController < ActionController::Base
  protect_from_forgery with: :null_session   # webhooks don't have CSRF tokens

  def receive
    raw_body   = request.body.read           # must read raw before framework parsing
    sig_header = request.headers['X-Bella-Signature'].to_s
    secret     = ENV['BELLA_WEBHOOK_SECRET']

    if secret.present?
      begin
        valid = BellaBaxter::WebhookSignature.verify(
          secret:           secret,
          signature_header: sig_header,
          raw_body:         raw_body
        )
      rescue BellaBaxter::WebhookSignatureError
        render json: { error: 'Invalid signature' }, status: :unauthorized and return
      end

      unless valid
        render json: { error: 'Invalid signature' }, status: :unauthorized and return
      end
    end

    payload = JSON.parse(raw_body, symbolize_names: true)
    Rails.logger.info "Webhook: #{payload[:type]} / #{payload.dig(:data, :key)}"

    render json: { received: true }
  end
end
php
<?php
// Laravel controller — uses BellaBaxter\WebhookSignatureVerifier from bella-baxter package

namespace App\Http\Controllers;

use BellaBaxter\WebhookSignatureVerifier;
use Illuminate\Http\Request;

class WebhookController extends Controller
{
    public function receive(Request $request)
    {
        $rawBody   = $request->getContent();    // raw string — not parsed by Laravel
        $sigHeader = $request->header('X-Bella-Signature', '');
        $secret    = env('BELLA_WEBHOOK_SECRET', '');

        if ($secret !== '') {
            $valid = WebhookSignatureVerifier::verify($secret, $sigHeader, $rawBody);
            if (!$valid) {
                return response()->json(['error' => 'Invalid signature'], 401);
            }
        }

        $payload = json_decode($rawBody, true) ?? [];
        $type    = $payload['type'] ?? 'unknown';
        $key     = $payload['data']['key'] ?? null;

        logger("Webhook: {$type} / {$key}");

        return response()->json(['received' => true]);
    }
}

Signature Verification (Manual / No SDK) ​

If you're not using an SDK, implement verification in any language:

1. Parse header:  "t=1710760200,v1=abc123..."
   → timestamp = 1710760200
   → v1        = abc123...

2. Reject if |now() - timestamp| > 300 seconds   (replay protection)

3. Compute HMAC:
   signing_input = "{timestamp}.{rawBodyString}"
   expected      = HMAC-SHA256(key=UTF8(whsec-xxx), msg=UTF8(signing_input))
   expected_hex  = lowercase_hex(expected)

4. Compare expected_hex == v1  using constant-time comparison

HMAC key is NOT hex-decoded

Use the raw whsec-xxx string as the HMAC key (UTF-8 bytes). Do not base64- or hex-decode it first.


Local Development with ngrok ​

To test webhooks on your local machine, expose your endpoint with ngrok:

sh
# 1. Start your local server
npm run dev          # or: uvicorn app:app, go run ., dotnet run, etc.

# 2. Expose it publicly
ngrok http 3000      # replace 3000 with your server port

# Ngrok will print something like:
#   Forwarding  https://a1b2c3d4.ngrok-free.app → http://localhost:3000

# 3. Register the ngrok URL as your webhook endpoint in Bella Baxter:
bella webhooks create \
  --url https://a1b2c3d4.ngrok-free.app/webhooks \
  --events SecretCreated,SecretUpdated \
  --project my-api \
  --environment dev

# 4. Copy your webhook secret from the CLI output:
#   Signing secret: whsec-...
export BELLA_WEBHOOK_SECRET="whsec-..."

Full Sample Apps ​

Each sample is a complete runnable receiver with a live HTML dashboard (auto-refreshes every 3 s) that shows incoming events, signature validation status, and full payload inspection.

LanguageFrameworkLink
JavaScript / TypeScriptExpressapps/webhooks/js
PythonFastAPIapps/webhooks/python
Gonet/httpapps/webhooks/go
.NETASP.NET Core (minimal API)apps/webhooks/dotnet
JavaSpring Bootapps/webhooks/java
RubyRailsapps/webhooks/ruby
PHPLaravel (Lumen)apps/webhooks/php

All samples read BELLA_WEBHOOK_SECRET from the environment. If the variable is not set, signature validation is skipped — convenient for initial local testing.


Delivery & Retries ​

Failed deliveries are retried automatically up to 5 times with exponential backoff.

View recent delivery attempts in the WebApp: Project → Settings → Webhooks → View Log

Or via CLI:

sh
bella webhooks deliveries list <webhook-id>
bella webhooks deliveries retry <delivery-id>   # manually retry a failed delivery

Manage Webhooks ​

sh
bella webhooks list
bella webhooks get <webhook-id>
bella webhooks update <webhook-id> --active false   # pause
bella webhooks delete <webhook-id>

Released under the ELv2 License.