Contentpen
Dark mode
Webhooks

Webhooks

What are Webhooks?

Webhooks let you receive automatic notifications when events happen in Contentpen. Instead of checking back to see if your blog post is ready, we'll send a request to your server the moment it's done.

Webhooks can also be a publishing destination: instead of connecting a CMS, you can have Contentpen hand each finished article to your endpoint and publish it yourself. See Using a webhook as a publishing destination.

Supported Events

Event

When it fires

blog_post.generation_started

Article generation begins

blog_post.generation_completed

Article generation finishes successfully

blog_post.generation_failed

Article generation fails

blog_post.published

An article is published — to a CMS, via Trigger Webhook in the editor, or handed to your endpoint as an Autopilot publishing destination

blog_post.publish_failed

A publish attempt fails

blog_post.updated

A previously published article is updated via Update Post

blog_post.scheduled

An article is scheduled for a future publish date

ping

A test event you send yourself from the webhook menu

Pick the events you want per endpoint — you'll only receive the ones you've subscribed to.

How to Setup

1) Create a Webhook Endpoint

First, you'll need a URL on your server that can receive POST requests from Contentpen. This is where we'll send event notifications.

Your endpoint should:

  • Accept POST requests

  • Return a 2xx status code within 10 seconds

  • Use HTTPS (required)

2) Add the Webhook in Contentpen

Go to your Integrations page and click on Webhooks.

Click Add Webhook to create a new endpoint.

Fill in the details:

  • Endpoint URL - Your endpoint URL (must be HTTPS)

  • Description - Optional, helps you identify this webhook later

  • Events - Select which events should trigger this webhook

Click Create Webhook and you'll see your signing secret.

⚠️ Important: Copy and save your signing secret now. It will only be shown once. You'll need it to verify webhook signatures.

Done! Your webhook is now active and will start receiving events.

Verifying Webhook Signatures

Every webhook request includes a signature so you can verify it actually came from Contentpen. Always verify signatures before processing webhooks.

Signature Header

We send the signature in the X-Contentpen-Signature header:

X-Contentpen-Signature: t=1705315800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t - Unix timestamp when we signed the request

  • v1 - The HMAC SHA-256 signature

Python Verification Example

import hmac
import hashlib
import time

def verify_contentpen_signature(payload: str, signature_header: str, secret: str, tolerance: int = 300) -> bool:
    """
    Verify a Contentpen webhook signature.
    
    Args:
        payload: Raw request body as string
        signature_header: Value of X-Contentpen-Signature header
        secret: Your webhook signing secret (starts with whsec_)
        tolerance: Max age in seconds (default 5 minutes)
    
    Returns:
        True if valid, raises ValueError otherwise
    """
    # Parse the signature header
    parts = dict(part.split("=", 1) for part in signature_header.split(","))
    timestamp = int(parts["t"])
    received_sig = parts["v1"]
    
    # Reject old requests (replay protection)
    if abs(time.time() - timestamp) > tolerance:
        raise ValueError("Timestamp too old")
    
    # Compute expected signature
    signing_key = secret.replace("whsec_", "")
    expected_sig = hmac.new(
        signing_key.encode(),
        f"{timestamp}.{payload}".encode(),
        hashlib.sha256
    ).hexdigest()
    
    # Compare signatures (constant-time to prevent timing attacks)
    if not hmac.compare_digest(expected_sig, received_sig):
        raise ValueError("Invalid signature")
    
    return True

Flask Example

from flask import Flask, request, abort

app = Flask(__name__)
WEBHOOK_SECRET = "whsec_your_secret_here"

