Webhook Integration
Connect Forest SEO to any platform using custom webhooks with RS256 signature verification for maximum security and flexibility.
Connect Forest SEO to any platform or custom system using webhooks. Perfect for developers who need full control over content delivery, custom CMS platforms, or complex multi-system workflows.
✅ Prerequisites
Before setting up a webhook, you need:
| Requirement | Details |
|---|---|
| Endpoint URL | Public HTTPS endpoint to receive POST requests |
| Authentication | Optional custom headers for your authentication |
| Request Handler | Code to process webhook payload |
| TLS/SSL | Valid HTTPS certificate (required) |
| Server Uptime | Reliable endpoint with >99% availability |
🔒 Security Note
Webhooks must use HTTPS. HTTP endpoints are rejected for security.
🚀 Quick Setup
Step 1: Create Webhook Endpoint
Example in Node.js/Express:
const express = require('express');
const app = express();
// IMPORTANT: Use raw body parser to preserve exact bytes for signature verification
app.use('/webhooks/forest-seo', express.raw({ type: 'application/json' }));
app.post('/webhooks/forest-seo', async (req, res) => {
try {
// 1. Verify signature (see Security section)
const signature = req.headers['x-forestseo-signature'];
if (signature && !await verifySignature(req.body, signature)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// 2. Parse content (now safe after verification)
const payload = JSON.parse(req.body);
const { id, event, version, timestamp, object } = payload;
// 3. Save to your database/CMS
if (event === 'content.publication.published') {
await saveContent(object);
}
// 4. Return success
res.json({
success: true,
message: 'Content published',
id: 'your-content-id',
});
} catch (error) {
console.error('Webhook error:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
app.listen(3000);
Step 2: Configure in Forest SEO
In Dashboard:
- Go to Settings → Integrations
- Click "Add Integration"
- Select "Webhook"
- Fill in details:
| Field | Value | Example |
|---|---|---|
| Name | Integration identifier | "Custom API Webhook" |
| Endpoint URL | Your webhook URL | https://api.example.com/webhooks/forest-seo |
| Version | Payload format | 1.1.0 (default) |
| Extra Headers | Custom auth headers | See Authentication section |
- Click "Test Connection"
- Verify you receive test payload
- Click "Save Integration"
📦 Webhook Payload
Payload Structure (v1.1.0)
{
"id": "dlv_abc123def456",
"version": "1.1.0",
"event": "content.publication.published",
"timestamp": "2025-01-15T10:30:00.000Z",
"project_id": "550e8400-e29b-41d4-a716-446655440000",
"object": {
// Event-specific data (see below)
}
}
Top-Level Fields:
| Field | Type | Description |
|---|---|---|
| id | string | Unique delivery ID (e.g., dlv_abc123def456) |
| version | string | Webhook API version (e.g., 1.1.0) |
| event | string | Event type (see Event Types below) |
| timestamp | ISO 8601 | When webhook was triggered |
| project_id | UUID | Forest SEO project identifier |
| object | object | Event-specific payload |
Event Types
| Event | Description | object Content |
|---|---|---|
| test | Test connection | Empty {} |
| content.publication.published | Content was published | Full content data |
| content.publication.unpublished | Content was unpublished | Content reference |
Content Publication Payload
When event is content.publication.published, the object contains:
{
"id": "c1b051c3-095a-47d5-a832-d3d803c36181",
"slug": "dubai-residence-permit-tax-free-life-investment-visa-guide",
"locale": "en",
"title": "Dubai Residence Permit: Tax-Free Life & Investment Visa Guide",
"excerpt": "Explore Dubai's residency permits: tax-free income, full ownership...",
"markdown": "Dubai's residency system feels like a golden ticket...\n\n## Key Benefits\n\n- **Tax-free income**...",
"html": "<p class=\"leading-7\">Dubai's residency system...</p><h2>Key Benefits</h2>...",
"lexical": {
"root": {
"type": "root",
"children": [
{
"type": "paragraph",
"children": [{"type": "text", "text": "..."}]
}
]
}
},
"categories": ["Travel & Lifestyle", "Immigration"],
"tags": ["Dubai residency", "UAE visa", "tax-free living"],
"meta": {
"title": "Dubai Residence Permit: Tax-Free Life & Investment Visa Guide",
"description": "Explore Dubai's residency permits: tax-free income..."
}
}
Object Fields:
| Field | Type | Description |
|---|---|---|
id | string | Content publication UUID |
slug | string | URL-friendly slug for the content |
locale | string | Content language code (e.g., "en", "ru", "es") |
title | string | Content title |
excerpt | string | Short summary/description of the content |
markdown | string | Full content in Markdown format |
html | string | Full content rendered as HTML with Lexical editor classes (Tailwind CSS) |
lexical | object | Lexical editor state as JSON (full editor structure) |
categories | string[] | Content categories |
tags | string[] | Content keywords/tags |
meta.title | string | SEO meta title (for <title> tag) |
meta.description | string | SEO meta description (for meta tag) |
🔐 Security: Signature Verification
Forest SEO signs all webhook requests using RS256 (RSA SHA-256) asymmetric cryptography.
Signature Headers
Each webhook request includes:
| Header | Description |
|---|---|
X-ForestSEO-Signature | Base64-encoded RSA signature of request body |
X-ForestSEO-Algorithm | Signing algorithm (always RS256) |
X-ForestSEO-KeyId | Key ID (kid) to identify which public key to use |
X-ForestSEO-Webhook-Version | Webhook API version (e.g., 1.1.0) |
Public Key Endpoint
Forest SEO exposes public keys for signature verification via a JWK Set endpoint:
GET https://api.forestseo.com/v1/auth/keys
Response (JWK Set format - RFC 7517):
{
"keys": [
{
"kty": "RSA",
"n": "0vx7agoebGcQ...",
"e": "AQAB",
"alg": "RS256",
"kid": "a1b2c3d4e5f6",
"use": "sig"
}
]
}
💡 Tip
Cache the JWK Set for 10 minutes to balance freshness with performance.
Verification Process
Steps:
- Read raw request body (do NOT parse/modify before verification)
- Fetch public keys from JWK endpoint (cache for 10 min)
- Find matching key using
X-ForestSEO-KeyIdheader - Verify RS256 signature against raw body bytes
Implementation Examples
Node.js:
const crypto = require('crypto');
async function verifyWebhook(requestBody, headers) {
// 1. Fetch public keys (cache this in production!)
const jwkSet = await fetch('https://api.forestseo.com/v1/auth/keys')
.then(res => res.json());
// 2. Find matching key
const keyId = headers['x-forestseo-keyid'];
const publicKeyJwk = jwkSet.keys.find(k => k.kid === keyId);
if (!publicKeyJwk) {
throw new Error(`Key ${keyId} not found`);
}
// 3. Convert JWK to public key
const publicKey = crypto.createPublicKey({
key: publicKeyJwk,
format: 'jwk'
});
// 4. Verify signature against raw body bytes
const signature = Buffer.from(headers['x-forestseo-signature'], 'base64');
return crypto.verify(
'sha256',
requestBody, // Raw bytes - no transformation!
{
key: publicKey,
padding: crypto.constants.RSA_PKCS1_PADDING,
},
signature
);
}
Python:
import base64
import json
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
import requests
def verify_webhook(request_body: bytes, headers: dict) -> bool:
# 1. Fetch public keys (cache this!)
jwk_response = requests.get("https://api.forestseo.com/v1/auth/keys")
jwk_set = jwk_response.json()
# 2. Find matching key
key_id = headers.get("X-ForestSEO-KeyId")
public_key_jwk = None
for key in jwk_set["keys"]:
if key["kid"] == key_id:
public_key_jwk = key
break
if not public_key_jwk:
raise ValueError(f"Key {key_id} not found")
# 3. Convert JWK to public key
from jwt.algorithms import RSAAlgorithm
public_key = RSAAlgorithm.from_jwk(json.dumps(public_key_jwk))
# 4. Verify signature
signature = base64.b64decode(headers["X-ForestSEO-Signature"])
try:
public_key.verify(
signature,
request_body, # Raw bytes!
padding.PKCS1v15(),
hashes.SHA256(),
)
return True
except Exception:
return False
🔧 Configuration Options
Version Selection
| Version | Description | Payload Changes |
|---|---|---|
| 1.1.0 | Current (recommended) | Structured event format |
| 1.0.0 | Legacy | Simple format (deprecated) |
Set in Integration:
{
"version": "1.1.0"
}
Custom Headers
Add custom authentication or metadata headers:
{
"extra_headers": {
"Authorization": "Bearer sk_live_abc123...",
"X-API-Key": "your-api-key",
"X-Custom-Header": "custom-value"
}
}
Common Patterns:
| Auth Method | Header Example |
|---|---|
| Bearer Token | Authorization: Bearer sk_live_abc123... |
| API Key | X-API-Key: your-api-key |
| Custom | X-Auth-Token: custom-token |
| Basic Auth | Authorization: Basic base64(user:pass) |
🔄 Response Handling
Expected Response Format
Your endpoint should respond with:
{
"success": true,
"message": "Content published successfully",
"id": "your-content-id",
"url": "https://yourblog.com/articles/your-slug"
}
Response Fields:
| Field | Required | Description |
|---|---|---|
| success | ✅ | true or false |
| message | ⭕ | Human-readable message |
| id | ⭕ | Your system's content ID |
| url | ⭕ | Published content URL |
| errors | ⭕ | Error details if failed |
Status Codes
Return appropriate HTTP status codes:
| Code | Meaning | Forest SEO Action |
|---|---|---|
| 200-299 | Success | Mark as published |
| 400-499 | Client error | Mark as failed, no retry |
| 500-599 | Server error | Retry with exponential backoff |
| Timeout | No response in 30s | Retry with exponential backoff |
Error Response Example:
{
"success": false,
"error": "Invalid content format",
"details": {
"field": "content",
"message": "Content must not exceed 100,000 characters"
}
}
🔁 Retry Logic
Retry Strategy:
| Attempt | Delay | Max Timeout |
|---|---|---|
| 1st | 5 seconds | 30s |
| 2nd | 15 seconds | 30s |
| 3rd | 45 seconds | 30s |
| Failed | Mark as error | - |
When Forest SEO Retries:
- Server errors (500-599)
- Network timeouts
- Connection refused
When Forest SEO Doesn't Retry:
- Client errors (400-499)
- Explicit
success: falsein 200 response - After 3 failed attempts
🧪 Testing
Test Connection
What Happens:
- Forest SEO sends test payload:
{
"id": "dlv_test_123",
"version": "1.1.0",
"event": "test",
"timestamp": "2025-01-08T10:00:00.000Z",
"project_id": "550e8400-e29b-41d4-a716-446655440000",
"object": {}
}
- Your endpoint should respond:
{
"success": true,
"message": "Webhook endpoint configured correctly"
}
- Forest SEO verifies 200-299 response
Local Development
Use Tunneling for Local Testing:
# Using ngrok
ngrok http 3000
# Returns public URL
https://abc123.ngrok.io -> http://localhost:3000
# Use in Forest SEO
https://abc123.ngrok.io/webhooks/forest-seo
Alternatives:
- Cloudflare Tunnel:
cloudflared tunnel - LocalTunnel:
lt --port 3000 - VS Code Port Forwarding: Built-in
Debug Tools
Webhook Inspectors:
| Tool | URL | Features |
|---|---|---|
| Webhook.site | webhook.site | Instant endpoint, inspect payloads |
| RequestBin | requestbin.com | Log all requests, replay |
| Beeceptor | beeceptor.com | Mock responses, rules |
🔧 Troubleshooting
Common Issues
Signature Verification Fails
Problem: Requests timeout before completion
Common Mistakes:
| Issue | Solution |
|---|---|
| Body was parsed | Use raw body parser, not express.json() |
| Wrong key | Match X-ForestSEO-KeyId with JWK kid |
| Stale cache | Refresh JWK Set from endpoint |
| Wrong algorithm | Use RS256, not HS256 |
Debug Steps:
- Log raw body bytes
- Log signature header (base64)
- Log matched key ID
- Verify public key conversion
- Test with known-good payload
Timeout Errors
Problem: Requests timeout before completion
Solutions:
-
Respond Quickly:
// ✅ Good: Respond immediately, process later res.json({ success: true }); await processInBackground(content); // ❌ Bad: Process before responding await processEverything(); // Takes 60s res.json({ success: true }); // Too late! -
Use Queues:
- Accept webhook
- Add to job queue (Redis/BullMQ)
- Respond immediately
- Process asynchronously
Payload Size Limits
Problem: "Request entity too large"
Solutions:
-
Increase Body Parser Limit:
app.use(express.raw({ type: 'application/json', limit: '10mb' })); -
Configure Nginx:
client_max_body_size 10M;
📚 Resources
💡 Best Practices
Security
- Always verify signatures using RS256
- Use HTTPS endpoints only
- Cache JWK Set for 10 minutes
- Implement rate limiting on your endpoint
- Log security events for monitoring
Reliability
- Respond within 30 seconds to avoid timeouts
- Use queues for heavy processing
- Implement idempotency using webhook ID
- Handle retries gracefully (check duplicate IDs)
- Monitor uptime with health checks
Performance
- Optimize database queries in webhook handlers
- Cache frequently used data
- Use CDN for serving assets
- Implement connection pooling for databases
- Scale horizontally as needed
Monitoring
- Track success/error rates over time
- Monitor response times (target <500ms)
- Alert on failures (>5% error rate)
- Log all webhooks with correlation IDs
- Regular health checks (automated)
🔗 Integration with Features
With Content Generation
- Generate AI content
- Send to any endpoint
- Full payload control
With Schedules
- Automate webhooks
- Batch publishing
- Scale infinitely