Ghost CMS provides a powerful webhook system that enables deep customization beyond its built-in features. By building a centralized webhook router with AWS API Gateway and Lambda, you can create a scalable foundation for Ghost integrations. This post explores our production webhook architecture and demonstrates its power through a real example: an automated time-gating system where paid content becomes public after 7 days.

Why Webhooks Matter for Ghost

Ghost's philosophy centers on simplicity and performance. Rather than building every possible feature into the core platform, Ghost provides webhooks as extension points. This approach offers several advantages:

  • Clean Core: Ghost remains fast and focused on content management
  • Custom Logic: Build exactly what your business needs
  • External Processing: Heavy operations run outside Ghost
  • Event-Driven: React to content changes in real-time
  • Integration Ready: Connect to any external service

Webhooks transform Ghost from a static CMS into a dynamic platform that adapts to your specific requirements.

Understanding Ghost's Webhook System

Ghost webhooks are HTTP POST requests sent to your endpoints when specific events occur. Each webhook includes:

Event Types

Ghost supports webhooks for three main resource types:

// Post events
post.published; // New post goes live
post.published.edited; // Published post updated
post.unpublished; // Post taken offline
post.deleted; // Post removed

// Page events
page.published;
page.published.edited;
page.unpublished;
page.deleted;

// Member events
member.added; // New member signs up
member.updated; // Member details change
member.deleted; // Member removed

Webhook Payload Structure

Every webhook delivers a consistent payload structure:

interface GhostWebhookPayload {
  post?: {
    current: PostObject; // Current state after the event
    previous?: PostObject; // Previous state (for updates)
  };
  page?: {
    current: PageObject;
    previous?: PageObject;
  };
  member?: {
    current: MemberObject;
    previous?: MemberObject;
  };
}

Security with Webhook Signatures

Ghost signs each webhook request using HMAC-SHA256. This signature ensures the request originated from your Ghost instance and hasn't been tampered with:

// Ghost sends signature in header: x-ghost-signature
// Format: sha256=hash, t=timestamp

const validateWebhook = (
  body: string,
  signature: string,
  secret: string,
): boolean => {
  const [hashPart, timestampPart] = signature.split(', ');
  const hash = hashPart.split('=')[1];
  const timestamp = timestampPart.split('=')[1];

  // Ghost signs: body + timestamp
  const payloadToSign = body + timestamp;
  const expectedHash = crypto
    .createHmac('sha256', secret)
    .update(payloadToSign)
    .digest('hex');

  // Timing-safe comparison prevents timing attacks
  return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(expectedHash));
};

Real-World Example: Time-Gated Early Access

To demonstrate the power of our webhook architecture, let's explore a production system that implements sophisticated content monetization. The business model is simple but effective:

  • Paid members: Immediate access to new content
  • Free readers: Content becomes available after 7 days
  • SEO benefit: All content eventually becomes public and indexable

This system showcases how webhook infrastructure enables complex features while keeping Ghost's core simple.

System Architecture

The time gating system combines scheduled automation with webhook-triggered updates:

