Why Use Webhooks?

Webhooks eliminate the need to continuously poll the API for status updates. Instead, Bluma notifies your server immediately when events occur, reducing API calls and providing instant updates. Benefits:
  • ⚡ Real-time notifications
  • 🔽 Reduced API usage
  • 💰 Lower rate limit consumption
  • 🎯 Event-driven architecture

Prerequisites

Before setting up webhooks, you need:
  • ✅ A publicly accessible HTTPS endpoint
  • ✅ A server that can receive POST requests
  • ✅ A Bluma API key (get one here)
Use ngrok or webhook.site for local development and testing. Production webhooks require HTTPS.

Step 1: Create Your Webhook Endpoint

Node.js/Express Example

import express from 'express';
import crypto from 'crypto';

const app = express();

// IMPORTANT: Use express.raw() for webhook routes to preserve body for signature verification
app.post('/webhooks/bluma',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.headers['x-bluma-signature'];
    const payload = req.body.toString();

    // Verify signature (see Step 3)
    if (!verifySignature(payload, signature)) {
      console.error('Invalid webhook signature');
      return res.status(401).send('Unauthorized');
    }

    // Parse and process event
    const event = JSON.parse(payload);
    handleWebhookEvent(event);

    // Always respond quickly (within 5 seconds)
    res.sendStatus(200);
  }
);

app.listen(3000);

Python/Flask Example

from flask import Flask, request
import hmac
import hashlib
import os

app = Flask(__name__)

@app.route('/webhooks/bluma', methods=['POST'])
def webhook():
    signature = request.headers.get('X-Bluma-Signature')
    payload = request.get_data()

    # Verify signature (see Step 3)
    if not verify_signature(payload, signature):
        return 'Unauthorized', 401

    # Parse and process event
    event = request.get_json()
    handle_webhook_event(event)

    # Always respond quickly
    return '', 200

if __name__ == '__main__':
    app.run(port=3000)
Critical: Always respond with a 2xx status code within 5 seconds, even if event processing takes longer. Process events asynchronously.

Step 2: Register Your Webhook

Register your webhook endpoint with the Bluma API:
curl -X POST https://api.getbluma.com/api/v1/webhooks \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.com/webhooks/bluma",
    "events": [
      "video.completed",
      "video.failed",
      "credits.low"
    ]
  }'
Response:
{
  "id": "webhook_abc123xyz",
  "url": "https://yourapp.com/webhooks/bluma",
  "events": ["video.completed", "video.failed", "credits.low"],
  "secret": "whsec_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
  "is_active": true,
  "created_at": "2025-11-03T10:30:00Z",
  "warning": "Save the webhook secret now. You will need it to verify webhook signatures."
}
IMPORTANT: The secret is shown only once. Save it securely - you’ll need it to verify webhook signatures.

Step 3: Verify Webhook Signatures

Always verify signatures to ensure webhooks are genuinely from Bluma and haven’t been tampered with.

How Signature Verification Works

Bluma signs each webhook with HMAC-SHA256:
signature = HMAC-SHA256(webhook_secret, request_body)
The signature is sent in the X-Bluma-Signature header as sha256=<hex_digest>.

Node.js Verification

import crypto from 'crypto';

const WEBHOOK_SECRET = process.env.BLUMA_WEBHOOK_SECRET;

function verifySignature(payload, signature) {
  // Compute expected signature
  const expectedSignature = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(payload)
    .digest('hex');

  // Compare signatures (constant-time comparison)
  const expectedFormat = `sha256=${expectedSignature}`;

  // Use timingSafeEqual to prevent timing attacks
  if (signature.length !== expectedFormat.length) {
    return false;
  }

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedFormat)
  );
}

// Usage in webhook handler
app.post('/webhooks/bluma',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.headers['x-bluma-signature'];
    const payload = req.body.toString();

    if (!verifySignature(payload, signature)) {
      console.error('❌ Invalid webhook signature');
      return res.status(401).send('Unauthorized');
    }

    console.log('✅ Signature verified');
    const event = JSON.parse(payload);
    // Process event...

    res.sendStatus(200);
  }
);

