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)
Multilingual (i18n) Astro Sites
Section titled “Multilingual (i18n) Astro Sites”If your Astro site has i18n configured with multiple locales (/en/, /zh/, /ar/, etc.), every webhook payload carries the article’s language so you can route it into the correct locale’s content collection.
Reading the language tag
Section titled “Reading the language tag”Every payload (since metadata.version: "1.1") includes:
{ "workspace": { "default_language": "English", "default_market": "United States" }, "article": { "language": "zh-CN", "language_name": "Chinese (Simplified)", "target_market": "China", "slug": "foreign-trade-cms-guide-2026" }}article.language— BCP 47 code (en,zh-CN,ar,ru, …) — what Astroi18nconfig expectsarticle.slug— always ASCII/pinyin, safe to use as-is in URLs across all locales
Writing to a per-locale content collection
Section titled “Writing to a per-locale content collection”Typical Astro i18n layout:
src/├── content/│ ├── blog-en/│ │ └── foreign-trade-cms-guide-2026.md│ ├── blog-zh/│ │ └── foreign-trade-cms-guide-2026.md│ └── blog-ar/│ └── foreign-trade-cms-guide-2026.md└── pages/ └── [locale]/ └── blog/ └── [slug].astroIn your webhook handler (running on the same machine as the Astro source, so it can write files):
import { writeFile } from 'node:fs/promises';import { mkdir } from 'node:fs/promises';import path from 'node:path';
// BCP 47 → your Astro locale slugconst LOCALE_MAP = { 'en': 'en', 'zh-CN': 'zh', 'zh-TW': 'zh-tw', 'ar': 'ar', 'ru': 'ru', 'ja': 'ja', // ...add the ones your Astro i18n config supports};
async function writeArticleToLocaleCollection(article, workspace) { const locale = LOCALE_MAP[article.language] || 'en'; const collectionDir = path.join(process.cwd(), 'src', 'content', `blog-${locale}`); await mkdir(collectionDir, { recursive: true });
// Astro Markdown frontmatter const frontmatter = [ '---', `title: ${JSON.stringify(article.title)}`, `description: ${JSON.stringify(article.seo.description)}`, `slug: ${article.slug}`, `lang: "${article.language}"`, // → renders <html lang="zh-CN"> `dir: "${article.language === 'ar' ? 'rtl' : 'ltr'}"`, `target_market: ${JSON.stringify(article.target_market)}`, `featured_image: ${JSON.stringify(article.featured_image?.url || '')}`, `keywords: ${JSON.stringify(article.seo.keywords)}`, `published_at: ${JSON.stringify(article.published_at)}`, '---', '', article.content.markdown, ].join('\n');
const filePath = path.join(collectionDir, `${article.slug}.md`); await writeFile(filePath, frontmatter, 'utf8'); return { locale, filePath };}Updating Method 1’s webhook server for i18n
Section titled “Updating Method 1’s webhook server for i18n”Take the Node.js Method 1 server below and replace the rebuild block with locale-aware writing first:
// Inside POST /webhook handler, after signature verification:const { article, workspace } = body;
try { const { locale, filePath } = await writeArticleToLocaleCollection(article, workspace); console.log(`✅ Wrote article to ${locale} collection: ${filePath}`);} catch (err) { console.error('Failed to write article file:', err); return res.status(500).json({ error: 'write_failed' });}
// Then trigger rebuild as before (the new file will be picked up)await triggerRebuild();res.status(200).json({ received: true, language: article.language });Astro astro.config.mjs reminder
Section titled “Astro astro.config.mjs reminder”Make sure your i18n config includes all the locales you might receive:
import { defineConfig } from 'astro/config';
export default defineConfig({ i18n: { defaultLocale: 'en', locales: ['en', 'zh', 'ar', 'ru'], routing: { prefixDefaultLocale: false, // /blog/... for en, /zh/blog/... for zh }, },});If BlogShoot delivers a language your Astro config doesn’t list, the writer above falls back to 'en'. You can also console.warn so you notice when to extend the config.
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]