@app.route("/webhooks/contentpen", methods=["POST"])
def handle_webhook():
    payload = request.get_data(as_text=True)
    signature = request.headers.get("X-Contentpen-Signature")
    
    try:
        verify_contentpen_signature(payload, signature, WEBHOOK_SECRET)
    except ValueError as e:
        abort(401, str(e))
    
    # Process the webhook
    data = request.json
    event = data["meta"]["event_type"]
    
    if event == "blog_post.generation_completed":
        # Handle successful generation
        blog_post = data["data"]["blog_post"]
        print(f"Blog ready: {blog_post['title']}")
        
    elif event == "blog_post.generation_failed":
        # Handle failed generation
        error_message = data["data"]["error_message"]
        print(f"Generation failed: {error_message}")
        
    elif event == "blog_post.published":
        # Two different meanings — see "Using a webhook as a publishing destination"
        payload = data["data"]
        if payload.get("autopilot"):
            # Autopilot is handing you an article to publish. Return 2xx once you
            # have accepted it.
            if payload.get("page_url"):
                # A rewrite of a page you already have live — update it in place,
                # or you end up with two pages competing for the same query.
                update_existing_page(payload["page_url"], payload["blog_post"])
            else:
                create_new_post(payload["blog_post"])
        else:
            # Just a notification that something went live.
            print(f"Published: {payload.get('published_url')}")
    
    return {"received": True}

Event Payloads

Every event shares the same envelope:

{
  "version": "1",
  "is_test": false,
  "meta": {
    "event_id": "351ea43e-1878-4b08-9118-18235717e699",
    "event_type": "blog_post.generation_completed",
    "event_version": "1.0",
    "timestamp": "2025-12-18T12:50:39.272448Z",
    "workspace_id": "a491fc0c-a961-481d-8939-b2a2e02b175e",
    "organization_id": "fec10d37-8178-41f3-8e83-6aebff170d46",
    "triggered_by": "be3f0498-0381-46fb-8682-52abf1fff9b8"
  },
  "data": { "...event-specific..." }
}
  • version - Envelope version. Bumped only for a breaking change to this structure.

  • is_test - true for a ping test, so you can route test traffic away from real processing.

  • meta.event_id - Unique per event. Use X-Contentpen-Delivery-Id for deduplicating deliveries (a retry reuses the delivery id).

The blog_post object comes in two sizes. Events about content include the full article (html_content, markdown_content, meta_title, meta_description, word_count, outline, featured_image_url, featured_image_alt, secondary_keywords); events about status carry a minimal one (id, title, slug, keyword, topic, language, article_size, created_at, updated_at).

A few fields to treat defensively rather than depend on:

  • language is the label the article was written with — you'll see both "English" and "en-US" depending on how it was set up. Don't parse it as a locale code.

  • markdown_content is null unless the article was actually produced as Markdown. html_content is the field that's always there.

  • outline, topic, featured_image_url, featured_image_alt and secondary_keywords are all legitimately empty or null on plenty of articles. topic may be an empty string rather than null.

  • Timestamps in blog_post are UTC without an offset (2026-09-01T07:15:37.742201); those in meta and published_at carry a Z.

blog_post.generation_started

Sent when article generation begins. Minimal blog post.

{
  "data": {
    "blog_post": {
      "id": "cea66be7-d8a4-47fa-9f45-3d753020de64",
      "slug": "content-calendar-guide",
      "title": "How to Build a Content Calendar That Actually Ships",
      "topic": "Building a content calendar a small team can keep up with",
      "keyword": "content calendar",
      "language": "en-US",
      "article_size": "small",
      "created_at": "2025-12-18T12:41:17.372587",
      "updated_at": "2025-12-18T12:41:17.372587"
    },
    "generation_type": "one_shot",
    "estimated_duration_seconds": null
  },
  "meta": { "...": "event_type: blog_post.generation_started" }
}

blog_post.generation_completed

Sent when your blog post is successfully generated. Full blog post.