graph TD
    subgraph Ghost CMS
        Post[Post with #early-access tag]
        Update[Post Made Public]
        Webhook[Webhook Trigger]
    end

    subgraph Scheduled Automation
        EventBridge[EventBridge Daily @ 2 AM]
        PaywallManager[Paywall Manager Lambda]
    end

    subgraph Webhook Infrastructure
        Router[API Gateway Router]
        Validation[Request Validation]
        LLMSLambda[LLMS.txt Generator]
    end

    subgraph Storage & Monitoring
        DynamoDB[(Tracking Table)]
        S3[(Public Content)]
        SNS[Notifications]
    end

    EventBridge --> PaywallManager
    PaywallManager --> |Check Posts| Ghost CMS
    PaywallManager --> |Update Visibility| Update
    PaywallManager --> DynamoDB
    PaywallManager --> SNS

    Update --> Webhook
    Webhook --> Router
    Router --> Validation
    Validation --> LLMSLambda
    LLMSLambda --> S3

    Post --> |Manual Webhook| Router

Note the two distinct flows:

  1. Scheduled Processing: EventBridge triggers the Paywall Manager to check and update posts
  2. Webhook Processing: When posts change (manually or automatically), webhooks trigger content regeneration

Implementation Deep Dive

1. Centralized Webhook Router

Instead of creating separate endpoints for each webhook, we use API Gateway as a centralized router. This provides consistent authentication, throttling, and monitoring:

export class GhostWebhookRouter extends Construct {
  public readonly api: apigateway.RestApi;

  constructor(scope: Construct, id: string, props: GhostWebhookRouterProps) {
    super(scope, id);

    this.api = new apigateway.RestApi(this, 'WebhookApi', {
      restApiName: 'ghost-webhook-router',
      deployOptions: {
        stageName: 'api',
        throttlingRateLimit: 100, // Requests per second
        throttlingBurstLimit: 200, // Burst capacity
        loggingLevel: apigateway.MethodLoggingLevel.INFO,
        metricsEnabled: true,
      },
    });

    // Configure CORS for Ghost admin testing
    this.api.addDefaultMethodOptions({
      corsConfiguration: {
        allowOrigins: [props.ghostUrl],
        allowMethods: ['POST', 'OPTIONS'],
        allowHeaders: [
          'Content-Type',
          'X-Ghost-Signature',
          'X-Ghost-Webhook-Name',
        ],
      },
    });

    // Add routes for different webhook handlers
    this.addRoute({
      path: 'content-processor',
      handler: contentProcessorLambda,
      description: 'Process new content for time gating',
    });
  }
}

2. Scheduled Visibility Manager

EventBridge triggers this Lambda daily to check for posts ready to become public:

export const visibilityManager: ScheduledHandler = async (event) => {
  const ghostApi = new GhostAdminAPI({
    url: process.env.GHOST_URL,
    key: await getSecretValue('ghost-admin-key'),
    version: 'v5.0',
  });

  // Find posts that should become public today
  const eligiblePosts = await ghostApi.posts.browse({
    filter: 'tag:early-access+visibility:paid',
    include: 'tags',
    limit: 'all',
  });

  const today = new Date();
  const postsToUpdate = eligiblePosts.filter((post) => {
    const daysSincePublished = Math.floor(
      (today - new Date(post.published_at)) / (1000 * 60 * 60 * 24),
    );
    return daysSincePublished >= 7;
  });

  for (const post of postsToUpdate) {
    // Remove early-access tag and update visibility
    const updatedTags = post.tags.filter((tag) => tag.slug !== 'early-access');

    await ghostApi.posts.edit({
      id: post.id,
      visibility: 'public',
      tags: updatedTags,
      updated_at: post.updated_at,
    });

    // Trigger webhook to regenerate public content
    await triggerContentRegeneration(post);

    // Log and notify
    await logToDynamoDB(post, 'made_public');
    await sendNotification(post);
  }
};

3. Triggering Content Regeneration via Webhooks

When the Paywall Manager makes a post public, it triggers our webhook to regenerate public-facing content:

async function triggerContentRegeneration(post: any): Promise<void> {
  const webhookUrl = process.env.LLMS_WEBHOOK_URL;
  // In our case: https://domain.com/api/webhook/llms

  if (!webhookUrl) {
    console.log('No webhook URL configured');
    return;
  }

  // Create a webhook payload mimicking Ghost's format
  const webhookPayload = {
    post: {
      current: {
        ...post,
        visibility: 'public',
        status: 'published',
      },
    },
  };

  const response = await fetch(webhookUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(webhookPayload),
  });

  if (response.ok) {
    console.log(`Triggered content regeneration for: ${post.slug}`);
  }
}

4. The Webhook Handler: Content Protection

Our webhook router forwards requests to the llms.txt generator, which ensures paid content stays protected:

// In the llms.txt generator Lambda (triggered via webhook)
export const handler = async (event: APIGatewayProxyEvent) => {
  // Validate webhook signature if configured
  const signature = event.headers['x-ghost-signature'];
  if (webhookSecret && !validateSignature(event.body, signature)) {
    return { statusCode: 401, body: 'Unauthorized' };
  }

  const payload = JSON.parse(event.body);
  const post = payload.post?.current;

  // Critical: Check visibility before generating public content
  if (post.visibility !== 'public') {
    console.log(
      `Post ${post.slug} has restricted visibility (${post.visibility})`,
    );
    console.log('Skipping llms.txt generation to protect paid content');
    return {
      statusCode: 200,
      body: JSON.stringify({
        message: 'Skipped - restricted content',
        visibility: post.visibility,
      }),
    };
  }

  // Generate and upload llms.txt for public posts
  const content = generateLlmsContent(post);
  await uploadToS3(post.slug, content);

  return { statusCode: 200, body: 'Generated successfully' };
};

This ensures:

  • No llms.txt while content is paid (protects revenue)
  • Automatic generation when content becomes public (improves SEO)

Advanced Webhook Patterns

1. Request Validation with JSON Schema

API Gateway can validate webhook payloads before they reach Lambda:

const webhookModel = new apigateway.Model(this, 'WebhookModel', {
  restApi: this.api,
  contentType: 'application/json',
  schema: {
    type: apigateway.JsonSchemaType.OBJECT,
    properties: {
      post: {
        type: apigateway.JsonSchemaType.OBJECT,
        properties: {
          current: {
            type: apigateway.JsonSchemaType.OBJECT,
            required: ['id', 'slug', 'status'],
          },
        },
      },
    },
  },
});

// Apply validation to routes
routeResource.addMethod('POST', integration, {
  requestValidator: new apigateway.RequestValidator(this, 'Validator', {
    validateRequestBody: true,
  }),
  requestModels: {
    'application/json': webhookModel,
  },
});

2. Retry Logic with Dead Letter Queues

For critical webhooks, implement retry logic with SQS:

const dlq = new sqs.Queue(this, 'WebhookDLQ', {
  queueName: 'ghost-webhook-dlq',
  retentionPeriod: cdk.Duration.days(14),
});

const webhookQueue = new sqs.Queue(this, 'WebhookQueue', {
  queueName: 'ghost-webhook-queue',
  deadLetterQueue: {
    queue: dlq,
    maxReceiveCount: 3, // Retry 3 times before DLQ
  },
});

// Lambda processes from queue instead of direct invocation
lambdaFunction.addEventSource(new SqsEventSource(webhookQueue));

3. Webhook Replay for Testing

Store webhook payloads for replay during development:

const storeWebhookForReplay = async (payload: any) => {
  await s3Client.send(
    new PutObjectCommand({
      Bucket: 'webhook-replay-bucket',
      Key: `webhooks/${Date.now()}-${payload.post?.current?.id}.json`,
      Body: JSON.stringify({
        timestamp: new Date().toISOString(),
        payload,
        headers: event.headers,
      }),
      ContentType: 'application/json',
    }),
  );
};

// Replay stored webhooks
const replayWebhook = async (webhookId: string) => {
  const stored = await s3Client.send(
    new GetObjectCommand({
      Bucket: 'webhook-replay-bucket',
      Key: webhookId,
    }),
  );

  // Re-invoke Lambda with stored payload
  await lambdaClient.send(
    new InvokeCommand({
      FunctionName: 'webhook-processor',
      Payload: stored.Body,
    }),
  );
};

Monitoring and Operations

CloudWatch Dashboards

Create comprehensive dashboards to monitor webhook processing:

const dashboard = new cloudwatch.Dashboard(this, 'WebhookDashboard', {
  dashboardName: 'ghost-webhooks',
});

dashboard.addWidgets(
  new cloudwatch.GraphWidget({
    title: 'Webhook Processing',
    left: [webhookCountMetric],
    right: [webhookErrorMetric],
  }),
  new cloudwatch.SingleValueWidget({
    title: 'Posts Made Public Today',
    metrics: [postsProcessedMetric],
  }),
);

Operational Commands

Useful commands for managing the system:

# Check webhook processing logs
aws logs tail /aws/lambda/webhook-processor --follow

# View posts scheduled to become public
aws dynamodb query \
  --table-name ghost-post-tracking \
  --index-name status-index \
  --key-condition-expression "status = :pending" \
  --expression-attribute-values '{":pending":{"S":"pending"}}'

# Manually trigger visibility check
aws lambda invoke \
  --function-name ghost-visibility-manager \
  --invocation-type Event \
  response.json

# Check failed webhooks in DLQ
aws sqs receive-message \
  --queue-url https://sqs.region.amazonaws.com/account/ghost-webhook-dlq

Beyond Time Gating: Other Webhook Use Cases

The webhook architecture enables countless customizations:

1. AI Content Enhancement

// Automatically generate summaries, SEO metadata, or translations
post.published -> Lambda -> OpenAI API -> Update post metadata

2. Social Media Automation

// Cross-post to social platforms when content publishes
post.published -> Lambda -> BlueSky/LinkedIn APIs

3. Email Digest Generation

// Weekly newsletter with new content
EventBridge (weekly) -> Lambda -> Query recent posts -> Send email

4. Content Backup

// Backup all content changes to S3
post.* -> Lambda -> S3 versioned bucket

5. Analytics Integration

// Track content performance in custom analytics
post.published -> Lambda -> Analytics database

Key Architecture Insights

Separation of Concerns

Our implementation separates scheduled automation from event-driven updates:

  1. EventBridge + Lambda: Handles time-based business logic
  2. Webhook Router: Provides a unified endpoint for all integrations
  3. Specialized Handlers: Each Lambda focuses on one task

This separation makes the system maintainable and testable.

Webhook Router as Central Hub

The centralized router provides:

  • Single endpoint for Ghost to configure
  • Consistent validation across all handlers
  • Unified monitoring through CloudWatch
  • Easy expansion - just add new routes

Real Implementation Details

In production, we currently have:

  • One webhook route: /api/webhook/llms for content generation
  • CloudFront distribution forwarding /api/webhook/* to API Gateway
  • EventBridge rule running the Paywall Manager daily at 2 AM UTC
  • DynamoDB table tracking all visibility changes
  • SNS notifications for operational awareness

Conclusion

Ghost's webhook system, combined with a well-architected router, creates a powerful platform for customization. Our production implementation shows how webhooks enable sophisticated features like automated time gating while keeping Ghost's core clean and performant.

The key takeaway: webhooks aren't just for receiving events from Ghost - they're also powerful for inter-service communication. Our Paywall Manager uses webhooks to trigger content regeneration, demonstrating how serverless components can orchestrate complex workflows through simple HTTP calls.

Whether you're implementing content monetization, automated publishing workflows, or custom integrations, the webhook router pattern provides a solid foundation that scales with your needs while maintaining the simplicity that makes Ghost great.