ForestSEO

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:

RequirementDetails
Endpoint URLPublic HTTPS endpoint to receive POST requests
AuthenticationOptional custom headers for your authentication
Request HandlerCode to process webhook payload
TLS/SSLValid HTTPS certificate (required)
Server UptimeReliable 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:

  1. Go to SettingsIntegrations
  2. Click "Add Integration"
  3. Select "Webhook"
  4. Fill in details:
FieldValueExample
NameIntegration identifier"Custom API Webhook"
Endpoint URLYour webhook URLhttps://api.example.com/webhooks/forest-seo
VersionPayload format1.1.0 (default)
Extra HeadersCustom auth headersSee Authentication section
  1. Click "Test Connection"
  2. Verify you receive test payload
  3. 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:

FieldTypeDescription
idstringUnique delivery ID (e.g., dlv_abc123def456)
versionstringWebhook API version (e.g., 1.1.0)
eventstringEvent type (see Event Types below)
timestampISO 8601When webhook was triggered
project_idUUIDForest SEO project identifier
objectobjectEvent-specific payload

Event Types

EventDescriptionobject Content
testTest connectionEmpty {}
content.publication.publishedContent was publishedFull content data
content.publication.unpublishedContent was unpublishedContent 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:

FieldTypeDescription
idstringContent publication UUID
slugstringURL-friendly slug for the content
localestringContent language code (e.g., "en", "ru", "es")
titlestringContent title
excerptstringShort summary/description of the content
markdownstringFull content in Markdown format
htmlstringFull content rendered as HTML with Lexical editor classes (Tailwind CSS)
lexicalobjectLexical editor state as JSON (full editor structure)
categoriesstring[]Content categories
tagsstring[]Content keywords/tags
meta.titlestringSEO meta title (for <title> tag)
meta.descriptionstringSEO 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:

HeaderDescription
X-ForestSEO-SignatureBase64-encoded RSA signature of request body
X-ForestSEO-AlgorithmSigning algorithm (always RS256)
X-ForestSEO-KeyIdKey ID (kid) to identify which public key to use
X-ForestSEO-Webhook-VersionWebhook 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:

  1. Read raw request body (do NOT parse/modify before verification)
  2. Fetch public keys from JWK endpoint (cache for 10 min)
  3. Find matching key using X-ForestSEO-KeyId header
  4. 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

VersionDescriptionPayload Changes
1.1.0Current (recommended)Structured event format
1.0.0LegacySimple 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 MethodHeader Example
Bearer TokenAuthorization: Bearer sk_live_abc123...
API KeyX-API-Key: your-api-key
CustomX-Auth-Token: custom-token
Basic AuthAuthorization: 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:

FieldRequiredDescription
successtrue or false
messageHuman-readable message
idYour system's content ID
urlPublished content URL
errorsError details if failed

Status Codes

Return appropriate HTTP status codes:

CodeMeaningForest SEO Action
200-299SuccessMark as published
400-499Client errorMark as failed, no retry
500-599Server errorRetry with exponential backoff
TimeoutNo response in 30sRetry 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:

AttemptDelayMax Timeout
1st5 seconds30s
2nd15 seconds30s
3rd45 seconds30s
FailedMark 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: false in 200 response
  • After 3 failed attempts

🧪 Testing

Test Connection

What Happens:

  1. 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": {}
}
  1. Your endpoint should respond:
{
  "success": true,
  "message": "Webhook endpoint configured correctly"
}
  1. 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:

ToolURLFeatures
Webhook.sitewebhook.siteInstant endpoint, inspect payloads
RequestBinrequestbin.comLog all requests, replay
Beeceptorbeeceptor.comMock responses, rules

🔧 Troubleshooting

Common Issues


Signature Verification Fails

Problem: Requests timeout before completion

Common Mistakes:

IssueSolution
Body was parsedUse raw body parser, not express.json()
Wrong keyMatch X-ForestSEO-KeyId with JWK kid
Stale cacheRefresh JWK Set from endpoint
Wrong algorithmUse RS256, not HS256

Debug Steps:

  1. Log raw body bytes
  2. Log signature header (base64)
  3. Log matched key ID
  4. Verify public key conversion
  5. Test with known-good payload

Timeout Errors

Problem: Requests timeout before completion

Solutions:

  1. 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!
  2. Use Queues:

    • Accept webhook
    • Add to job queue (Redis/BullMQ)
    • Respond immediately
    • Process asynchronously

Payload Size Limits

Problem: "Request entity too large"

Solutions:

  1. Increase Body Parser Limit:

    app.use(express.raw({ type: 'application/json', limit: '10mb' }));
  2. 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

Learn more about Content →

With Schedules

  • Automate webhooks
  • Batch publishing
  • Scale infinitely

Learn more about Schedules →