{
  "data": {
    "author": {
      "id": "be3f0498-0381-46fb-8682-52abf1fff9b8",
      "email": "user@example.com",
      "first_name": "John",
      "last_name": "Doe"
    },
    "blog_post": {
      "id": "cea66be7-d8a4-47fa-9f45-3d753020de64",
      "slug": "content-calendar-guide",
      "title": "How to Build a Content Calendar That Actually Ships",
      "topic": "Building a content calendar a small team can keep up with",
      "keyword": "content calendar",
      "outline": "<h1>Blog Outline...</h1>",
      "language": "en-US",
      "created_at": "2025-12-18T12:41:17.372587",
      "updated_at": "2025-12-18T12:50:38.220962",
      "meta_title": "How to Build a Content Calendar That Actually Ships",
      "meta_description": "Plan, write and publish a month of content without the calendar falling apart by week two.",
      "word_count": 2186,
      "article_size": "small",
      "html_content": "<h2>Introduction</h2><p>Your article content...</p>",
      "markdown_content": null,
      "featured_image_url": "https://example.com/image.jpg",
      "featured_image_alt": null,
      "secondary_keywords": null
    },
    "generation_type": "one_shot",
    "duration_seconds": null
  },
  "meta": {
    "event_id": "351ea43e-1878-4b08-9118-18235717e699",
    "event_type": "blog_post.generation_completed",
    "event_version": "1.0",
    "timestamp": "2025-12-18T12:50:39.272448Z",
    "workspace_id": "a491fc0c-a961-481d-8939-b2a2e02b175e",
    "organization_id": "fec10d37-8178-41f3-8e83-6aebff170d46",
    "triggered_by": "be3f0498-0381-46fb-8682-52abf1fff9b8"
  }
}

blog_post.generation_failed

Sent when blog post generation fails. Minimal blog post.

{
  "data": {
    "blog_post": {
      "id": "32390d6a-8a6b-4dcf-a3c0-c3875327901c",
      "slug": "...",
      "title": "...",
      "topic": "Turning one blog post into a month of social posts",
      "keyword": "content repurposing",
      "language": "en-US",
      "created_at": "2025-12-19T04:56:21.396444",
      "updated_at": "2025-12-19T04:56:51.776592",
      "article_size": "medium"
    },
    "error_code": "GENERATION_ERROR",
    "error_message": "SERP analysis failed to return results",
    "generation_type": "one_shot"
  },
  "meta": {
    "event_id": "d925551f-7ff7-4a1f-8ab9-e1e20313848b",
    "timestamp": "2025-12-19T04:56:51.941889Z",
    "event_type": "blog_post.generation_failed",
    "triggered_by": "b8b7b29b-66da-42aa-b8c5-3b0889418a09",
    "workspace_id": "b5c01989-5f4d-4f4a-a029-e8e92ed075d0",
    "event_version": "1.0",
    "organization_id": "699ddf8b-cfd7-4782-8cf7-695d8db1b01c"
  }
}

- error_message is a human-readable sentence written for a person — surface it, don't parse it. The wording can change.

- error_code is a short stable string you can branch on. Two are worth singling out because they're yours to fix: CONTENT_FILTER means the topic was refused and needs rewording, and URL_FETCH_FAILED means the source URL of a content refresh couldn't be read. Everything else — GENERATION_ERROR, RATE_LIMIT, TIMEOUT and the rest — is a transient or upstream problem on our side; retry the article from the app, and talk to us if it keeps happening. Treat unrecognised codes the same way, since new ones may be added over time.

blog_post.published

Sent when an article is published. Full blog post.

This event has two meanings, and the autopilot field tells them apart:

  • without autopilot — a notification: the article was published to a CMS, or you fired it manually with Trigger Webhook. Nothing is expected of you.

  • with autopilot — a request: your endpoint is the destination, and you are being asked to publish this article. See the section below.

