How to setup Webhooks integration with Contentpen
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.
Currently supported events:
- Blog post generated - When your article is successfully created
- Blog post failed - When something goes wrong during generation
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 requestv1- 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}")
return {"received": True}
Event Payloads
blog_post.generation_completed
Sent when your blog post is successfully generated.
Sample Payload
{
"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": "islamic-car-financing-pakistan-guide",
"title": "Islamic Car Financing in Pakistan: A Complete Guide",
"topic": "Getting Islamic Car Financing in Pakistan",
"keyword": "Islamic Car Financing",
"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": "Islamic Car Financing in Pakistan: A Complete Guide",
"meta_description": "Islamic car financing explained for Pakistan...",
"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.
Sample Payload
{
"data": {
"blog_post": {
"id": "32390d6a-8a6b-4dcf-a3c0-c3875327901c",
"slug": "...",
"title": "...",
"topic": "Impacts of AI on Everyday Life",
"keyword": "AI",
"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"
}
}
HTTP Headers
Every webhook request includes these headers:
Header | Description |
|---|---|
| |
| |
| Event type (e.g., |
| Unique delivery ID (use for deduplication) |
| HMAC signature for verification |
| Unix timestamp |
Retries
If your endpoint returns an error (non-2xx status) or times out, we'll retry the delivery:
Attempt | Delay |
|---|---|
1st retry | 1 minute |
2nd retry | 5 minutes |
3rd retry | 30 minutes |
4th retry | 2 hours |
After 4 failed retries, the delivery is marked as failed.
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:
{
"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"
}
}
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.
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
- Always verify signatures - Never process unverified webhooks
- Respond quickly - Return a 2xx response within 10 seconds
- Process async - Queue heavy processing, respond immediately
- Handle duplicates - Use
X-Contentpen-Deliveryheader to deduplicate - Store secrets securely - Use environment variables, not hardcoded values
Need Help?
If you're having trouble setting up webhooks, check the delivery logs first to see what's happening. If you're still stuck, reach out to our support team.
Updated on: 30/12/2025
Thank you!