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.
Choose Your Approach
Section titled “Choose Your Approach”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
How SSG + Webhook Works
Section titled “How SSG + Webhook Works”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 publishedKey Principle
Section titled “Key Principle”Your Astro site stays 100% static SSG. The webhook receiver is a small separate service that:
- Receives webhook from BlogShoot
- Verifies HMAC-SHA256 signature
- Triggers rebuild (via command or deploy hook)
Method 1: Standalone Webhook Server
Section titled “Method 1: Standalone Webhook Server”Universal solution that works anywhere. No platform dependencies.
Features
Section titled “Features”- ✅ 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
Quick Start (Node.js)
Section titled “Quick Start (Node.js)”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
# Requiredexport BLOGSHOOT_WEBHOOK_SECRET="$(openssl rand -hex 32)"
# Choose one or both:export REBUILD_COMMAND="npm run build" # Local rebuildexport DEPLOY_HOOK_URL="https://..." # Or trigger external deploy hook
# Optionalexport GIT_AUTO_PULL="true" # Auto git pull before rebuildexport PORT="3100"Step 3: Run the server
node webhook-server.jsThat’s it! Server is running on http://localhost:3100/webhook
Quick Start (Python)
Section titled “Quick Start (Python)”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 osimport sysimport jsonimport hmacimport hashlibimport subprocessimport loggingfrom http.server import HTTPServer, BaseHTTPRequestHandlerfrom datetime import datetimefrom urllib import request as urllib_requestfrom 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
# Requiredexport BLOGSHOOT_WEBHOOK_SECRET="$(openssl rand -hex 32)"
# Choose one or both:export REBUILD_COMMAND="npm run build" # Local rebuildexport DEPLOY_HOOK_URL="https://..." # Or trigger external deploy hook
# Optionalexport GIT_AUTO_PULL="true"export PORT="3100"Step 3: Run the server
python webhook-server.pyDeployment Options
Section titled “Deployment Options”Option A: PM2 (VPS/ECS)
# Install PM2npm install -g pm2
# Start webhook serverpm2 start server.js --name blogshoot-webhook
# Configure environmentpm2 set BLOGSHOOT_WEBHOOK_SECRET your-secret-herepm2 set REBUILD_COMMAND "npm run build"pm2 set GIT_AUTO_PULL true
# Save and setup auto-restartpm2 savepm2 startupOption B: Docker
Create Dockerfile:
FROM node:18-alpineWORKDIR /appCOPY webhook-server/server.js .EXPOSE 3100CMD ["node", "server.js"]Run:
docker build -t blogshoot-webhook .docker run -d \ -p 3100:3100 \ -e BLOGSHOOT_WEBHOOK_SECRET=your-secret \ -e REBUILD_COMMAND="npm run build" \ blogshoot-webhookOption C: Systemd Service (Linux)
Create /etc/systemd/system/blogshoot-webhook.service:
[Unit]Description=BlogShoot Webhook ServerAfter=network.target
[Service]Type=simpleUser=www-dataWorkingDirectory=/var/www/your-astro-siteEnvironment="BLOGSHOOT_WEBHOOK_SECRET=your-secret"Environment="REBUILD_COMMAND=npm run build"Environment="GIT_AUTO_PULL=true"ExecStart=/usr/bin/node /path/to/server.jsRestart=always
[Install]WantedBy=multi-user.targetEnable:
sudo systemctl enable blogshoot-webhooksudo systemctl start blogshoot-webhookOption 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; }}Configuration Reference
Section titled “Configuration Reference”| Environment Variable | Description | Example |
|---|---|---|
BLOGSHOOT_WEBHOOK_SECRET | Webhook signature secret (required) | abc123... (64 chars) |
PORT | Server port | 3100 |
REBUILD_COMMAND | Command to rebuild site | npm run build |
DEPLOY_HOOK_URL | External deploy hook | https://api.vercel.com/... |
GIT_AUTO_PULL | Auto git pull | true or false |
LOG_LEVEL | Logging level | info, debug, error |
Method 2: Cloudflare Pages Function
Section titled “Method 2: Cloudflare Pages Function”Serverless solution for Cloudflare Pages users.
Architecture
Section titled “Architecture”BlogShoot generates article ↓POST https://yoursite.com/api/webhook ↓Cloudflare Pages Function verifies signature ↓Trigger Cloudflare Deploy Hook ↓Astro rebuilds and deploysQuick Start
Section titled “Quick Start”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 Dashboard → Your Project → Settings → Environment variables:
BLOGSHOOT_WEBHOOK_SECRET = <openssl rand -hex 32>CLOUDFLARE_DEPLOY_HOOK = <Get from Cloudflare Deploy Hooks>Step 3: Create Deploy Hook
- Cloudflare Pages Dashboard → Your Project
- Settings → Builds & deployments
- Scroll to Deploy hooks
- Click Create deploy hook
- Name:
BlogShoot Webhook - Branch:
main
- Name:
- Copy the URL and set as
CLOUDFLARE_DEPLOY_HOOK
Step 4: Deploy
git add functions/api/webhook.tsgit commit -m "Add webhook integration"git pushCloudflare automatically deploys the function!
View Function Logs
Section titled “View Function Logs”In Cloudflare Pages Dashboard → Functions tab:
- View real-time logs
- Monitor webhook requests
- Debug signature issues
Configure in BlogShoot
Section titled “Configure in BlogShoot”Once your webhook receiver is running (either method), configure BlogShoot:
Step 1: Go to Integrations
Section titled “Step 1: Go to Integrations”- Log in to app.blogshoot.com
- Navigate to Integrations → Webhook
- Click Add Webhook Connection
Step 2: Fill in Connection Details
Section titled “Step 2: Fill in Connection Details”For Method 1 (Standalone Server):
Connection Name: My Astro Site ProductionWebhook 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/webhookWebhook Secret: <same as BLOGSHOOT_WEBHOOK_SECRET>Step 3: Configure Event Types
Section titled “Step 3: Configure Event Types”Select which events trigger webhooks:
- ✅
article.created- New article published - ✅
article.updated- Article modified - ⬜
article.deleted- Article removed (optional)
Step 4: Test Connection
Section titled “Step 4: Test Connection”- Click Test Connection
- BlogShoot sends a test webhook
- ✅ Success: “Connection test successful”
- ❌ Failed: Check troubleshooting section
Step 5: Save
Section titled “Step 5: Save”Click Save Connection to activate the webhook.
Comparison: Method 1 vs Method 2
Section titled “Comparison: Method 1 vs Method 2”| Feature | Method 1: Standalone | Method 2: Cloudflare |
|---|---|---|
| Platform Support | ✅ Any (VPS, ECS, Docker, GitLab, etc.) | ⚠️ Cloudflare Pages only |
| Setup Complexity | Medium (manual deployment) | Easy (automatic deployment) |
| Dependencies | Zero (uses built-in modules) | Zero (serverless) |
| Cost | Server/container cost | Free tier sufficient |
| Control | Full control | Platform-managed |
| Scalability | Manual scaling | Auto-scale (unlimited) |
| HTTPS | Requires reverse proxy (nginx) | Built-in |
| Logs | Server logs / PM2 / systemd | Cloudflare Dashboard |
| Best For | Custom setups, self-hosted, GitLab | Cloudflare Pages deployments |
When to Use Method 1
Section titled “When to Use Method 1”- ✅ 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
When to Use Method 2
Section titled “When to Use Method 2”- ✅ Your site is already on Cloudflare Pages
- ✅ You want zero-configuration deployment
- ✅ You prefer serverless architecture
- ✅ You want automatic global edge distribution
Full Code Reference (Cloudflare Function)
Section titled “Full Code Reference (Cloudflare Function)”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 checkexport 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' } } );};2. Configure Environment Variables
Section titled “2. Configure Environment Variables”In Cloudflare Pages Dashboard:
- Go to your project
- Settings → Environment variables
- Add the following variables:
Production Environment
Section titled “Production Environment”| Variable | Value | Required |
|---|---|---|
BLOGSHOOT_WEBHOOK_SECRET | Generate using openssl rand -hex 32 | ✅ Required |
CLOUDFLARE_DEPLOY_HOOK | Your deploy hook URL (see step 3) | ⚠️ Recommended |
3. Create Cloudflare Deploy Hook
Section titled “3. Create Cloudflare Deploy Hook”- Go to Cloudflare Pages Dashboard
- Select your Astro project
- Settings → Builds & deployments
- Scroll to Deploy hooks section
- Click Create deploy hook
- Configure:
- Hook name:
BlogShoot Webhook - Branch to build:
main(or your production branch)
- Hook name:
- Click Save
- 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-xxxxxxxxxxxxAdd this as the CLOUDFLARE_DEPLOY_HOOK environment variable.
4. Deploy the Function
Section titled “4. Deploy the Function”Commit and push your changes:
git add functions/api/webhook.tsgit commit -m "Add BlogShoot webhook integration"git push origin mainCloudflare Pages will automatically deploy your changes, including the new function.
5. Configure in BlogShoot
Section titled “5. Configure in BlogShoot”- Log in to app.blogshoot.com
- Go to Integrations → Webhook
- Click Add Webhook Connection
- Fill in the form:
Connection Name: My Astro Site ProductionWebhook URL: https://yoursite.com/api/webhookWebhook Secret: <paste your BLOGSHOOT_WEBHOOK_SECRET>-
Configure event types:
- ✅
article.created - ✅
article.updated - ⬜
article.deleted(optional)
- ✅
-
Set timeout:
10000ms (10 seconds) -
Click Test Connection
- ✅ Success: You’ll see “Connection test successful”
- ❌ Failed: Check the troubleshooting section below
-
Click Save Connection
Testing the Integration
Section titled “Testing the Integration”Test the Webhook Endpoint
Section titled “Test the Webhook Endpoint”You can test the endpoint manually using curl:
# Generate test signatureSECRET="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 requestcurl -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"}Test GET Endpoint (Health Check)
Section titled “Test GET Endpoint (Health Check)”curl https://yoursite.com/api/webhookExpected response:
{ "service": "BlogShoot Webhook Receiver", "status": "online", "endpoint": "/api/webhook", "methods": ["POST"]}Test End-to-End
Section titled “Test End-to-End”- Create a test article in BlogShoot
- Click Publish → Push to Webhook
- Select your webhook connection
- Click Push
- Wait 2-5 minutes for the rebuild
- Visit your site to see the new article
Monitoring & Debugging
Section titled “Monitoring & Debugging”View Function Logs
Section titled “View Function Logs”In Cloudflare Pages Dashboard:
- Go to your project
- Click Functions tab
- View recent invocations and logs
View Build Logs
Section titled “View Build Logs”- Go to Deployments tab
- Click on the latest deployment
- View build logs to see if the rebuild succeeded
Using Wrangler CLI
Section titled “Using Wrangler CLI”# Install Wranglernpm install -g wrangler
# Login to Cloudflarewrangler login
# Tail function logs in real-timewrangler pages deployment tailCommon Log Messages
Section titled “Common Log Messages”| Log Message | Meaning |
|---|---|
WEBHOOK_RECEIVED | Webhook request received |
SIGNATURE_VERIFIED | Signature validation passed |
SIGNATURE_FAILED | Invalid signature (check secret) |
REBUILD_TRIGGERED | Deploy hook called successfully |
PROCESSING_COMPLETE | Webhook processing finished |
Troubleshooting
Section titled “Troubleshooting””Invalid signature” error
Section titled “”Invalid signature” error”Cause: Webhook secret mismatch
Solution:
- Verify
BLOGSHOOT_WEBHOOK_SECRETin Cloudflare matches the secret in BlogShoot - Ensure no extra spaces or newlines in the secret
- Regenerate the secret if needed:
Terminal window openssl rand -hex 32 - Update both Cloudflare and BlogShoot with the new secret
”Webhook not configured” error
Section titled “”Webhook not configured” error”Cause: Environment variable not set
Solution:
- Check
BLOGSHOOT_WEBHOOK_SECRETis set in Cloudflare Pages - Ensure you’re setting it for the correct environment (Production/Preview)
- Redeploy after adding the variable
Webhook received but site not rebuilding
Section titled “Webhook received but site not rebuilding”Cause: Deploy hook not configured or invalid
Solution:
- Verify
CLOUDFLARE_DEPLOY_HOOKenvironment variable is set - Test the deploy hook manually:
Terminal window curl -X POST https://api.cloudflare.com/client/v4/pages/webhooks/deploy_hooks/your-hook-id - Check deploy hook is for the correct branch
Function not responding
Section titled “Function not responding”Cause: Function not deployed or route mismatch
Solution:
- Check
functions/api/webhook.tsexists in your repository - Verify the file is committed and pushed to your main branch
- Check recent deployment included the function
- Function URL should be exactly
/api/webhook
Advanced Configuration
Section titled “Advanced Configuration”Auto-commit Articles to Git (Optional)
Section titled “Auto-commit Articles to Git (Optional)”If you want to automatically commit article markdown files to your repository:
-
Add GitHub token to environment variables:
GITHUB_TOKEN=ghp_your_personal_access_tokenGITHUB_REPO=your-username/your-repo -
Uncomment the GitHub integration code in
webhook.ts(around line 144) -
Articles will be auto-committed to
src/content/blog/[slug].md
Custom Deploy Branches
Section titled “Custom Deploy Branches”To deploy to different branches based on environment:
// In webhook.tsconst deployHookUrl = env.NODE_ENV === 'production' ? env.CLOUDFLARE_DEPLOY_HOOK_PROD : env.CLOUDFLARE_DEPLOY_HOOK_STAGING;Rate Limiting
Section titled “Rate Limiting”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};Best Practices
Section titled “Best Practices”Security
Section titled “Security”- ✅ 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
Performance
Section titled “Performance”- ✅ 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
Reliability
Section titled “Reliability”- ✅ Implement idempotency - Use
event_idto 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
Next Steps
Section titled “Next Steps”Need Help?
Section titled “Need Help?”- Check Troubleshooting Guide
- View API Reference
- Contact support: [email protected]