{
  "data": {
    "blog_post": { "...full article, as in generation_completed..." },
    "author": {
      "id": "be3f0498-0381-46fb-8682-52abf1fff9b8",
      "email": "user@example.com",
      "first_name": "John",
      "last_name": "Doe"
    },
    "published_url": "https://yourblog.com/content-calendar-guide",
    "cms_platform": "wordpress",
    "categories": ["Content Marketing"],
    "tags": ["content calendar", "editorial planning"],
    "published_at": "2025-12-20T09:00:02.113402Z",
    "page_url": null,
    "autopilot": null
  },
  "meta": { "...": "event_type: blog_post.published" }
}
  • cms_platform - wordpress, ghost, webflow, shopify, wix, emdash, or webhook when your endpoint is the destination. null when fired manually.

  • published_url - null when we don't know it yet, which includes every webhook-destination delivery: your system is the one about to create the URL.

  • page_url - the live URL this article was rewritten from, when it was rewritten from one. Set for any refreshed article — whether the refresh came from Autopilot or from the editor's own refresh flow — and null for a brand-new article. See Create or update?

blog_post.publish_failed

Sent when a publish attempt fails. Minimal blog post.

{
  "data": {
    "blog_post": { "...minimal article..." },
    "cms_platform": "wordpress",
    "error_code": "PUBLISH_ERROR",
    "error_message": "401 Unauthorized: application password rejected"
  },
  "meta": { "...": "event_type: blog_post.publish_failed" }
}


error_code is always PUBLISH_ERROR here — the CMS-specific detail is in error_message (usually the status code and body the CMS returned).

blog_post.updated

Sent when a previously published article is re-pushed with Update Post. Full blog post.

{
  "data": {
    "blog_post": { "...full article..." },
    "updated_by": {
      "id": "be3f0498-0381-46fb-8682-52abf1fff9b8",
      "email": "user@example.com",
      "first_name": "John",
      "last_name": "Doe"
    },
    "updated_fields": ["content"],
    "page_url": "https://yourblog.com/on-page-seo-checklist"
  },
  "meta": { "...": "event_type: blog_post.updated" }
}

page_url is set here whenever the article was a refresh of a live page — which is the common case for this event, since Update Post is how a refreshed article reaches a CMS that already holds the original.

blog_post.scheduled

Sent when an article is scheduled for a future publish date. Minimal blog post.

{
  "data": {
    "blog_post": { "...minimal article..." },
    "author": {
      "id": "be3f0498-0381-46fb-8682-52abf1fff9b8",
      "email": "user@example.com",
      "first_name": "John",
      "last_name": "Doe"
    },
    "scheduled_at": "2025-12-24T00:00:00Z"
  },
  "meta": { "...": "event_type: blog_post.scheduled" }
}

Using a Webhook as a Publishing Destination

Autopilot normally publishes to a connected CMS. If your site isn't one we integrate with — a headless site, a static build, your own editorial queue — you can point Autopilot at a webhook endpoint instead, and publish the article yourself.

How it differs from every other event: this delivery is the publish. We are not telling you something happened; we are handing you an article and asking you to put it live. Your HTTP response decides what Contentpen records.

Setting it up

  1. Create a webhook endpoint as above, and make sure Blog Post Published is one of its selected events. An endpoint without it still appears in the destination list, greyed out and labelled Unavailable — hover it to see what's missing — but it can't be chosen until you add the event.

  2. Open Autopilot → Settings → Where it publishes, and pick your webhook from the list. Your webhooks appear under a Webhooks heading, alongside your connected sites.

  3. That's it. Autopilot writes on its normal cadence, and on each article's publish date we POST it to your endpoint.

⚠️ Keep the event subscribed. The Webhooks page doesn't know it's being used as a destination, so if you later uncheck Blog Post Published on that endpoint, publishing stops working — you'll see the article marked as a failed publish, with the reason. Re-check the event to fix it.

What you receive

A normal blog_post.published event with the full article, cms_platform: "webhook", and an autopilot block:

{
  "version": "1",
  "is_test": false,
  "meta": {
    "event_id": "9f2b1c44-6d0e-4a63-9a0e-2c9f1b7d5a31",
    "event_type": "blog_post.published",
    "event_version": "1.0",
    "timestamp": "2026-09-04T00:00:07.482913Z",
    "workspace_id": "a491fc0c-a961-481d-8939-b2a2e02b175e",
    "organization_id": "fec10d37-8178-41f3-8e83-6aebff170d46",
    "triggered_by": "be3f0498-0381-46fb-8682-52abf1fff9b8"
  },
  "data": {
    "blog_post": {
      "id": "cea66be7-d8a4-47fa-9f45-3d753020de64",
      "slug": "content-calendar-guide",
      "title": "How to Build a Content Calendar That Actually Ships",
      "topic": "Building a content calendar a small team can keep up with",
      "keyword": "content calendar",
      "language": "en-US",
      "article_size": "small",
      "created_at": "2026-09-02T12:41:17.372587",
      "updated_at": "2026-09-03T12:50:38.220962",
      "meta_title": "How to Build a Content Calendar That Actually Ships",
      "meta_description": "Plan, write and publish a month of content without the calendar falling apart by week two.",
      "word_count": 2186,
      "outline": "<h1>Blog Outline...</h1>",
      "html_content": "<h2>Introduction</h2><p>Your article content...</p>",
      "markdown_content": null,
      "featured_image_url": "https://example.com/image.jpg",
      "featured_image_alt": null,
      "secondary_keywords": null
    },
    "author": {
      "id": "be3f0498-0381-46fb-8682-52abf1fff9b8",
      "email": "user@example.com",
      "first_name": "John",
      "last_name": "Doe"
    },
    "published_url": null,
    "cms_platform": "webhook",
    "categories": null,
    "tags": null,
    "published_at": "2026-09-04T00:00:07.482913Z",
    "page_url": null,
    "autopilot": {
      "campaign_id": "7d1c2f90-4b3a-4c8e-9f21-8a6b0d4e5c77",
      "queue_item_id": "b2a5e6d1-3c47-4f8b-9d20-1e7c5a9f4b60",
      "source": "competitor_gap",
      "action": "generate",
      "scheduled_for": "2026-09-04T00:00:00Z",
      "delivery_is_the_publish": true
    }
  }
}

Field

Meaning

autopilot.campaign_id

The Autopilot setup that produced this article

autopilot.queue_item_id

The Autopilot topic it was written for

autopilot.source

Why Autopilot picked the topic, e.g. competitor_gap, new_article, striking_distance

autopilot.action

generate for a brand-new article, refresh for a rewrite of a page you already have live

autopilot.scheduled_for

The date this article was scheduled to go live

autopilot.delivery_is_the_publish

Always true. A marker that you are expected to act, not just observe

Create or update? Read page_url

page_url sits on data, beside published_url — not inside autopilot — because a refresh isn't an Autopilot-only idea. Refresh an article yourself from the editor and publish it, and the same field tells you the same thing.

Autopilot does two different jobs, and roughly half of what it sends you is the second one:

  • page_url is null → a brand-new article. Create a post.

  • page_url is set → this article is a rewritten version of the page already live at that URL, because Autopilot found it was underperforming. Update that page in place. Creating a new post instead leaves two pages competing for the same query — the exact problem the rewrite was meant to fix.

{
  "data": {
    "blog_post": {
      "slug": "on-page-seo-checklist",
      "title": "On-Page SEO Checklist: 12 Checks That Still Matter in 2026",
      "html_content": "<p>…the rewritten article…</p>"
    },
    "cms_platform": "webhook",
    "page_url": "https://yourblog.com/on-page-seo-checklist",
    "autopilot": {
      "action": "refresh",
      "source": "striking_distance",
      "scheduled_for": "2026-09-07T00:00:00Z",
      "delivery_is_the_publish": true
    }
  }
}

blog_post.slug is usually the existing page's slug too, but match on page_url — it's the field that is guaranteed to identify the page, and a slug can be rewritten.

