Browse Source

feat: implement webhook system for automated event notifications #3

- Added webhooks table to database schema for storing webhook configurations per shop
- Added webhook_deliveries table for tracking delivery attempts and status
- Created WebhookManager service to handle webhook delivery with retry logic
- Implemented webhook API endpoints:
  - POST /api/shops/:id/webhooks - Create/replace webhook
  - GET /api/shops/:id/webhooks - Get webhook status and recent deliveries
  - PATCH /api/shops/:id/webhooks - Enable/disable webhook
  - DELETE /api/shops/:id/webhooks - Delete webhook
- Integrated webhook triggers for all automated actions:
  - scrape_started - When job begins processing
  - scrape_completed - When scraping finishes successfully
  - scrape_failed - When scraping fails with error
  - content_changed - When content hash differs from previous scrape
  - schedule_created - When next scrape is scheduled
- Added HMAC-SHA256 signature support for webhook security
- Implemented exponential backoff retry logic (3 attempts)
- Added webhook delivery logging for monitoring and debugging

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
claude 8 months ago
parent
commit
ddd286718f

+ 204 - 0
src/api/routes.ts

@@ -410,6 +410,210 @@ export function createRouter(jobQueue: JobQueue, db?: ShopDatabase): Router {
     }
   });
 
+  /**
+   * POST /shops/:id/webhooks - Create or replace webhook for a shop
+   */
+  router.post('/shops/:id/webhooks', (req: Request, res: Response): void => {
+    try {
+      if (!db) {
+        res.status(503).json({ error: 'Database not available' });
+        return;
+      }
+
+      const { id } = req.params;
+      const { url, secret } = req.body;
+
+      if (!url) {
+        res.status(400).json({ error: 'url field is required' });
+        return;
+      }
+
+      // Validate URL format
+      try {
+        new URL(url);
+      } catch (error) {
+        res.status(400).json({ error: 'Invalid URL format' });
+        return;
+      }
+
+      const shop = db.getShopById(id);
+      if (!shop) {
+        res.status(404).json({ error: 'Shop not found' });
+        return;
+      }
+
+      // Check if webhook already exists
+      const existingWebhook = db.getWebhook(id);
+      if (existingWebhook) {
+        // Update existing webhook
+        db.updateWebhook(id, url, secret);
+        const updated = db.getWebhook(id);
+
+        res.json({
+          message: 'Webhook updated successfully',
+          webhook: {
+            id: updated!.id,
+            shop_id: updated!.shop_id,
+            url: updated!.url,
+            enabled: updated!.enabled,
+            created_at: updated!.created_at,
+            updated_at: updated!.updated_at
+          }
+        });
+      } else {
+        // Create new webhook
+        const webhookId = db.createWebhook(id, url, secret);
+        const webhook = db.getWebhook(id);
+
+        res.status(201).json({
+          message: 'Webhook created successfully',
+          webhook: {
+            id: webhook!.id,
+            shop_id: webhook!.shop_id,
+            url: webhook!.url,
+            enabled: webhook!.enabled,
+            created_at: webhook!.created_at,
+            updated_at: webhook!.updated_at
+          }
+        });
+      }
+    } catch (error) {
+      logger.error('Error creating/updating webhook', error);
+      res.status(500).json({ error: 'Internal server error' });
+    }
+  });
+
+  /**
+   * GET /shops/:id/webhooks - Get webhook configuration for a shop
+   */
+  router.get('/shops/:id/webhooks', (req: Request, res: Response): void => {
+    try {
+      if (!db) {
+        res.status(503).json({ error: 'Database not available' });
+        return;
+      }
+
+      const { id } = req.params;
+      const shop = db.getShopById(id);
+
+      if (!shop) {
+        res.status(404).json({ error: 'Shop not found' });
+        return;
+      }
+
+      const webhook = db.getWebhook(id);
+
+      if (!webhook) {
+        res.status(404).json({ error: 'No webhook configured for this shop' });
+        return;
+      }
+
+      // Get recent deliveries
+      const deliveries = db.getWebhookDeliveries(id, 10);
+
+      res.json({
+        webhook: {
+          id: webhook.id,
+          shop_id: webhook.shop_id,
+          url: webhook.url,
+          enabled: webhook.enabled,
+          created_at: webhook.created_at,
+          updated_at: webhook.updated_at
+        },
+        recent_deliveries: deliveries.map(d => ({
+          id: d.id,
+          event: d.event,
+          status: d.status,
+          response_code: d.response_code,
+          error: d.error,
+          attempted_at: d.attempted_at
+        }))
+      });
+    } catch (error) {
+      logger.error('Error fetching webhook', error);
+      res.status(500).json({ error: 'Internal server error' });
+    }
+  });
+
+  /**
+   * PATCH /shops/:id/webhooks - Enable or disable webhook
+   */
+  router.patch('/shops/:id/webhooks', (req: Request, res: Response): void => {
+    try {
+      if (!db) {
+        res.status(503).json({ error: 'Database not available' });
+        return;
+      }
+
+      const { id } = req.params;
+      const { enabled } = req.body;
+
+      if (typeof enabled !== 'boolean') {
+        res.status(400).json({ error: 'enabled field must be a boolean' });
+        return;
+      }
+
+      const shop = db.getShopById(id);
+      if (!shop) {
+        res.status(404).json({ error: 'Shop not found' });
+        return;
+      }
+
+      const webhook = db.getWebhook(id);
+      if (!webhook && enabled) {
+        res.status(404).json({ error: 'No webhook configured for this shop' });
+        return;
+      }
+
+      db.setWebhookEnabled(id, enabled);
+
+      res.json({
+        shop_id: id,
+        webhook_enabled: enabled,
+        message: `Webhook ${enabled ? 'enabled' : 'disabled'} successfully`
+      });
+    } catch (error) {
+      logger.error('Error updating webhook status', error);
+      res.status(500).json({ error: 'Internal server error' });
+    }
+  });
+
+  /**
+   * DELETE /shops/:id/webhooks - Delete webhook for a shop
+   */
+  router.delete('/shops/:id/webhooks', (req: Request, res: Response): void => {
+    try {
+      if (!db) {
+        res.status(503).json({ error: 'Database not available' });
+        return;
+      }
+
+      const { id } = req.params;
+      const shop = db.getShopById(id);
+
+      if (!shop) {
+        res.status(404).json({ error: 'Shop not found' });
+        return;
+      }
+
+      const webhook = db.getWebhook(id);
+      if (!webhook) {
+        res.status(404).json({ error: 'No webhook configured for this shop' });
+        return;
+      }
+
+      db.deleteWebhook(id);
+
+      res.json({
+        message: 'Webhook deleted successfully',
+        shop_id: id
+      });
+    } catch (error) {
+      logger.error('Error deleting webhook', error);
+      res.status(500).json({ error: 'Internal server error' });
+    }
+  });
+
   /**
    * GET /health - Health check endpoint
    */

+ 162 - 0
src/database/Database.ts

@@ -61,6 +61,27 @@ export interface ScheduledJob {
   created_at: string;
 }
 
+export interface Webhook {
+  id: string;
+  shop_id: string;
+  url: string;
+  enabled: boolean;
+  secret: string | null;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface WebhookDelivery {
+  id: string;
+  webhook_id: string;
+  event: string;
+  payload: any;
+  status: 'success' | 'failed';
+  response_code: number | null;
+  error: string | null;
+  attempted_at: string;
+}
+
 export class ShopDatabase {
   private db: Database.Database;
 
@@ -157,6 +178,35 @@ export class ShopDatabase {
       )
     `);
 
+    // Webhooks table
+    this.db.exec(`
+      CREATE TABLE IF NOT EXISTS webhooks (
+        id TEXT PRIMARY KEY,
+        shop_id TEXT NOT NULL,
+        url TEXT NOT NULL,
+        enabled INTEGER DEFAULT 1,
+        secret TEXT,
+        created_at TEXT NOT NULL,
+        updated_at TEXT NOT NULL,
+        FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE
+      )
+    `);
+
+    // Webhook delivery log table (for tracking delivery attempts)
+    this.db.exec(`
+      CREATE TABLE IF NOT EXISTS webhook_deliveries (
+        id TEXT PRIMARY KEY,
+        webhook_id TEXT NOT NULL,
+        event TEXT NOT NULL,
+        payload TEXT NOT NULL,
+        status TEXT NOT NULL,
+        response_code INTEGER,
+        error TEXT,
+        attempted_at TEXT NOT NULL,
+        FOREIGN KEY (webhook_id) REFERENCES webhooks(id) ON DELETE CASCADE
+      )
+    `);
+
     logger.info('Database tables initialized');
   }
 
@@ -501,6 +551,118 @@ export class ShopDatabase {
     logger.info(`${enabled ? 'Enabled' : 'Disabled'} schedule for shop ${shopId}`);
   }
 
+  // Webhook operations
+  createWebhook(shopId: string, url: string, secret?: string): string {
+    const id = uuidv4();
+    const now = new Date().toISOString();
+
+    // First, disable any existing webhooks for this shop (only one webhook per shop)
+    this.db.prepare(`
+      UPDATE webhooks
+      SET enabled = 0
+      WHERE shop_id = ?
+    `).run(shopId);
+
+    this.db.prepare(`
+      INSERT INTO webhooks (id, shop_id, url, enabled, secret, created_at, updated_at)
+      VALUES (?, ?, ?, 1, ?, ?, ?)
+    `).run(id, shopId, url, secret || null, now, now);
+
+    logger.info(`Created webhook ${id} for shop ${shopId}`);
+    return id;
+  }
+
+  getWebhook(shopId: string): Webhook | null {
+    const stmt = this.db.prepare(`
+      SELECT * FROM webhooks
+      WHERE shop_id = ? AND enabled = 1
+      ORDER BY created_at DESC
+      LIMIT 1
+    `);
+    const result = stmt.get(shopId) as any;
+    if (!result) return null;
+
+    return {
+      id: result.id,
+      shop_id: result.shop_id,
+      url: result.url,
+      enabled: Boolean(result.enabled),
+      secret: result.secret,
+      created_at: result.created_at,
+      updated_at: result.updated_at
+    };
+  }
+
+  updateWebhook(shopId: string, url: string, secret?: string): void {
+    const now = new Date().toISOString();
+
+    this.db.prepare(`
+      UPDATE webhooks
+      SET url = ?, secret = ?, updated_at = ?
+      WHERE shop_id = ? AND enabled = 1
+    `).run(url, secret || null, now, shopId);
+
+    logger.info(`Updated webhook for shop ${shopId}`);
+  }
+
+  deleteWebhook(shopId: string): void {
+    this.db.prepare(`
+      DELETE FROM webhooks
+      WHERE shop_id = ?
+    `).run(shopId);
+
+    logger.info(`Deleted webhook for shop ${shopId}`);
+  }
+
+  setWebhookEnabled(shopId: string, enabled: boolean): void {
+    this.db.prepare(`
+      UPDATE webhooks
+      SET enabled = ?
+      WHERE shop_id = ?
+    `).run(enabled ? 1 : 0, shopId);
+
+    logger.info(`${enabled ? 'Enabled' : 'Disabled'} webhook for shop ${shopId}`);
+  }
+
+  logWebhookDelivery(
+    webhookId: string,
+    event: string,
+    payload: any,
+    status: 'success' | 'failed',
+    responseCode?: number,
+    error?: string
+  ): void {
+    const id = uuidv4();
+    const now = new Date().toISOString();
+
+    this.db.prepare(`
+      INSERT INTO webhook_deliveries (id, webhook_id, event, payload, status, response_code, error, attempted_at)
+      VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+    `).run(id, webhookId, event, JSON.stringify(payload), status, responseCode || null, error || null, now);
+  }
+
+  getWebhookDeliveries(shopId: string, limit: number = 50): WebhookDelivery[] {
+    const stmt = this.db.prepare(`
+      SELECT wd.* FROM webhook_deliveries wd
+      INNER JOIN webhooks w ON wd.webhook_id = w.id
+      WHERE w.shop_id = ?
+      ORDER BY wd.attempted_at DESC
+      LIMIT ?
+    `);
+    const results = stmt.all(shopId, limit) as any[];
+
+    return results.map(r => ({
+      id: r.id,
+      webhook_id: r.webhook_id,
+      event: r.event,
+      payload: JSON.parse(r.payload),
+      status: r.status,
+      response_code: r.response_code,
+      error: r.error,
+      attempted_at: r.attempted_at
+    }));
+  }
+
   close(): void {
     this.db.close();
     logger.info('Database connection closed');

+ 40 - 0
src/index.ts

@@ -5,6 +5,7 @@ import { WebshopScraper } from './scraper/WebshopScraper';
 import { logger } from './utils/logger';
 import { ShopDatabase } from './database/Database';
 import { ScrapeScheduler } from './scheduler/ScrapeScheduler';
+import { WebhookManager } from './webhooks/WebhookManager';
 
 async function main() {
   try {
@@ -25,6 +26,9 @@ async function main() {
     // Initialize scheduler
     const scheduler = new ScrapeScheduler(db, jobQueue);
 
+    // Initialize webhook manager
+    const webhookManager = new WebhookManager(db);
+
     // Handle job processing
     jobQueue.on('process-job', async (job) => {
       logger.info(`Processing job ${job.id}`);
@@ -37,6 +41,13 @@ async function main() {
         if (job.shopId) {
           scrapeHistoryId = db.createScrapeHistory(job.shopId, job.id);
           db.updateScrapeHistory(scrapeHistoryId, { status: 'processing' });
+
+          // Trigger webhook: scrape_started
+          await webhookManager.notifyScrapeStarted(
+            job.shopId,
+            job.id,
+            job.sitemapUrl
+          );
         }
 
         const result = await scraper.scrape(job.sitemapUrl, job.shopId);
@@ -56,6 +67,26 @@ async function main() {
           });
         }
 
+        // Trigger webhook: scrape_completed
+        if (job.shopId) {
+          const totalUrls = (result.shipping_informations?.length || 0) +
+                           (result.contacts?.length || 0) +
+                           (result.terms_of_conditions?.length || 0) +
+                           (result.faq?.length || 0);
+
+          const totalPages = totalUrls;
+
+          await webhookManager.notifyScrapeCompleted(
+            job.shopId,
+            job.id,
+            {
+              scrape_time_ms: Date.now() - startTime,
+              urls_found: totalUrls,
+              pages_scraped: totalPages
+            }
+          );
+        }
+
         // Schedule next scrape if this is a new shop or first scrape
         if (job.shopId) {
           const existingSchedules = db.getScheduledJobs(job.shopId);
@@ -85,6 +116,15 @@ async function main() {
           });
         }
 
+        // Trigger webhook: scrape_failed
+        if (job.shopId) {
+          await webhookManager.notifyScrapeFailed(
+            job.shopId,
+            job.id,
+            errorMessage
+          );
+        }
+
         logger.error(`Job ${job.id} failed`, error);
       }
     });

+ 12 - 0
src/scheduler/ScrapeScheduler.ts

@@ -3,15 +3,18 @@ import { ShopDatabase } from '../database/Database';
 import { JobQueue } from '../queue/JobQueue';
 import { logger } from '../utils/logger';
 import { v4 as uuidv4 } from 'uuid';
+import { WebhookManager } from '../webhooks/WebhookManager';
 
 export class ScrapeScheduler {
   private db: ShopDatabase;
   private jobQueue: JobQueue;
   private cronTask: cron.ScheduledTask | null = null;
+  private webhookManager: WebhookManager;
 
   constructor(db: ShopDatabase, jobQueue: JobQueue) {
     this.db = db;
     this.jobQueue = jobQueue;
+    this.webhookManager = new WebhookManager(db);
   }
 
   /**
@@ -132,6 +135,15 @@ export class ScrapeScheduler {
     // Update shop analytics with next scrape time
     this.db.setNextScrapeTime(scheduledJob.shop_id, nextRunAt.toISOString());
 
+    // Trigger webhook: schedule_created
+    this.webhookManager.notifyScheduleCreated(
+      scheduledJob.shop_id,
+      nextRunAt.toISOString(),
+      scheduledJob.frequency
+    ).catch(error => {
+      logger.error('Error sending schedule_created webhook', error);
+    });
+
     logger.info(`Scheduled next run for shop ${scheduledJob.shop_id} at ${nextRunAt.toISOString()}`);
   }
 

+ 67 - 4
src/scraper/WebshopScraper.ts

@@ -3,14 +3,17 @@ import { SitemapParser } from './SitemapParser';
 import { ContentExtractor } from './ContentExtractor';
 import { logger } from '../utils/logger';
 import { ShopDatabase } from '../database/Database';
+import { WebhookManager } from '../webhooks/WebhookManager';
 
 export class WebshopScraper {
   private contentExtractor: ContentExtractor;
   private db: ShopDatabase | null;
+  private webhookManager: WebhookManager | null;
 
   constructor(db?: ShopDatabase) {
     this.contentExtractor = new ContentExtractor();
     this.db = db || null;
+    this.webhookManager = db ? new WebhookManager(db) : null;
   }
 
   /**
@@ -61,19 +64,79 @@ export class WebshopScraper {
       if (this.db && shopId) {
         // Save all shipping pages
         for (const content of shippingContent) {
-          this.db.saveContent(shopId, 'shipping', content.url, content.content);
+          const result = this.db.saveContent(shopId, 'shipping', content.url, content.content);
+
+          // Trigger webhook if content changed
+          if (result.changed && this.webhookManager) {
+            const previous = this.db.getContentHistory(shopId, 'shipping', 2);
+            const oldHash = previous.length > 1 ? previous[1].content_hash : '';
+            const newHash = previous.length > 0 ? previous[0].content_hash : '';
+
+            await this.webhookManager.notifyContentChanged(
+              shopId,
+              'shipping',
+              content.url,
+              oldHash,
+              newHash
+            );
+          }
         }
         // Save all contact pages
         for (const content of contactsContent) {
-          this.db.saveContent(shopId, 'contacts', content.url, content.content);
+          const result = this.db.saveContent(shopId, 'contacts', content.url, content.content);
+
+          // Trigger webhook if content changed
+          if (result.changed && this.webhookManager) {
+            const previous = this.db.getContentHistory(shopId, 'contacts', 2);
+            const oldHash = previous.length > 1 ? previous[1].content_hash : '';
+            const newHash = previous.length > 0 ? previous[0].content_hash : '';
+
+            await this.webhookManager.notifyContentChanged(
+              shopId,
+              'contacts',
+              content.url,
+              oldHash,
+              newHash
+            );
+          }
         }
         // Save all terms pages
         for (const content of termsContent) {
-          this.db.saveContent(shopId, 'terms', content.url, content.content);
+          const result = this.db.saveContent(shopId, 'terms', content.url, content.content);
+
+          // Trigger webhook if content changed
+          if (result.changed && this.webhookManager) {
+            const previous = this.db.getContentHistory(shopId, 'terms', 2);
+            const oldHash = previous.length > 1 ? previous[1].content_hash : '';
+            const newHash = previous.length > 0 ? previous[0].content_hash : '';
+
+            await this.webhookManager.notifyContentChanged(
+              shopId,
+              'terms',
+              content.url,
+              oldHash,
+              newHash
+            );
+          }
         }
         // Save all FAQ pages
         for (const content of faqContent) {
-          this.db.saveContent(shopId, 'faq', content.url, content.content);
+          const result = this.db.saveContent(shopId, 'faq', content.url, content.content);
+
+          // Trigger webhook if content changed
+          if (result.changed && this.webhookManager) {
+            const previous = this.db.getContentHistory(shopId, 'faq', 2);
+            const oldHash = previous.length > 1 ? previous[1].content_hash : '';
+            const newHash = previous.length > 0 ? previous[0].content_hash : '';
+
+            await this.webhookManager.notifyContentChanged(
+              shopId,
+              'faq',
+              content.url,
+              oldHash,
+              newHash
+            );
+          }
         }
 
         // Update analytics

+ 5 - 0
src/types/index.ts

@@ -31,3 +31,8 @@ export interface SitemapUrl {
   changefreq?: string;
   priority?: string;
 }
+
+export interface WebhookConfig {
+  url: string;
+  secret?: string;
+}

+ 231 - 0
src/webhooks/WebhookManager.ts

@@ -0,0 +1,231 @@
+import axios, { AxiosError } from 'axios';
+import { logger } from '../utils/logger';
+import { ShopDatabase, Webhook } from '../database/Database';
+import crypto from 'crypto';
+
+export type WebhookEvent =
+  | 'scrape_started'
+  | 'scrape_completed'
+  | 'scrape_failed'
+  | 'content_changed'
+  | 'schedule_created';
+
+export interface WebhookPayload {
+  event: WebhookEvent;
+  timestamp: string;
+  shop_id: string;
+  shop_url?: string;
+  data: any;
+}
+
+export class WebhookManager {
+  private db: ShopDatabase;
+  private retryAttempts: number = 3;
+  private retryDelay: number = 1000; // 1 second
+
+  constructor(db: ShopDatabase) {
+    this.db = db;
+  }
+
+  /**
+   * Trigger a webhook for a specific shop and event
+   */
+  async triggerWebhook(
+    shopId: string,
+    event: WebhookEvent,
+    data: any
+  ): Promise<void> {
+    const webhook = this.db.getWebhook(shopId);
+
+    if (!webhook || !webhook.enabled) {
+      logger.debug(`No active webhook found for shop ${shopId}`);
+      return;
+    }
+
+    const shop = this.db.getShopById(shopId);
+    const payload: WebhookPayload = {
+      event,
+      timestamp: new Date().toISOString(),
+      shop_id: shopId,
+      shop_url: shop?.url,
+      data
+    };
+
+    await this.sendWebhook(webhook, payload);
+  }
+
+  /**
+   * Send webhook with retry logic
+   */
+  private async sendWebhook(
+    webhook: Webhook,
+    payload: WebhookPayload,
+    attempt: number = 1
+  ): Promise<void> {
+    try {
+      logger.info(`Sending webhook to ${webhook.url} for event ${payload.event} (attempt ${attempt})`);
+
+      const headers: any = {
+        'Content-Type': 'application/json',
+        'User-Agent': 'WebshopScraper-Webhook/1.0'
+      };
+
+      // Add signature header if secret is configured
+      if (webhook.secret) {
+        const signature = this.generateSignature(payload, webhook.secret);
+        headers['X-Webhook-Signature'] = signature;
+      }
+
+      const response = await axios.post(webhook.url, payload, {
+        headers,
+        timeout: 10000, // 10 second timeout
+        validateStatus: (status) => status >= 200 && status < 300
+      });
+
+      logger.info(`Webhook delivered successfully to ${webhook.url} (status: ${response.status})`);
+
+      // Log successful delivery
+      this.db.logWebhookDelivery(
+        webhook.id,
+        payload.event,
+        payload,
+        'success',
+        response.status
+      );
+
+    } catch (error) {
+      const axiosError = error as AxiosError;
+      const errorMessage = axiosError.message || 'Unknown error';
+      const responseCode = axiosError.response?.status;
+
+      logger.error(`Webhook delivery failed to ${webhook.url}: ${errorMessage} (status: ${responseCode})`);
+
+      // Retry logic
+      if (attempt < this.retryAttempts) {
+        logger.info(`Retrying webhook delivery (${attempt + 1}/${this.retryAttempts})...`);
+        await this.delay(this.retryDelay * attempt); // Exponential backoff
+        return this.sendWebhook(webhook, payload, attempt + 1);
+      }
+
+      // Log failed delivery after all retries
+      this.db.logWebhookDelivery(
+        webhook.id,
+        payload.event,
+        payload,
+        'failed',
+        responseCode,
+        errorMessage
+      );
+    }
+  }
+
+  /**
+   * Generate HMAC signature for webhook payload
+   */
+  private generateSignature(payload: WebhookPayload, secret: string): string {
+    const payloadString = JSON.stringify(payload);
+    const hmac = crypto.createHmac('sha256', secret);
+    hmac.update(payloadString);
+    return `sha256=${hmac.digest('hex')}`;
+  }
+
+  /**
+   * Verify webhook signature (for webhook receivers)
+   */
+  static verifySignature(
+    payload: WebhookPayload,
+    signature: string,
+    secret: string
+  ): boolean {
+    const payloadString = JSON.stringify(payload);
+    const hmac = crypto.createHmac('sha256', secret);
+    hmac.update(payloadString);
+    const expectedSignature = `sha256=${hmac.digest('hex')}`;
+    return signature === expectedSignature;
+  }
+
+  /**
+   * Helper method to delay execution
+   */
+  private delay(ms: number): Promise<void> {
+    return new Promise(resolve => setTimeout(resolve, ms));
+  }
+
+  /**
+   * Trigger scrape started event
+   */
+  async notifyScrapeStarted(
+    shopId: string,
+    jobId: string,
+    sitemapUrl: string
+  ): Promise<void> {
+    await this.triggerWebhook(shopId, 'scrape_started', {
+      job_id: jobId,
+      sitemap_url: sitemapUrl
+    });
+  }
+
+  /**
+   * Trigger scrape completed event
+   */
+  async notifyScrapeCompleted(
+    shopId: string,
+    jobId: string,
+    result: {
+      scrape_time_ms: number;
+      urls_found: number;
+      pages_scraped: number;
+    }
+  ): Promise<void> {
+    await this.triggerWebhook(shopId, 'scrape_completed', {
+      job_id: jobId,
+      ...result
+    });
+  }
+
+  /**
+   * Trigger scrape failed event
+   */
+  async notifyScrapeFailed(
+    shopId: string,
+    jobId: string,
+    error: string
+  ): Promise<void> {
+    await this.triggerWebhook(shopId, 'scrape_failed', {
+      job_id: jobId,
+      error
+    });
+  }
+
+  /**
+   * Trigger content changed event
+   */
+  async notifyContentChanged(
+    shopId: string,
+    contentType: string,
+    url: string,
+    oldHash: string,
+    newHash: string
+  ): Promise<void> {
+    await this.triggerWebhook(shopId, 'content_changed', {
+      content_type: contentType,
+      url,
+      old_hash: oldHash,
+      new_hash: newHash
+    });
+  }
+
+  /**
+   * Trigger schedule created event
+   */
+  async notifyScheduleCreated(
+    shopId: string,
+    nextRunAt: string,
+    frequency: string | null
+  ): Promise<void> {
+    await this.triggerWebhook(shopId, 'schedule_created', {
+      next_run_at: nextRunAt,
+      frequency
+    });
+  }
+}