Skip to content

Webhook Integration

Connect any custom CMS to BlogShoot using standard webhooks. Receive AI-generated articles automatically through secure HTTP callbacks.

Webhook integration allows you to receive BlogShoot articles in any custom CMS or platform that can handle HTTP POST requests. Instead of manually copying content, BlogShoot automatically pushes articles to your webhook endpoint when they’re ready.

BlogShoot generates article → You select Webhook push →
BlogShoot sends HTTP POST → Your CMS receives and processes
  • Standard HTTP webhooks - Works with any platform that accepts POST requests
  • Secure delivery - HMAC-SHA256 signature verification prevents forgery
  • Automatic retries - Failed deliveries retry up to 3 times automatically
  • Custom headers - Add authentication tokens or API keys
  • Delivery history - Track all webhook deliveries and troubleshoot issues

Before setting up webhook integration, you’ll need:

  • A publicly accessible HTTPS endpoint (webhook URL)
  • Ability to receive and process HTTP POST requests
  • Basic understanding of JSON and API authentication
  • Development resources to implement the webhook receiver

First, create an endpoint in your CMS that can receive HTTP POST requests. This endpoint should:

  • Accept POST requests with JSON payload
  • Return 2xx status code (200-299) on success
  • Process requests quickly (respond within 10 seconds)
  • Verify webhook signatures (see security section)
  1. Navigate to IntegrationsWebhook
  2. Click “Add Webhook Connection”
  3. Fill in connection details:
Connection Name: My Custom CMS
Webhook URL: https://your-cms.com/webhooks/blogshoot
Webhook Secret: [generate a strong random string]
  1. (Optional) Add custom HTTP headers:

    • Click “Add Custom Header”
    • Example: Authorization: Bearer your-api-token
  2. Click “Test Connection”

    • ✅ Success: Connection verified
    • ❌ Failed: Check troubleshooting section
  3. Click “Save Connection”

Configure which events trigger webhooks:

  • article.created - New article published
  • article.updated - Existing article modified
  • article.deleted - Article removed

You can also set:

  • Timeout - Maximum wait time for response (default: 10 seconds)
  • Custom Headers - Additional authentication or metadata

BlogShoot sends a structured JSON payload containing all article data:

{
"event_id": "unique-uuid-v4",
"event_type": "article.created",
"timestamp": "2026-06-06T10:30:00Z",
"workspace": {
"id": "workspace_id",
"name": "My Company",
"website_url": "https://my-company.com",
"default_language": "English",
"default_market": "United States"
},
"article": {
"id": "blogshoot_article_id",
"title": "Your Article Title",
"slug": "article-url-slug",
"language": "en",
"language_name": "English",
"target_market": "United States",
"content": {
"html": "<p>Full HTML content...</p>",
"markdown": "# Article Title\n\nContent...",
"plain_text": "Plain text version"
},
"excerpt": "Brief article summary",
"featured_image": {
"url": "https://cdn.blogshoot.com/image.jpg",
"alt": "Image description",
"width": 1200,
"height": 630
},
"images": [
{
"url": "https://...",
"alt": "Image description",
"position": 1
}
],
"seo": {
"title": "SEO-optimized title",
"description": "Meta description",
"keywords": ["keyword1", "keyword2"]
},
"categories": ["Category1", "Category2"],
"tags": ["tag1", "tag2"],
"author": {
"name": "Author Name",
"email": "[email protected]"
},
"status": "publish",
"published_at": "2026-06-06T10:30:00Z",
"created_at": "2026-06-06T10:00:00Z",
"updated_at": "2026-06-06T10:30:00Z"
},
"metadata": {
"source": "blogshoot",
"version": "1.1"
}
}

Each webhook request includes these headers:

Content-Type: application/json
User-Agent: BlogShoot-Webhook/1.0
X-BlogShoot-Signature: [hmac-sha256-hex-signature]
X-BlogShoot-Event: article.created
X-BlogShoot-Delivery-ID: [unique-uuid]

Always verify webhook signatures to ensure requests come from BlogShoot:

<?php
// Read raw request body
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_BLOGSHOOT_SIGNATURE'] ?? '';
// Your webhook secret from BlogShoot dashboard
$webhook_secret = 'your-webhook-secret-from-blogshoot';
// Calculate expected signature
$expected_signature = hash_hmac('sha256', $payload, $webhook_secret);
// Verify signature
if (!hash_equals($expected_signature, $signature)) {
http_response_code(401);
die(json_encode(['error' => 'Invalid signature']));
}
// Signature verified - safe to process
$data = json_decode($payload, true);
processWebhook($data);
?>
const crypto = require('crypto');
app.post('/webhook/blogshoot', (req, res) => {
// Get signature from header
const signature = req.headers['x-blogshoot-signature'];
const webhookSecret = process.env.BLOGSHOOT_WEBHOOK_SECRET;
// Calculate expected signature from raw body
const expectedSignature = crypto
.createHmac('sha256', webhookSecret)
.update(req.rawBody) // Must use raw body!
.digest('hex');
// Verify signature
if (signature !== expectedSignature) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Process webhook
processWebhook(req.body);
res.status(200).json({ success: true });
});

Handle different event types:

<?php
$data = json_decode($payload, true);
switch ($data['event_type']) {
case 'article.created':
createPost($data['article']);
break;
case 'article.updated':
updatePost($data['article']);
break;
case 'article.deleted':
deletePost($data['article']['id']);
break;
case 'test':
// Test connection - just return success
break;
}
// Return 200 OK
http_response_code(200);
echo json_encode([
'success' => true,
'event_id' => $data['event_id']
]);
?>

If your CMS has multiple language versions (e.g. /en/, /zh/, /ar/ subtrees, or separate language collections), BlogShoot’s webhook payload tells you exactly which language tree each article belongs to.

1. You discover keywords with target_market="China", content_language="Chinese (Simplified)"
→ Each keyword in the result list is tagged with language + target_market
2. You generate an article from that keyword (UI, /open/articles/generate, or Planner)
→ Article inherits the keyword's language/market (or you can override explicitly)
3. Worker produces a Chinese article using Chinese search context and Chinese output language
4. Worker auto-fires webhook → payload.article.language = "zh-CN"
5. Your receiver routes to /zh/ tree (or your equivalent)

article.language is always a BCP 47 code (ISO 639-1 with optional region). This is what WPML, Polylang, Astro i18n, and most CMS plugins consume directly.

BlogShoot language_namearticle.languageNotes
EnglishenDefault
Chinese (Simplified)zh-CNMainland China; WPML uses zh-hans
Chinese (Traditional)zh-TWHong Kong / Taiwan; WPML uses zh-hant
Japaneseja
Koreanko
Spanishes
Frenchfr
Germande
Italianit
Portuguesept
Dutchnl
ArabicarRTL — set dir="rtl" on rendered HTML
Hindihi
Russianru
Turkishtr
Vietnamesevi
Thaith
Indonesianid
Polishpl

Anything not in the table falls back to "en" so you never receive a null/missing language field.

app.post('/api/webhooks/blogshoot', async (req, res) => {
// 1. Always verify signature first (see Security section)
if (!verifySignature(req)) return res.status(401).end();
const { article, workspace } = req.body;
// 2. Route by article.language — this is your single source of truth
const language = article.language || 'en';
const market = article.target_market || workspace.default_market;
// 3. Direct to language-specific storage / collection / language tree
switch (language) {
case 'zh-CN':
await saveToCollection('articles_zh', { ...article, locale: 'zh-CN' });
break;
case 'ar':
// Right-to-left language — receiver may want to set dir="rtl"
await saveToCollection('articles_ar', { ...article, locale: 'ar', dir: 'rtl' });
break;
case 'ru':
await saveToCollection('articles_ru', { ...article, locale: 'ru' });
break;
case 'en':
default:
await saveToCollection('articles_en', { ...article, locale: 'en' });
}
res.status(200).json({ received: true, routed_to: language });
});

WPML uses its own internal language codes (zh-hans, pt-pt, etc.) — map BCP 47 to WPML codes when attaching a post to a language:

add_action('rest_api_init', function () {
register_rest_route('blogshoot/v1', '/webhook', [
'methods' => 'POST',
'callback' => function ($request) {
$data = $request->get_json_params();
$article = $data['article'];
// Create the post (in your default language first)
$post_id = wp_insert_post([
'post_title' => $article['title'],
'post_content' => $article['content']['html'],
'post_status' => 'publish',
'post_name' => $article['slug'], // slug is always ASCII / pinyin — safe for URLs
]);
// Map BCP 47 → WPML language code
$wpml_lang_map = [
'en' => 'en',
'zh-CN' => 'zh-hans',
'zh-TW' => 'zh-hant',
'ar' => 'ar',
'ru' => 'ru',
'ja' => 'ja',
'ko' => 'ko',
// ... add others as needed
];
$wpml_lang = $wpml_lang_map[$article['language']] ?? 'en';
// Tell WPML which language tree this post belongs to
do_action('wpml_set_element_language_details', [
'element_id' => $post_id,
'element_type' => 'post_post',
'trid' => null, // null = new translation group
'language_code' => $wpml_lang,
'source_language_code' => null, // null = original, not a translation
]);
return ['post_id' => $post_id, 'language' => $wpml_lang];
},
'permission_callback' => '__return_true',
]);
});

