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:
- Scheduled Processing: EventBridge triggers the Paywall Manager to check and update posts
- 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:
- EventBridge + Lambda: Handles time-based business logic
- Webhook Router: Provides a unified endpoint for all integrations
- 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/llmsfor 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.