Python Verification

import hmac
import hashlib
import os

WEBHOOK_SECRET = os.getenv('BLUMA_WEBHOOK_SECRET')

def verify_signature(payload, signature):
    """Verify webhook signature using HMAC-SHA256"""
    expected_signature = hmac.new(
        WEBHOOK_SECRET.encode(),
        payload,
        hashlib.sha256
    ).hexdigest()

    expected_format = f'sha256={expected_signature}'

    # Constant-time comparison to prevent timing attacks
    return hmac.compare_digest(signature, expected_format)

# Usage in webhook handler
@app.route('/webhooks/bluma', methods=['POST'])
def webhook():
    signature = request.headers.get('X-Bluma-Signature')
    payload = request.get_data()

    if not verify_signature(payload, signature):
        print('❌ Invalid webhook signature')
        return 'Unauthorized', 401

    print('✅ Signature verified')
    event = request.get_json()
    # Process event...

    return '', 200
Always use constant-time comparison functions (crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python) to prevent timing attacks.

Step 4: Handle Events

Process different event types appropriately:

Complete Event Handler

async function handleWebhookEvent(event) {
  console.log(`Received event: ${event.type} (ID: ${event.id})`);

  switch (event.type) {
    case 'video.completed':
      await handleVideoCompleted(event.data);
      break;

    case 'video.failed':
      await handleVideoFailed(event.data);
      break;

    case 'video.queued':
      await handleVideoQueued(event.data);
      break;

    case 'video.processing':
      await handleVideoProcessing(event.data);
      break;

    case 'credits.low':
      await handleCreditsLow(event.data);
      break;

    case 'credits.exhausted':
      await handleCreditsExhausted(event.data);
      break;

    default:
      console.warn(`Unknown event type: ${event.type}`);
  }
}

async function handleVideoCompleted(video) {
  console.log(`✅ Video completed: ${video.id}`);
  console.log(`   URL: ${video.url}`);
  console.log(`   Duration: ${video.duration}s`);

  // Download video to your storage
  await downloadVideo(video.url, video.id);

  // Notify user
  await notifyUser(video.user_id, {
    title: 'Video Ready!',
    message: `Your ${video.template_id} video is ready to download.`,
    url: video.url
  });

  // Update database
  await db.videos.update({
    where: { id: video.id },
    data: {
      status: 'completed',
      url: video.url,
      completed_at: new Date()
    }
  });
}

async function handleVideoFailed(video) {
  console.error(`❌ Video failed: ${video.id}`);
  console.error(`   Error: ${video.error.detail}`);

  // Alert user
  await notifyUser(video.user_id, {
    title: 'Video Generation Failed',
    message: video.error.detail,
    severity: 'error'
  });

  // Update database
  await db.videos.update({
    where: { id: video.id },
    data: {
      status: 'failed',
      error: video.error.detail
    }
  });
}

async function handleCreditsLow(data) {
  console.warn(`⚠️ Credits running low: ${data.remaining} remaining`);

  // Alert admin
  await sendEmail({
    to: 'admin@yourapp.com',
    subject: 'Bluma Credits Running Low',
    body: `Only ${data.remaining} credits remaining. Consider purchasing more.`
  });
}

Step 5: Implement Idempotency

Webhooks may be delivered multiple times. Handle duplicates gracefully:

Using a Processed Events Cache

// In-memory cache (use Redis for production)
const processedEvents = new Set();

app.post('/webhooks/bluma',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const signature = req.headers['x-bluma-signature'];
    const payload = req.body.toString();

    // Verify signature
    if (!verifySignature(payload, signature)) {
      return res.status(401).send('Unauthorized');
    }

    const event = JSON.parse(payload);

    // Check if already processed
    if (processedEvents.has(event.id)) {
      console.log(`Duplicate event ${event.id}, skipping`);
      return res.sendStatus(200); // Still return 200!
    }

    // Process event
    await handleWebhookEvent(event);

    // Mark as processed
    processedEvents.add(event.id);

    res.sendStatus(200);
  }
);

