How Ghost Webhooks Work and How to Create Custom Webhooks

How Ghost Webhooks Work and How to Create Custom Webhooks

Ghost implements event-driven webhooks through a three-layer architecture where Admin API endpoints register definitions in the database, and a WebhookTrigger service delivers signed JSON payloads to target URLs when events occur.

Ghost webhooks provide a real-time notification system that alerts external services when content changes or members interact with your site. According to the TryGhost/Ghost source code, this system processes events through distinct API, model, and trigger layers to ensure reliable delivery of webhook notifications.

Architecture of the Ghost Webhook System

Ghost organizes its webhook infrastructure into three main components that handle registration, persistence, and execution.

Admin API Layer

The entry point for webhook management resides in ghost/core/core/server/api/endpoints/webhooks.js. This controller exposes endpoints for creating, editing, and deleting webhook records while enforcing permission checks. When you register a new webhook, this layer validates the input schema and delegates storage to the model layer.

Model Layer

Webhook definitions persist in the webhooks database table via ghost/core/core/server/models/webhook.js. This Bookshelf model emits change events whenever records are added, modified, or removed, ensuring the system remains synchronized with your integration requirements.

Trigger Layer

The execution engine lives in ghost/core/core/server/services/webhooks/webhook-trigger.js. The WebhookTrigger service listens for Ghost events (such as member.added), retrieves matching webhook definitions via WebhookTrigger#getAll, constructs JSON payloads, and delivers them to target URLs. This service also handles automatic cleanup and respects custom integration limits.

How the WebhookTrigger Service Processes Events

When a Ghost event fires, the trigger service executes a specific lifecycle:

  1. Event Matching: The service queries the webhooks table for entries where the event column matches the fired event name.
  2. Payload Construction: For each match, Ghost builds a JSON payload containing the event type and relevant model data (such as member details).
  3. Request Signing: If the webhook includes a secret, the service generates an HMAC-SHA256 signature and attaches it as the X-Ghost-Signature header.
  4. HTTP Delivery: The service POSTs the payload to the target_url.
  5. Status Tracking: After delivery, Ghost updates last_triggered_at and last_triggered_status via WebhookTrigger#update.
  6. Auto-Cleanup: If the endpoint returns HTTP 410 (Gone), WebhookTrigger#onError automatically destroys the webhook record to prevent further delivery attempts.

The service also enforces the custom-integrations limit—when limits are reached, only internal webhooks fire.

Creating Custom Webhooks via the Admin API

To register a custom webhook, send a POST request to /admin/webhooks/ with the following required fields:

  • name: Human-readable identifier for the webhook
  • event: Ghost event identifier (e.g., member.added, post.published)
  • target_url: HTTPS endpoint that will receive the POST request
  • integration_id: UUID of the integration that owns this webhook
  • secret (optional): String used to sign requests via HMAC-SHA256

The validation logic in ghost/core/core/server/api/endpoints/utils/validators/input/webhooks.js enforces that integration_id must be present when using session authentication; otherwise, it throws a ValidationError.

Example: Registering a Webhook with Node.js

import fetch from 'node-fetch';

const ADMIN_API_KEY = 'YOUR_ADMIN_API_KEY';
const GHOST_URL = 'http://localhost:2368';

await fetch(`${GHOST_URL}/admin/webhooks/`, {
  method: 'POST',
  headers: {
    'Authorization': `Ghost ${ADMIN_API_KEY}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    webhooks: [{
      name: 'Member Signup Handler',
      event: 'member.added',
      target_url: 'https://api.example.com/ghost-webhooks/members',
      secret: 'whsec_super_secret_key',
      integration_id: '5f29d1c5c7c3f6001f8b4567'
    }]
  })
});

Webhook Payload Structure

When Ghost triggers your webhook, it sends a JSON payload with this structure:

{
  "event": "member.added",
  "data": {
    "member": {
      "id": "5f29d1c5c7c3f6001f8b4567",
      "email": "user@example.com",
      "name": "Jane Doe",
      "subscribed": true,
      "created_at": "2023-07-21T15:30:00.000Z"
    }
  }
}

This matches the internal payload generation logic where Ghost converts the Bookshelf model to JSON and wraps it in an event-specific structure.

Verifying Webhook Signatures

When you provide a secret, Ghost sends requests with the header:


X-Ghost-Signature: sha256=abc123def456..., t=1658421234567

Verify this signature in your receiver to confirm the payload originated from your Ghost instance:

import crypto from 'crypto';
import express from 'express';

const app = express();
app.use(express.json());

app.post('/ghost-webhooks/members', (req, res) => {
  const signature = req.headers['x-ghost-signature'];
  const secret = 'whsec_super_secret_key'; // Same secret used during creation
  
  if (!signature) {
    return res.status(400).send('Missing signature');
  }
  
  const [hashPart, tsPart] = signature.split(', ');
  const receivedHash = hashPart.split('=')[1];
  const timestamp = tsPart.split('=')[1];
  
  const expectedHash = crypto
    .createHmac('sha256', secret)
    .update(`${JSON.stringify(req.body)}${timestamp}`)
    .digest('hex');
  
  if (crypto.timingSafeEqual(Buffer.from(receivedHash), Buffer.from(expectedHash))) {
    console.log('Verified Ghost webhook:', req.body);
    res.sendStatus(200);
  } else {
    res.status(401).send('Invalid signature');
  }
});

Managing Webhooks Programmatically

Beyond creation, you can manage webhooks through the Admin API:

Delete a webhook:

await fetch(`${GHOST_URL}/admin/webhooks/${WEBHOOK_ID}/`, {
  method: 'DELETE',
  headers: {
    'Authorization': `Ghost ${ADMIN_API_KEY}`
  }
});

Ghost also maintains a webhooks table component in the admin interface at apps/admin-x-settings/src/components/settings/advanced/integrations/webhooks-table.tsx for manual management.

Summary

  • Ghost webhooks use a three-tier architecture: Admin API endpoints (webhooks.js), a Bookshelf model (webhook.js), and a trigger service (webhook-trigger.js).
  • Custom webhooks require POST /admin/webhooks/ with name, event, target_url, and integration_id fields.
  • The WebhookTrigger service automatically signs payloads with HMAC-SHA256 when a secret is configured, and destroys webhooks that return HTTP 410.
  • Always verify the X-Ghost-Signature header in production to ensure payload authenticity.

Frequently Asked Questions

What events can trigger Ghost webhooks?

Ghost webhooks support any internal event emitted by the platform, including member.added, post.published, post.updated, and subscriber.added. The specific event name must match exactly what Ghost emits internally—for example, member.added fires when a new member completes signup.

How do I verify a webhook is actually from Ghost?

When creating the webhook with a secret parameter, Ghost generates an HMAC-SHA256 hash of the JSON payload concatenated with a timestamp, formatted as sha256=HASH, t=TIMESTAMP in the X-Ghost-Signature header. Your receiving service must reconstruct this hash using the shared secret and compare it using constant-time comparison to prevent timing attacks.

What happens if my webhook endpoint returns a 410 error?

Ghost automatically destroys webhooks that receive an HTTP 410 (Gone) response. The WebhookTrigger#onError method in ghost/core/core/server/services/webhooks/webhook-trigger.js handles this cleanup to prevent Ghost from repeatedly attempting delivery to defunct endpoints.

Can I create webhooks without an integration ID?

No—when using session authentication, the validator in ghost/core/core/server/api/endpoints/utils/validators/input/webhooks.js requires the integration_id field. You must create a custom integration in Ghost Admin first, then use that integration's ID when registering webhooks. This associates the webhook with your specific integration and enforces permission boundaries.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →