Skip to content

Webhook Integration for Astro & SSG Sites

This guide shows you how to set up webhook integration for Astro and other SSG (Static Site Generation) websites. Your site remains fully static - the webhook receiver runs separately to trigger rebuilds.

We provide two solutions. Choose based on your hosting platform:

✅ Method 1: Standalone Webhook Server (Universal)

Section titled “✅ Method 1: Standalone Webhook Server (Universal)”

Best for: VPS, ECS, Docker, GitLab, self-hosted, any custom setup

  • 📦 Single Node.js or Python script
  • 🌍 Works everywhere - no platform lock-in
  • 🔧 Full control over deployment
  • 📂 Available at: blogshoot-web/webhook-server/

Jump to Standalone Server Setup

✅ Method 2: Cloudflare Pages Function (Serverless)

Section titled “✅ Method 2: Cloudflare Pages Function (Serverless)”

Best for: Sites deployed on Cloudflare Pages

  • ⚡ Zero configuration, automatic deployment
  • 🌐 Global edge network
  • 💰 Free tier sufficient for most users
  • 📂 Available at: blogshoot-web/functions/api/webhook.ts

Jump to Cloudflare Setup


When BlogShoot generates an article, it sends a webhook that triggers your site to rebuild:

BlogShoot generates article
POST /webhook (verify signature)
Trigger rebuild (git pull + npm run build, OR deploy hook)
Static site regenerated
New article published

Your Astro site stays 100% static SSG. The webhook receiver is a small separate service that:

  1. Receives webhook from BlogShoot
  2. Verifies HMAC-SHA256 signature
  3. Triggers rebuild (via command or deploy hook)

Universal solution that works anywhere. No platform dependencies.

  • Complete code provided below - Just copy and paste
  • Zero dependencies - Uses only built-in Node.js/Python modules
  • Works everywhere - VPS, ECS, Docker, systemd, PM2, AWS Lambda, etc.
  • Two language options - Node.js or Python, your choice

Step 1: Create webhook server file

Create a file named webhook-server.js and paste the following code:

📄 Click to view complete Node.js server code
#!/usr/bin/env node
/**
* BlogShoot Webhook Server - Universal Node.js Script
*
* Environment Variables:
* PORT - Server port (default: 3100)
* BLOGSHOOT_WEBHOOK_SECRET - Webhook secret for signature verification
* REBUILD_COMMAND - Command to trigger rebuild (e.g., "npm run build")
* DEPLOY_HOOK_URL - URL to trigger deployment
* GIT_AUTO_PULL - Auto git pull before rebuild (default: false)
*/
import { createServer } from 'http';
import { createHmac } from 'crypto';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
const CONFIG = {
PORT: process.env.PORT || 3100,
WEBHOOK_SECRET: process.env.BLOGSHOOT_WEBHOOK_SECRET,
REBUILD_COMMAND: process.env.REBUILD_COMMAND || '',
DEPLOY_HOOK_URL: process.env.DEPLOY_HOOK_URL || '',
GIT_AUTO_PULL: process.env.GIT_AUTO_PULL === 'true',
LOG_LEVEL: process.env.LOG_LEVEL || 'info',
};
const logger = {
info: (...args) => console.log('[INFO]', new Date().toISOString(), ...args),
debug: (...args) => CONFIG.LOG_LEVEL === 'debug' && console.log('[DEBUG]', new Date().toISOString(), ...args),
error: (...args) => console.error('[ERROR]', new Date().toISOString(), ...args),
};
function generateSignature(payload, secret) {
return createHmac('sha256', secret).update(payload).digest('hex');
}
function verifySignature(receivedSignature, payload, secret) {
const expectedSignature = generateSignature(payload, secret);
return receivedSignature === expectedSignature;
}
async function parseBody(req) {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', chunk => { body += chunk.toString(); });
req.on('end', () => { resolve(body); });
req.on('error', reject);
});
}
async function gitPull() {
logger.info('Executing git pull...');
try {
const { stdout, stderr } = await execAsync('git pull origin main');
logger.info('Git pull output:', stdout);
if (stderr) logger.debug('Git pull stderr:', stderr);
return { success: true, output: stdout };
} catch (error) {
logger.error('Git pull failed:', error.message);
return { success: false, error: error.message };
}
}
async function executeRebuild() {
if (!CONFIG.REBUILD_COMMAND) {
logger.debug('No REBUILD_COMMAND configured, skipping');
return { success: true, skipped: true };
}
logger.info('Executing rebuild command:', CONFIG.REBUILD_COMMAND);
try {
const { stdout, stderr } = await execAsync(CONFIG.REBUILD_COMMAND, { timeout: 600000 });
logger.info('Rebuild completed successfully');
logger.debug('Rebuild output:', stdout);
if (stderr) logger.debug('Rebuild stderr:', stderr);
return { success: true, output: stdout };
} catch (error) {
logger.error('Rebuild failed:', error.message);
return { success: false, error: error.message };
}
}
async function triggerDeployHook(eventId) {
if (!CONFIG.DEPLOY_HOOK_URL) {
logger.debug('No DEPLOY_HOOK_URL configured, skipping');
return { success: true, skipped: true };
}
logger.info('Triggering deploy hook:', CONFIG.DEPLOY_HOOK_URL);
try {
const response = await fetch(CONFIG.DEPLOY_HOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source: 'blogshoot-webhook',
event_id: eventId,
timestamp: new Date().toISOString(),
}),
});
if (!response.ok) {
throw new Error(`Deploy hook failed: ${response.status} ${response.statusText}`);
}
logger.info('Deploy hook triggered successfully');
return { success: true, status: response.status };
} catch (error) {
logger.error('Deploy hook failed:', error.message);
return { success: false, error: error.message };
}
}
async function processWebhook(payload) {
const { event_id, event_type, article } = payload;
logger.info(`Processing webhook: ${event_type} (${event_id})`);
logger.debug('Article:', article?.title || 'N/A');
if (event_type === 'test') {
logger.info('Test event received, skipping processing');
return { success: true, message: 'Test event processed' };
}
if (event_type !== 'article.created' && event_type !== 'article.updated') {
logger.info(`Event type ${event_type} not configured for processing`);
return { success: true, message: 'Event ignored' };
}
const results = { gitPull: null, rebuild: null, deployHook: null };
if (CONFIG.GIT_AUTO_PULL) {
results.gitPull = await gitPull();
if (!results.gitPull.success) {
logger.error('Git pull failed, aborting processing');
return { success: false, message: 'Git pull failed', results };
}
}
if (CONFIG.REBUILD_COMMAND) {
results.rebuild = await executeRebuild();
}
if (CONFIG.DEPLOY_HOOK_URL) {
results.deployHook = await triggerDeployHook(event_id);
}
logger.info('Webhook processing completed', results);
return { success: true, message: 'Webhook processed', results };
}
async function handleRequest(req, res) {
const startTime = Date.now();
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-BlogShoot-Signature, X-BlogShoot-Event');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
if (req.method === 'GET' && req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'healthy',
service: 'BlogShoot Webhook Server',
version: '1.0.0',
uptime: process.uptime(),
config: {
hasWebhookSecret: !!CONFIG.WEBHOOK_SECRET,
hasRebuildCommand: !!CONFIG.REBUILD_COMMAND,
hasDeployHook: !!CONFIG.DEPLOY_HOOK_URL,
gitAutoPull: CONFIG.GIT_AUTO_PULL,
},
}));
return;
}
if (req.method === 'POST' && req.url === '/webhook') {
try {
if (!CONFIG.WEBHOOK_SECRET) {
logger.error('BLOGSHOOT_WEBHOOK_SECRET not configured');
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Webhook not configured' }));
return;
}
const rawBody = await parseBody(req);
const signature = req.headers['x-blogshoot-signature'] || '';
const eventType = req.headers['x-blogshoot-event'] || '';
const deliveryId = req.headers['x-blogshoot-delivery-id'] || '';
logger.info(`Webhook received: ${eventType} (${deliveryId})`);
if (!verifySignature(signature, rawBody, CONFIG.WEBHOOK_SECRET)) {
logger.error('Invalid signature');
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid signature' }));
return;
}
logger.info('Signature verified successfully');
const payload = JSON.parse(rawBody);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
event_id: payload.event_id,
event_type: payload.event_type,
message: 'Webhook received and processing',
processing_time_ms: Date.now() - startTime,
}));
processWebhook(payload).catch(error => {
logger.error('Async webhook processing error:', error);
});
} catch (error) {
logger.error('Request handling error:', error);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Internal server error', message: error.message }));
}
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: 'Not found',
endpoints: { webhook: 'POST /webhook', health: 'GET /health' },
}));
}
function startServer() {
if (!CONFIG.WEBHOOK_SECRET) {
logger.error('❌ BLOGSHOOT_WEBHOOK_SECRET is required!');
logger.error(' Generate one using: openssl rand -hex 32');
process.exit(1);
}
if (!CONFIG.REBUILD_COMMAND && !CONFIG.DEPLOY_HOOK_URL) {
logger.error('⚠️ Warning: Neither REBUILD_COMMAND nor DEPLOY_HOOK_URL is configured');
logger.error(' Webhook will receive requests but won\'t trigger any builds');
}
const server = createServer(handleRequest);
server.listen(CONFIG.PORT, () => {
logger.info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
logger.info('🚀 BlogShoot Webhook Server Started');
logger.info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
logger.info(`📍 Listening on: http://localhost:${CONFIG.PORT}`);
logger.info(`🔐 Webhook Secret: ${CONFIG.WEBHOOK_SECRET.substring(0, 8)}...`);
logger.info(`🔨 Rebuild Command: ${CONFIG.REBUILD_COMMAND || '(not configured)'}`);
logger.info(`🚢 Deploy Hook: ${CONFIG.DEPLOY_HOOK_URL || '(not configured)'}`);
logger.info(`📦 Git Auto Pull: ${CONFIG.GIT_AUTO_PULL ? 'enabled' : 'disabled'}`);
logger.info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
logger.info('Endpoints:');
logger.info(` POST http://localhost:${CONFIG.PORT}/webhook`);
logger.info(` GET http://localhost:${CONFIG.PORT}/health`);
logger.info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
});
process.on('SIGTERM', () => {
logger.info('SIGTERM signal received: closing HTTP server');
server.close(() => { logger.info('HTTP server closed'); process.exit(0); });
});
process.on('SIGINT', () => {
logger.info('SIGINT signal received: closing HTTP server');
server.close(() => { logger.info('HTTP server closed'); process.exit(0); });
});
}
startServer();