Using Database

app.post('/webhooks/bluma',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const signature = req.headers['x-bluma-signature'];
    const payload = req.body.toString();

    if (!verifySignature(payload, signature)) {
      return res.status(401).send('Unauthorized');
    }

    const event = JSON.parse(payload);

    try {
      // Try to insert event (will fail if duplicate due to unique constraint)
      await db.webhook_events.create({
        data: {
          event_id: event.id,
          type: event.type,
          payload: event,
          processed_at: new Date()
        }
      });

      // New event - process it
      await handleWebhookEvent(event);

    } catch (error) {
      if (error.code === 'P2002') { // Prisma unique constraint error
        console.log(`Duplicate event ${event.id}, skipping`);
      } else {
        throw error;
      }
    }

    res.sendStatus(200);
  }
);

Step 6: Testing Locally

Option 1: Using ngrok

# Install ngrok
brew install ngrok  # macOS
# or download from https://ngrok.com

# Start your local server
npm start  # or python app.py

# In another terminal, expose your local server
ngrok http 3000

# Copy the HTTPS URL (e.g., https://abc123.ngrok.io)
# Register this as your webhook URL
curl -X POST https://api.getbluma.com/api/v1/webhooks \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -d '{
    "url": "https://abc123.ngrok.io/webhooks/bluma",
    "events": ["video.completed"]
  }'

# Trigger a test video
curl -X POST https://api.getbluma.com/api/v1/videos \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -d '{
    "template_id": "meme-dialogue",
    "context": { "prompt": "Test video" }
  }'

# Watch your local server logs for the webhook!

Option 2: Using webhook.site

  1. Go to webhook.site
  2. Copy your unique URL
  3. Register it as your webhook:
curl -X POST https://api.getbluma.com/api/v1/webhooks \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -d '{
    "url": "https://webhook.site/your-unique-id",
    "events": ["video.completed", "video.failed"]
  }'
  1. Generate a test video
  2. View the webhook payload in real-time at webhook.site
webhook.site is perfect for inspecting payloads, but won’t verify signatures. Use ngrok for full integration testing.

Step 7: Monitor Webhook Deliveries

Check webhook delivery status and debug failures:
curl https://api.getbluma.com/api/v1/webhooks/webhook_abc123xyz/deliveries \
  -H "Authorization: Bearer YOUR_API_KEY"
Response:
{
  "deliveries": [
    {
      "id": "delivery_abc123",
      "event_id": "evt_abc123xyz",
      "event_type": "video.completed",
      "attempt_number": 1,
      "status_code": 200,
      "duration_ms": 145,
      "error_message": null,
      "created_at": "2025-11-03T10:31:45Z"
    },
    {
      "id": "delivery_xyz789",
      "event_id": "evt_xyz789def",
      "event_type": "video.completed",
      "attempt_number": 2,
      "status_code": 500,
      "duration_ms": 5002,
      "error_message": "Connection timeout",
      "next_retry_at": "2025-11-03T10:37:00Z",
      "created_at": "2025-11-03T10:32:00Z"
    }
  ]
}

Production Best Practices

Respond Quickly

Return 200 status within 5 seconds. Queue events for async processing.

Verify Signatures

Always validate HMAC signatures before processing.

Handle Duplicates

Use event IDs for idempotency (database or cache).

Log Everything

Log all webhook deliveries for debugging and audit trails.

Use HTTPS

Production webhooks require HTTPS endpoints.

Monitor Failures

Set up alerts for delivery failures to prevent auto-disable.

Asynchronous Processing Pattern

import Queue from 'bull'; // or any job queue

const webhookQueue = new Queue('webhooks', process.env.REDIS_URL);

// Process queue in background
webhookQueue.process(async (job) => {
  const event = job.data;
  await handleWebhookEvent(event);
});

// Webhook endpoint - respond immediately
app.post('/webhooks/bluma',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const signature = req.headers['x-bluma-signature'];
    const payload = req.body.toString();

    if (!verifySignature(payload, signature)) {
      return res.status(401).send('Unauthorized');
    }

    const event = JSON.parse(payload);

    // Queue for async processing
    await webhookQueue.add(event);

    // Respond immediately (< 1 second)
    res.sendStatus(200);
  }
);

Troubleshooting

Check:
  • Is your endpoint publicly accessible via HTTPS?
  • Is your firewall blocking Bluma’s servers?
  • Are you returning a 2xx status code?
  • Check delivery logs for error messages
Test:
# Test your endpoint manually
curl -X POST https://yourapp.com/webhooks/bluma \
  -H "Content-Type: application/json" \
  -H "X-Bluma-Signature: sha256=test" \
  -d '{"test": true}'
Common issues:
  • Using parsed JSON instead of raw body
  • Wrong webhook secret (check environment variable)
  • Secret was regenerated
Fix:
// ✅ CORRECT - raw body
app.post('/webhooks/bluma',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const payload = req.body.toString(); // Raw bytes
    // ...
  }
);

// ❌ WRONG - parsed JSON
app.post('/webhooks/bluma',
  express.json(),
  (req, res) => {
    const payload = JSON.stringify(req.body); // Already parsed!
    // Signature will NEVER match
  }
);
This is expected behavior. Implement idempotency:
// Store processed event IDs
const processed = new Set();

if (processed.has(event.id)) {
  return res.sendStatus(200); // Still return 200!
}

await handleEvent(event);
processed.add(event.id);
Cause: 10 consecutive delivery failuresFix:
  1. Check delivery logs to identify the issue
  2. Fix your endpoint
  3. Delete and recreate the webhook
# Delete old webhook
curl -X DELETE https://api.getbluma.com/api/v1/webhooks/webhook_abc123 \
  -H "Authorization: Bearer YOUR_API_KEY"

# Create new webhook
curl -X POST https://api.getbluma.com/api/v1/webhooks \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -d '{"url": "https://yourapp.com/webhooks/bluma", ...}'

Complete Working Example

Here’s a production-ready webhook server:
import express from 'express';
import crypto from 'crypto';
import { createClient } from 'redis';

const app = express();
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

const WEBHOOK_SECRET = process.env.BLUMA_WEBHOOK_SECRET;

// Signature verification
function verifySignature(payload, signature) {
  const expectedSignature = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(payload)
    .digest('hex');

  const expectedFormat = `sha256=${expectedSignature}`;

  if (signature.length !== expectedFormat.length) return false;

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedFormat)
  );
}

// Webhook endpoint
app.post('/webhooks/bluma',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const signature = req.headers['x-bluma-signature'];
    const payload = req.body.toString();

    // Verify signature
    if (!verifySignature(payload, signature)) {
      console.error('❌ Invalid signature');
      return res.status(401).send('Unauthorized');
    }

    const event = JSON.parse(payload);

    // Check for duplicates (idempotency)
    const isDuplicate = await redis.get(`event:${event.id}`);
    if (isDuplicate) {
      console.log(`Duplicate event ${event.id}, skipping`);
      return res.sendStatus(200);
    }

    // Mark as received
    await redis.setex(`event:${event.id}`, 86400, 'processed');

    // Process event asynchronously
    processEventAsync(event).catch(error => {
      console.error('Event processing failed:', error);
    });

    // Respond immediately
    res.sendStatus(200);
  }
);

async function processEventAsync(event) {
  console.log(`Processing event: ${event.type} (${event.id})`);

  switch (event.type) {
    case 'video.completed':
      await downloadVideo(event.data.url, event.data.id);
      await notifyUser(event.data.user_id, 'Video ready!');
      break;

    case 'video.failed':
      await notifyUser(event.data.user_id, `Video failed: ${event.data.error.detail}`);
      break;

    case 'credits.low':
      await alertAdmin(`Credits low: ${event.data.remaining} remaining`);
      break;
  }
}

app.listen(3000, () => {
  console.log('✅ Webhook server running on port 3000');
});

Next Steps

Congratulations! 🎉

You’ve successfully set up webhooks for real-time event notifications. Your application will now receive instant updates when videos complete, eliminating the need for polling.