Polylang uses simpler codes (mostly en, zh_cn, ar — note underscore for region):

$polylang_lang_map = [
'en' => 'en',
'zh-CN' => 'zh_cn',
'zh-TW' => 'zh_tw',
'ar' => 'ar',
// ...
];
$polylang_lang = $polylang_lang_map[$article['language']] ?? 'en';
pll_set_post_language($post_id, $polylang_lang);

Why is the slug always ASCII / pinyin even for Chinese articles?

Section titled “Why is the slug always ASCII / pinyin even for Chinese articles?”

article.slug is intentionally kept ASCII/English/pinyin form even when the article content is Chinese (e.g. foreign-trade-cms-guide-2026). This ensures:

  • URLs work across all CMS plugins (some choke on CJK characters in URLs)
  • Slugs are short and stable for sharing
  • No URL-encoding surprises (%E4%B8%AD%E6%96%87... is ugly and breaks copy-paste)

If you want CJK in your URLs, transform the slug yourself on the receiver side — but you’ll lose copy-paste safety.

Asking BlogShoot to discover keywords in a specific language

Section titled “Asking BlogShoot to discover keywords in a specific language”

For automation customers, trigger language-specific discovery via Open API:

Terminal window
# Discover Chinese keywords (returns Chinese keyword phrases with zh-CN SERP data)
curl -X POST https://api.blogshoot.com/open/keywords/discover \
-H "X-BlogShoot-Key: bsk_..." \
-H "X-Workspace-Id: <your_workspace_id>" \
-d '{
"target_market": "China",
"content_language": "Chinese (Simplified)"
}'
# Same workspace, also discover English keywords
curl -X POST https://api.blogshoot.com/open/keywords/discover \
-H "X-BlogShoot-Key: bsk_..." \
-H "X-Workspace-Id: <your_workspace_id>" \
-d '{
"target_market": "United States",
"content_language": "English"
}'
# List only the Chinese ones
curl "https://api.blogshoot.com/open/keywords/list?language=Chinese%20%28Simplified%29&target_market=China" \
-H "X-BlogShoot-Key: bsk_..."

When you trigger article generation from a Chinese keyword, the resulting article automatically inherits language="Chinese (Simplified)" and the webhook payload arrives with article.language="zh-CN".

Articles include a featured image and additional images. You can:

  1. Store URLs directly - Reference BlogShoot’s CDN
  2. Download and host - Upload images to your own storage
function downloadImage($url, $alt = '') {
$image_data = file_get_contents($url);
$filename = basename(parse_url($url, PHP_URL_PATH));
$filepath = '/uploads/' . $filename;
file_put_contents($filepath, $image_data);
return [
'path' => $filepath,
'alt' => $alt
];
}
// Process featured image
if (!empty($article['featured_image']['url'])) {
$image = downloadImage(
$article['featured_image']['url'],
$article['featured_image']['alt']
);
setFeaturedImage($post_id, $image);
}

If webhook delivery fails, BlogShoot automatically retries:

AttemptDelayTotal Time
1st failure5 seconds5s
2nd failure30 seconds35s
3rd failure5 minutes5m 35s
After 3 failuresManual retry needed-

Delivery is considered successful when:

  • HTTP status code is 200-299
  • Response received within timeout (default: 10 seconds)

For permanently failed deliveries:

  1. Go to IntegrationsWebhook
  2. Click “View Delivery History”
  3. Find the failed delivery
  4. Click “Retry”

Track all webhook deliveries:

  1. Navigate to IntegrationsWebhook
  2. Select your connection
  3. Click “Delivery History”