How to respond

  • Return 2xx once you've accepted the article — after you've queued it, at minimum. Contentpen marks the article Published and frees the Autopilot slot.

  • Return anything else, or time out, and we retry: 5 attempts in total, backing off roughly 1m → 5m → 15m → 1h. If all five fail, the article is marked Publish failed with the response we got, you're emailed that a scheduled article missed its slot, and after three consecutive failures Autopilot pauses publishing rather than keep failing.

💡 Respond fast, work later. Queue the article and return 2xx within 10 seconds. Rebuilding a static site inside the request is how you end up with a timeout on an article you actually published — and a retry that publishes it twice.

Use X-Contentpen-Delivery-Id to deduplicate: retries of the same publish reuse the same delivery id, so storing it is what makes a double-publish impossible.

HTTP Headers

Every webhook request includes these headers:

Header

Description

Content-Type

application/json

User-Agent

ContentPen-Webhook/1.0

X-Contentpen-Event

Event type (e.g., blog_post.generation_completed)

X-Contentpen-Delivery-Id

Unique delivery ID (use for deduplication; stable across retries)

X-Contentpen-Signature

HMAC signature for verification

X-Contentpen-Timestamp

Unix timestamp

Retries

Requests time out after 30 seconds. Whether a failed delivery is retried depends on what it was:

Retried automatically — 5 attempts in total:

  • the generation events (generation_started, generation_completed, generation_failed)

  • an Autopilot webhook-destination publish, because that delivery is the publish

Attempt

Delay before it

1st retry

~1 minute

2nd retry

~5 minutes

3rd retry

~15 minutes

4th retry

~1 hour

After the 5th attempt the delivery is marked failed — and for a webhook destination, so is the article's publish.

Sent once, not retried: the notifications fired at the moment you act — blog_post.published from a CMS publish or a manual Trigger Webhook, publish_failed, updated, scheduled — and ping tests. If one of those fails, retry it by hand from Delivery Logs.

Every attempt reuses the same X-Contentpen-Delivery-Id, so deduplicating on it is safe.

Testing Your Webhook

You can send a test event to verify your endpoint is working.

From the webhook endpoint menu, click the menu on your webhook endpoint and click Ping test.

This sends a ping event to your endpoint:

{
  "version": "1",
  "is_test": true,
  "event": "ping",
  "created_at": "2025-12-19 06:17:31.713981+00:00",
  "data": {
    "message": "This is a test webhook from ContentPen",
    "workspace_id": "b5c01989-5f4d-4f4a-a029-e8e92ed075d0",
    "endpoint_id": "cc4b2837-f216-41e3-aece-a35b2f6987ca",
    "article_id": "00000000-0000-0000-0000-000000000000"
  }
}

ℹ️ The ping is deliberately shaped differently from a real event — it has a top-level event instead of a meta block, and is_test: true. Don't build your handler around it; branch on X-Contentpen-Event or on meta.event_type.

Viewing Delivery Logs

You can see all webhook deliveries and debug any failures from within the app.

Click View logs from the same webhook endpoint menu to see delivery history.

Click View to see the full request payload and response. A failed delivery can be retried by hand from here, which is also how you re-drive a webhook-destination publish once you've fixed your endpoint.

Regenerating Your Secret

If your secret is compromised, you can regenerate it from the webhook settings.

⚠️ Note: Your old secret stops working immediately. Update your server with the new secret right away.

Best Practices

  1. Always verify signatures - Never process unverified webhooks

  2. Respond quickly - Return a 2xx response within 10 seconds, and do the heavy work after

  3. Process async - Queue heavy processing, respond immediately

  4. Handle duplicates - Use the X-Contentpen-Delivery-Id header to deduplicate

  5. Ignore what you don't know - We add fields to payloads and events to the list; treat unknown ones as harmless

  6. Store secrets securely - Use environment variables, not hardcoded values


Was this article helpful?