Step 2: Configure environment variables

Terminal window
# Required
export BLOGSHOOT_WEBHOOK_SECRET="$(openssl rand -hex 32)"
# Choose one or both:
export REBUILD_COMMAND="npm run build" # Local rebuild
export DEPLOY_HOOK_URL="https://..." # Or trigger external deploy hook
# Optional
export GIT_AUTO_PULL="true" # Auto git pull before rebuild
export PORT="3100"

Step 3: Run the server

Terminal window
node webhook-server.js

That’s it! Server is running on http://localhost:3100/webhook


Step 1: Create webhook server file

Create a file named webhook-server.py and paste the following code:

📄 Click to view complete Python server code
#!/usr/bin/env python3
"""
BlogShoot Webhook Server - Universal Python Script
Environment Variables:
PORT - Server port (default: 3100)
BLOGSHOOT_WEBHOOK_SECRET - Webhook secret for signature verification
REBUILD_COMMAND - Command to trigger rebuild
DEPLOY_HOOK_URL - URL to trigger deployment
GIT_AUTO_PULL - Auto git pull before rebuild (default: false)
"""
import os
import sys
import json
import hmac
import hashlib
import subprocess
import logging
from http.server import HTTPServer, BaseHTTPRequestHandler
from datetime import datetime
from urllib import request as urllib_request
from urllib.error import URLError
CONFIG = {
'PORT': int(os.getenv('PORT', 3100)),
'WEBHOOK_SECRET': os.getenv('BLOGSHOOT_WEBHOOK_SECRET'),
'REBUILD_COMMAND': os.getenv('REBUILD_COMMAND', ''),
'DEPLOY_HOOK_URL': os.getenv('DEPLOY_HOOK_URL', ''),
'GIT_AUTO_PULL': os.getenv('GIT_AUTO_PULL', 'false').lower() == 'true',
'LOG_LEVEL': os.getenv('LOG_LEVEL', 'info').upper(),
}
logging.basicConfig(
level=getattr(logging, CONFIG['LOG_LEVEL'], logging.INFO),
format='[%(levelname)s] %(asctime)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger(__name__)
def generate_signature(payload: str, secret: str) -> str:
return hmac.new(
secret.encode('utf-8'),
payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
def verify_signature(received_signature: str, payload: str, secret: str) -> bool:
expected_signature = generate_signature(payload, secret)
return hmac.compare_digest(received_signature, expected_signature)
def git_pull():
logger.info('Executing git pull...')
try:
result = subprocess.run(
['git', 'pull', 'origin', 'main'],
capture_output=True,
text=True,
timeout=60
)
logger.info(f'Git pull output: {result.stdout}')
if result.stderr:
logger.debug(f'Git pull stderr: {result.stderr}')
return {'success': result.returncode == 0, 'output': result.stdout}
except Exception as e:
logger.error(f'Git pull failed: {str(e)}')
return {'success': False, 'error': str(e)}
def execute_rebuild():
if not CONFIG['REBUILD_COMMAND']:
logger.debug('No REBUILD_COMMAND configured, skipping')
return {'success': True, 'skipped': True}
logger.info(f'Executing rebuild command: {CONFIG["REBUILD_COMMAND"]}')
try:
result = subprocess.run(
CONFIG['REBUILD_COMMAND'],
shell=True,
capture_output=True,
text=True,
timeout=600
)
logger.info('Rebuild completed successfully')
logger.debug(f'Rebuild output: {result.stdout}')
if result.stderr:
logger.debug(f'Rebuild stderr: {result.stderr}')
return {'success': result.returncode == 0, 'output': result.stdout}
except Exception as e:
logger.error(f'Rebuild failed: {str(e)}')
return {'success': False, 'error': str(e)}
def trigger_deploy_hook(event_id: str):
if not CONFIG['DEPLOY_HOOK_URL']:
logger.debug('No DEPLOY_HOOK_URL configured, skipping')
return {'success': True, 'skipped': True}
logger.info(f'Triggering deploy hook: {CONFIG["DEPLOY_HOOK_URL"]}')
try:
data = json.dumps({
'source': 'blogshoot-webhook',
'event_id': event_id,
'timestamp': datetime.utcnow().isoformat() + 'Z',
}).encode('utf-8')
req = urllib_request.Request(
CONFIG['DEPLOY_HOOK_URL'],
data=data,
headers={'Content-Type': 'application/json'},
method='POST'
)
with urllib_request.urlopen(req, timeout=30) as response:
status_code = response.getcode()
logger.info(f'Deploy hook triggered successfully (status: {status_code})')
return {'success': True, 'status': status_code}
except URLError as e:
logger.error(f'Deploy hook failed: {str(e)}')
return {'success': False, 'error': str(e)}
def process_webhook(payload: dict):
event_id = payload.get('event_id')
event_type = payload.get('event_type')
article = payload.get('article', {})
logger.info(f'Processing webhook: {event_type} ({event_id})')
logger.debug(f'Article: {article.get("title", "N/A")}')
if event_type == 'test':
logger.info('Test event received, skipping processing')
return {'success': True, 'message': 'Test event processed'}
if event_type not in ['article.created', 'article.updated']:
logger.info(f'Event type {event_type} not configured for processing')
return {'success': True, 'message': 'Event ignored'}
results = {'gitPull': None, 'rebuild': None, 'deployHook': None}
if CONFIG['GIT_AUTO_PULL']:
results['gitPull'] = git_pull()
if not results['gitPull']['success']:
logger.error('Git pull failed, aborting processing')
return {'success': False, 'message': 'Git pull failed', 'results': results}
if CONFIG['REBUILD_COMMAND']:
results['rebuild'] = execute_rebuild()
if CONFIG['DEPLOY_HOOK_URL']:
results['deployHook'] = trigger_deploy_hook(event_id)
logger.info(f'Webhook processing completed: {results}')
return {'success': True, 'message': 'Webhook processed', 'results': results}
class WebhookHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
logger.debug(f'{self.address_string()} - {format % args}')
def send_json_response(self, status_code: int, data: dict):
self.send_response(status_code)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps(data).encode('utf-8'))
def do_OPTIONS(self):
self.send_response(200)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type, X-BlogShoot-Signature, X-BlogShoot-Event')
self.end_headers()
def do_GET(self):
if self.path == '/health':
self.send_json_response(200, {
'status': 'healthy',
'service': 'BlogShoot Webhook Server',
'version': '1.0.0',
'config': {
'hasWebhookSecret': bool(CONFIG['WEBHOOK_SECRET']),
'hasRebuildCommand': bool(CONFIG['REBUILD_COMMAND']),
'hasDeployHook': bool(CONFIG['DEPLOY_HOOK_URL']),
'gitAutoPull': CONFIG['GIT_AUTO_PULL'],
},
})
else:
self.send_json_response(404, {
'error': 'Not found',
'endpoints': {'webhook': 'POST /webhook', 'health': 'GET /health'},
})
def do_POST(self):
if self.path != '/webhook':
self.send_json_response(404, {'error': 'Not found'})
return
try:
if not CONFIG['WEBHOOK_SECRET']:
logger.error('BLOGSHOOT_WEBHOOK_SECRET not configured')
self.send_json_response(500, {'error': 'Webhook not configured'})
return
content_length = int(self.headers.get('Content-Length', 0))
raw_body = self.rfile.read(content_length).decode('utf-8')
signature = self.headers.get('X-BlogShoot-Signature', '')
event_type = self.headers.get('X-BlogShoot-Event', '')
delivery_id = self.headers.get('X-BlogShoot-Delivery-ID', '')
logger.info(f'Webhook received: {event_type} ({delivery_id})')
if not verify_signature(signature, raw_body, CONFIG['WEBHOOK_SECRET']):
logger.error('Invalid signature')
self.send_json_response(401, {'error': 'Invalid signature'})
return
logger.info('Signature verified successfully')
payload = json.loads(raw_body)
self.send_json_response(200, {
'success': True,
'event_id': payload.get('event_id'),
'event_type': payload.get('event_type'),
'message': 'Webhook received and processing',
})
import threading
thread = threading.Thread(target=process_webhook, args=(payload,))
thread.daemon = True
thread.start()
except Exception as e:
logger.error(f'Request handling error: {str(e)}')
self.send_json_response(500, {
'error': 'Internal server error',
'message': str(e),
})
def start_server():
if not CONFIG['WEBHOOK_SECRET']:
logger.error('❌ BLOGSHOOT_WEBHOOK_SECRET is required!')
logger.error(' Generate one using: openssl rand -hex 32')
sys.exit(1)
if not CONFIG['REBUILD_COMMAND'] and not CONFIG['DEPLOY_HOOK_URL']:
logger.warning('⚠️ Warning: Neither REBUILD_COMMAND nor DEPLOY_HOOK_URL is configured')
logger.warning(' Webhook will receive requests but won\'t trigger any builds')
server = HTTPServer(('0.0.0.0', CONFIG['PORT']), WebhookHandler)
logger.info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
logger.info('🚀 BlogShoot Webhook Server Started')
logger.info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
logger.info(f'📍 Listening on: http://0.0.0.0:{CONFIG["PORT"]}')
logger.info(f'🔐 Webhook Secret: {CONFIG["WEBHOOK_SECRET"][:8]}...')
logger.info(f'🔨 Rebuild Command: {CONFIG["REBUILD_COMMAND"] or "(not configured)"}')
logger.info(f'🚢 Deploy Hook: {CONFIG["DEPLOY_HOOK_URL"] or "(not configured)"}')
logger.info(f'📦 Git Auto Pull: {"enabled" if CONFIG["GIT_AUTO_PULL"] else "disabled"}')
logger.info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
logger.info('Endpoints:')
logger.info(f' POST http://localhost:{CONFIG["PORT"]}/webhook')
logger.info(f' GET http://localhost:{CONFIG["PORT"]}/health')
logger.info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
try:
server.serve_forever()
except KeyboardInterrupt:
logger.info('Shutting down server...')
server.shutdown()
logger.info('Server stopped')
if __name__ == '__main__':
start_server()

Step 2: Configure environment variables

Terminal window
# Required
export BLOGSHOOT_WEBHOOK_SECRET="$(openssl rand -hex 32)"
# Choose one or both:
export REBUILD_COMMAND="npm run build" # Local rebuild
export DEPLOY_HOOK_URL="https://..." # Or trigger external deploy hook
# Optional
export GIT_AUTO_PULL="true"
export PORT="3100"

Step 3: Run the server

Terminal window
python webhook-server.py
Option A: PM2 (VPS/ECS)
Terminal window
# Install PM2
npm install -g pm2
# Start webhook server
pm2 start server.js --name blogshoot-webhook
# Configure environment
pm2 set BLOGSHOOT_WEBHOOK_SECRET your-secret-here
pm2 set REBUILD_COMMAND "npm run build"
pm2 set GIT_AUTO_PULL true
# Save and setup auto-restart
pm2 save
pm2 startup
Option B: Docker

Create Dockerfile:

FROM node:18-alpine
WORKDIR /app
COPY webhook-server/server.js .
EXPOSE 3100
CMD ["node", "server.js"]

Run:

Terminal window
docker build -t blogshoot-webhook .
docker run -d \
-p 3100:3100 \
-e BLOGSHOOT_WEBHOOK_SECRET=your-secret \
-e REBUILD_COMMAND="npm run build" \
blogshoot-webhook
Option C: Systemd Service (Linux)

Create /etc/systemd/system/blogshoot-webhook.service:

[Unit]
Description=BlogShoot Webhook Server
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/var/www/your-astro-site
Environment="BLOGSHOOT_WEBHOOK_SECRET=your-secret"
Environment="REBUILD_COMMAND=npm run build"
Environment="GIT_AUTO_PULL=true"
ExecStart=/usr/bin/node /path/to/server.js
Restart=always
[Install]
WantedBy=multi-user.target

Enable:

Terminal window
sudo systemctl enable blogshoot-webhook
sudo systemctl start blogshoot-webhook
Option D: Nginx Reverse Proxy (HTTPS)
server {
listen 443 ssl;
server_name webhook.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/webhook.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/webhook.yourdomain.com/privkey.pem;
location /webhook {
proxy_pass http://localhost:3100/webhook;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Environment VariableDescriptionExample
BLOGSHOOT_WEBHOOK_SECRETWebhook signature secret (required)abc123... (64 chars)
PORTServer port3100
REBUILD_COMMANDCommand to rebuild sitenpm run build
DEPLOY_HOOK_URLExternal deploy hookhttps://api.vercel.com/...
GIT_AUTO_PULLAuto git pulltrue or false
LOG_LEVELLogging levelinfo, debug, error

Serverless solution for Cloudflare Pages users.

BlogShoot generates article
POST https://yoursite.com/api/webhook
Cloudflare Pages Function verifies signature
Trigger Cloudflare Deploy Hook
Astro rebuilds and deploys

Step 1: The function file already exists!

File: blogshoot-web/functions/api/webhook.ts (already created for you)

Step 2: Configure Cloudflare environment variables

In Cloudflare Pages DashboardYour ProjectSettingsEnvironment variables:

Terminal window
BLOGSHOOT_WEBHOOK_SECRET = <openssl rand -hex 32>
CLOUDFLARE_DEPLOY_HOOK = <Get from Cloudflare Deploy Hooks>

Step 3: Create Deploy Hook

  1. Cloudflare Pages Dashboard → Your Project
  2. SettingsBuilds & deployments
  3. Scroll to Deploy hooks
  4. Click Create deploy hook
    • Name: BlogShoot Webhook
    • Branch: main
  5. Copy the URL and set as CLOUDFLARE_DEPLOY_HOOK

Step 4: Deploy

Terminal window
git add functions/api/webhook.ts
git commit -m "Add webhook integration"
git push

Cloudflare automatically deploys the function!

In Cloudflare Pages DashboardFunctions tab:

  • View real-time logs
  • Monitor webhook requests
  • Debug signature issues

Once your webhook receiver is running (either method), configure BlogShoot:

  1. Log in to app.blogshoot.com
  2. Navigate to IntegrationsWebhook
  3. Click Add Webhook Connection

For Method 1 (Standalone Server):

Connection Name: My Astro Site Production
Webhook URL: https://your-domain.com/webhook
(or http://your-ip:3100/webhook for VPS without reverse proxy)
Webhook Secret: <same as BLOGSHOOT_WEBHOOK_SECRET>

For Method 2 (Cloudflare Function):

Connection Name: My Astro Site (Cloudflare)
Webhook URL: https://yoursite.com/api/webhook
Webhook Secret: <same as BLOGSHOOT_WEBHOOK_SECRET>

Select which events trigger webhooks:

  • article.created - New article published
  • article.updated - Article modified
  • article.deleted - Article removed (optional)
  1. Click Test Connection
  2. BlogShoot sends a test webhook
  3. ✅ Success: “Connection test successful”
  4. ❌ Failed: Check troubleshooting section

Click Save Connection to activate the webhook.


FeatureMethod 1: StandaloneMethod 2: Cloudflare
Platform Support✅ Any (VPS, ECS, Docker, GitLab, etc.)⚠️ Cloudflare Pages only
Setup ComplexityMedium (manual deployment)Easy (automatic deployment)
DependenciesZero (uses built-in modules)Zero (serverless)
CostServer/container costFree tier sufficient
ControlFull controlPlatform-managed
ScalabilityManual scalingAuto-scale (unlimited)
HTTPSRequires reverse proxy (nginx)Built-in
LogsServer logs / PM2 / systemdCloudflare Dashboard
Best ForCustom setups, self-hosted, GitLabCloudflare Pages deployments
  • ✅ You have your own VPS or ECS
  • ✅ You use GitLab, CodeUp, or other non-Cloudflare platforms
  • ✅ You want full control over the deployment
  • ✅ You already have infrastructure (Docker, Kubernetes)
  • ✅ You deploy to multiple environments
  • ✅ Your site is already on Cloudflare Pages
  • ✅ You want zero-configuration deployment
  • ✅ You prefer serverless architecture
  • ✅ You want automatic global edge distribution

If you need to see the complete Cloudflare Function code:

/**
* BlogShoot Webhook Receiver for Cloudflare Pages
* Endpoint: https://yoursite.com/api/webhook
*/
interface Env {
BLOGSHOOT_WEBHOOK_SECRET: string;
CLOUDFLARE_DEPLOY_HOOK?: string;
}
interface WebhookPayload {
event_id: string;
event_type: 'article.created' | 'article.updated' | 'article.deleted' | 'test';
timestamp: string;
article?: {
id: string;
title: string;
slug: string;
content: {
html: string;
markdown: string;
plain_text: string;
};
excerpt: string;
featured_image: {
url: string;
alt: string;
} | null;
tags: string[];
categories: string[];
published_at: string;
};
}
async function generateSignature(payload: string, secret: string): Promise<string> {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign(
'HMAC',
key,
encoder.encode(payload)
);
return Array.from(new Uint8Array(signature))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
async function triggerRebuild(deployHookUrl: string, eventId: string) {
const response = await fetch(deployHookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source: 'blogshoot-webhook',
event_id: eventId,
timestamp: new Date().toISOString(),
}),
});
if (!response.ok) {
throw new Error(`Deploy hook failed: ${response.status}`);
}
return { success: true };
}
export const onRequestPost: PagesFunction<Env> = async (context) => {
const { request, env } = context;
try {
// 1. Read raw body for signature verification
const rawBody = await request.text();
const signature = request.headers.get('x-blogshoot-signature') || '';
// 2. Verify webhook secret exists
if (!env.BLOGSHOOT_WEBHOOK_SECRET) {
return new Response(
JSON.stringify({ error: 'Webhook not configured' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
// 3. Verify HMAC-SHA256 signature
const expectedSignature = await generateSignature(rawBody, env.BLOGSHOOT_WEBHOOK_SECRET);
if (signature !== expectedSignature) {
console.error('Invalid signature');
return new Response(
JSON.stringify({ error: 'Invalid signature' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
// 4. Parse webhook payload
const payload: WebhookPayload = JSON.parse(rawBody);
const { event_id, event_type, article } = payload;
// 5. Handle test event
if (event_type === 'test') {
return new Response(
JSON.stringify({
success: true,
message: 'Test webhook received successfully',
event_id
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
// 6. Trigger rebuild for article events
if (event_type === 'article.created' || event_type === 'article.updated') {
if (env.CLOUDFLARE_DEPLOY_HOOK) {
context.waitUntil(
triggerRebuild(env.CLOUDFLARE_DEPLOY_HOOK, event_id)
);
}
}
// 7. Return success response
return new Response(
JSON.stringify({
success: true,
event_id,
event_type,
message: 'Webhook received and processing',
article_id: article?.id,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Webhook error:', error);
return new Response(
JSON.stringify({ error: 'Internal server error', message: error.message }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};
// Optional: GET endpoint for health check
export const onRequestGet: PagesFunction<Env> = async () => {
return new Response(
JSON.stringify({
service: 'BlogShoot Webhook Receiver',
status: 'online',
endpoint: '/api/webhook',
methods: ['POST'],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
};

In Cloudflare Pages Dashboard:

  1. Go to your project
  2. SettingsEnvironment variables
  3. Add the following variables:
VariableValueRequired
BLOGSHOOT_WEBHOOK_SECRETGenerate using openssl rand -hex 32✅ Required
CLOUDFLARE_DEPLOY_HOOKYour deploy hook URL (see step 3)⚠️ Recommended
  1. Go to Cloudflare Pages Dashboard
  2. Select your Astro project
  3. SettingsBuilds & deployments
  4. Scroll to Deploy hooks section
  5. Click Create deploy hook
  6. Configure:
    • Hook name: BlogShoot Webhook
    • Branch to build: main (or your production branch)
  7. Click Save
  8. Copy the generated URL - you’ll need this for the environment variable

The URL looks like:

https://api.cloudflare.com/client/v4/pages/webhooks/deploy_hooks/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

Add this as the CLOUDFLARE_DEPLOY_HOOK environment variable.

Commit and push your changes:

Terminal window
git add functions/api/webhook.ts
git commit -m "Add BlogShoot webhook integration"
git push origin main

Cloudflare Pages will automatically deploy your changes, including the new function.

  1. Log in to app.blogshoot.com
  2. Go to IntegrationsWebhook
  3. Click Add Webhook Connection
  4. Fill in the form:
Connection Name: My Astro Site Production
Webhook URL: https://yoursite.com/api/webhook
Webhook Secret: <paste your BLOGSHOOT_WEBHOOK_SECRET>
  1. Configure event types:

    • article.created
    • article.updated
    • article.deleted (optional)
  2. Set timeout: 10000 ms (10 seconds)

  3. Click Test Connection

    • ✅ Success: You’ll see “Connection test successful”
    • ❌ Failed: Check the troubleshooting section below
  4. Click Save Connection

You can test the endpoint manually using curl:

Terminal window
# Generate test signature
SECRET="your-webhook-secret"
PAYLOAD='{"event_type":"test","event_id":"test-123","timestamp":"2025-12-07T10:00:00Z"}'
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
# Send test request
curl -X POST https://yoursite.com/api/webhook \
-H "Content-Type: application/json" \
-H "X-BlogShoot-Signature: $SIGNATURE" \
-H "X-BlogShoot-Event: test" \
-d "$PAYLOAD"

Expected response:

{
"success": true,
"message": "Test webhook received successfully",
"event_id": "test-123"
}
Terminal window
curl https://yoursite.com/api/webhook

Expected response:

{
"service": "BlogShoot Webhook Receiver",
"status": "online",
"endpoint": "/api/webhook",
"methods": ["POST"]
}
  1. Create a test article in BlogShoot
  2. Click PublishPush to Webhook
  3. Select your webhook connection
  4. Click Push
  5. Wait 2-5 minutes for the rebuild
  6. Visit your site to see the new article

In Cloudflare Pages Dashboard:

  1. Go to your project
  2. Click Functions tab
  3. View recent invocations and logs
  1. Go to Deployments tab
  2. Click on the latest deployment
  3. View build logs to see if the rebuild succeeded
Terminal window
# Install Wrangler
npm install -g wrangler
# Login to Cloudflare
wrangler login
# Tail function logs in real-time
wrangler pages deployment tail
Log MessageMeaning
WEBHOOK_RECEIVEDWebhook request received
SIGNATURE_VERIFIEDSignature validation passed
SIGNATURE_FAILEDInvalid signature (check secret)
REBUILD_TRIGGEREDDeploy hook called successfully
PROCESSING_COMPLETEWebhook processing finished

Cause: Webhook secret mismatch

Solution:

  1. Verify BLOGSHOOT_WEBHOOK_SECRET in Cloudflare matches the secret in BlogShoot
  2. Ensure no extra spaces or newlines in the secret
  3. Regenerate the secret if needed:
    Terminal window
    openssl rand -hex 32
  4. Update both Cloudflare and BlogShoot with the new secret

Cause: Environment variable not set

Solution:

  1. Check BLOGSHOOT_WEBHOOK_SECRET is set in Cloudflare Pages
  2. Ensure you’re setting it for the correct environment (Production/Preview)
  3. Redeploy after adding the variable

Cause: Deploy hook not configured or invalid

Solution:

  1. Verify CLOUDFLARE_DEPLOY_HOOK environment variable is set
  2. Test the deploy hook manually:
    Terminal window
    curl -X POST https://api.cloudflare.com/client/v4/pages/webhooks/deploy_hooks/your-hook-id
  3. Check deploy hook is for the correct branch

Cause: Function not deployed or route mismatch

Solution:

  1. Check functions/api/webhook.ts exists in your repository
  2. Verify the file is committed and pushed to your main branch
  3. Check recent deployment included the function
  4. Function URL should be exactly /api/webhook

If you want to automatically commit article markdown files to your repository:

  1. Add GitHub token to environment variables:

    GITHUB_TOKEN=ghp_your_personal_access_token
    GITHUB_REPO=your-username/your-repo
  2. Uncomment the GitHub integration code in webhook.ts (around line 144)

  3. Articles will be auto-committed to src/content/blog/[slug].md

To deploy to different branches based on environment:

// In webhook.ts
const deployHookUrl = env.NODE_ENV === 'production'
? env.CLOUDFLARE_DEPLOY_HOOK_PROD
: env.CLOUDFLARE_DEPLOY_HOOK_STAGING;

Add basic rate limiting to prevent abuse:

// Simple in-memory rate limiter (resets on function cold start)
const requestCounts = new Map<string, number>();
export const onRequestPost: PagesFunction<Env> = async (context) => {
const clientIP = context.request.headers.get('cf-connecting-ip');
const count = (requestCounts.get(clientIP) || 0) + 1;
if (count > 10) {
return new Response('Rate limited', { status: 429 });
}
requestCounts.set(clientIP, count);
// ... rest of webhook handler
};
  • Always verify signatures - Never skip HMAC validation
  • Use HTTPS only - Cloudflare Pages enforces this by default
  • Rotate secrets periodically - Every 6-12 months
  • Use environment variables - Never hardcode secrets
  • Monitor webhook logs - Watch for suspicious activity
  • Respond quickly - Return 200 OK immediately, process async
  • Use context.waitUntil() - For non-blocking operations
  • Set appropriate timeouts - 10 seconds is usually enough
  • Handle errors gracefully - Return proper HTTP status codes
  • Implement idempotency - Use event_id to prevent duplicates
  • Log all events - For debugging and audit trails
  • Test regularly - Use BlogShoot’s test connection feature
  • Monitor deployments - Set up alerts for failed builds