You’ll see:

  • Event type and timestamp
  • Delivery status (Success/Failed)
  • HTTP status code
  • Response time
  • Error messages (if failed)
  • Retry count
  • Success ✅ - Delivered successfully
  • Pending ⏳ - Scheduled for retry
  • Failed ❌ - Permanent failure (needs manual retry)
  1. Respond quickly - Process webhook asynchronously if possible:

    // Respond immediately
    http_response_code(200);
    echo json_encode(['success' => true]);
    fastcgi_finish_request(); // PHP-FPM only
    // Then process in background
    processArticleInBackground($data);
  2. Set appropriate timeout - Match your CMS processing speed:

    • Simple insert: 5-10 seconds
    • Complex processing: 15-30 seconds
    • Image downloads: 20-60 seconds
  1. Always verify signatures - Never trust unsigned webhooks
  2. Use HTTPS - Encrypt data in transit
  3. Store secrets securely - Use environment variables
  4. Rotate secrets periodically - Every 6-12 months
  5. Add custom auth headers - Additional authentication layer
  1. Implement idempotency - Use event_id to prevent duplicates:

    $event_id = $data['event_id'];
    if (eventAlreadyProcessed($event_id)) {
    http_response_code(200);
    die(json_encode(['success' => true, 'duplicate' => true]));
    }
    markEventProcessed($event_id);
  2. Log all webhooks - Keep audit trail for debugging

  3. Handle errors gracefully - Return 5xx for temporary failures

  4. Monitor delivery history - Set up alerts for failures

  1. Click “Test Connection” in BlogShoot dashboard
  2. BlogShoot sends a test event: { "event_type": "test", ... }
  3. Your endpoint should:
    • Verify signature
    • Return 200 OK
    • Log the test event

Before connecting to BlogShoot, test your endpoint with:

  • Webhook.site - Inspect incoming requests
  • RequestBin - Capture and debug webhooks
  • Postman - Send test POST requests manually
Terminal window
curl -X POST https://your-cms.com/webhooks/blogshoot \
-H "Content-Type: application/json" \
-H "X-BlogShoot-Signature: [calculated-signature]" \
-H "X-BlogShoot-Event: test" \
-d '{
"event_type": "test",
"event_id": "test-123",
"timestamp": "2025-12-04T10:30:00Z"
}'

Possible causes:

  1. Endpoint not accessible

    • Verify URL is publicly accessible
    • Check firewall rules
    • Ensure HTTPS is configured
  2. Signature verification failed

    • Check webhook secret matches
    • Verify you’re using raw request body
    • Check for encoding issues
  3. Timeout

    • Increase timeout setting
    • Optimize endpoint response time
    • Implement async processing
  4. Invalid response

    • Ensure 200-299 status code
    • Return valid JSON
    • Check for error handling

Troubleshooting steps:

  1. Check delivery history for error messages
  2. Verify endpoint logs - Is request received?
  3. Test signature - Calculate locally and compare
  4. Check response time - Too slow? Optimize or increase timeout
  5. Review error logs - Server errors, database issues?
  1. Check image URLs are accessible
  2. Verify sufficient storage space
  3. Check file permissions on upload directory
  4. Ensure firewall allows outbound HTTPS

Implement idempotency using event_id:

// Check if already processed
$event_id = $data['event_id'];
if (eventExists($event_id)) {
return ['success' => true, 'duplicate' => true];
}
// Process and mark as handled
processArticle($data['article']);
saveEventId($event_id);

Add custom headers for additional security:

Authorization: Bearer your-api-token
X-API-Key: your-api-key
X-Custom-Header: custom-value

Access in your endpoint:

$api_key = $_SERVER['HTTP_X_API_KEY'] ?? '';
if ($api_key !== 'your-api-key') {
http_response_code(401);
die('Unauthorized');
}

Currently, all article data is sent. If you need specific fields only, process the payload and extract what you need:

function processArticle(article) {
// Extract only what you need
return {
title: article.title,
content: article.content.html,
excerpt: article.excerpt,
featured_image: article.featured_image?.url,
categories: article.categories,
tags: article.tags
};
}

You can create multiple webhook connections:

  • Development - Test endpoint for staging
  • Production - Live CMS endpoint
  • Backup - Secondary CMS or archive system

Each connection has independent:

  • URL and secret
  • Event configuration
  • Custom headers
  • Delivery history
  • Never commit secrets to version control
  • Use environment variables or secret management systems
  • Rotate secrets regularly every 6-12 months
  • Use different secrets for dev/staging/production

For additional security, whitelist BlogShoot’s IP addresses:

Contact [email protected] for current IP ranges

BlogShoot respects reasonable rate limits:

  • Maximum 5 webhooks per second per connection
  • Exponential backoff on errors
  • Automatic retry with delays

If you need higher limits, contact support.