pSEO Webhooks

Programmatic SEO (pSEO) in MassBlogger works differently than standard blog posts. Instead of sending final HTML, we send templates and variables that you resolve at render time.

If you don't want to store pSEO pages yourself, you can also fetch live pSEO pages directly from Massblogger with your website API key via /api/pseo-pages. Use webhooks when you want your own storage layer; use REST when you want Massblogger-hosted storage with custom rendering.

Why Templates?

By sending templates instead of static HTML, you gain several advantages:

  • Dynamic Content: Update a variable (like [year]) once in MassBlogger, and all your pages update instantly on your site.
  • Better Linking: Resolve internal links at render time to ensure they always point to the correct URLs and handle self-linking correctly.
  • Custom Fields: Access structured data (fields) to build custom UI components like comparison tables or feature lists.

Event Types

EventDescription
pseo.page.updatedSent on every save. Contains the page template, variables, and links.
pseo.page.deletedSent when a page is deleted. Remove it from your database using pageId.
pseo.internalLinks.updatedSent when you update the "Links" tab in a campaign. Update your global links collection.

Payload Structure

{
  "event": "pseo.page.updated",
  "data": {
    "pageId": "unique_id",       // Use this as your primary key
    "campaignId": "camp_123",
    "targetSlug": "my-slug",
    "status": "published",
    "html": "<h2>[city] Hotels</h2>...", // Template with [var] tags
    "variables": {
      "city": "London",
      "year": "2026"
    },
    "fields": {                  // Custom fields from your data source
      "rating": 4.5,
      "price": "$120"
    },
    "internalLinks": [           // Links specific to this page
      { "keyword": "London", "url": "/london", "internal": true }
    ]
  }
}

Implementation Guide

1. Webhook Handler

Store the incoming data in your database. Always use pageId as the unique identifier to handle slug changes correctly.

// app/api/pseo-webhook/route.js
import { NextResponse } from 'next/server';
import { getDatabase } from '@/lib/mongodb';

export async function POST(request) {
  try {
    const body = await request.json();
    const db = await getDatabase();

    // 1. Handle Page Updates
    if (body.event === 'pseo.page.updated') {
      const { pageId, targetSlug, status, html, fields, variables, internalLinks } = body.data;

      await db.collection('pseo_pages').updateOne(
        { pageId },
        {
          $set: {
            slug: targetSlug,
            status,
            html,
            fields: fields || {},
            variables: variables || {},
            internalLinks: internalLinks || [],
            updatedAt: new Date(),
          },
          $setOnInsert: { pageId, createdAt: new Date() },
        },
        { upsert: true }
      );
      return NextResponse.json({ ok: true, action: 'updated' });
    }

    // 2. Handle Page Deletion
    if (body.event === 'pseo.page.deleted') {
      const { pageId } = body.data;
      await db.collection('pseo_pages').deleteOne({ pageId });
      return NextResponse.json({ ok: true, action: 'deleted' });
    }

    // 3. Handle Campaign-wide Internal Links
    if (body.event === 'pseo.internalLinks.updated') {
      const { campaignId, internalLinks } = body.data;
      await db.collection('pseo_internal_links').updateOne(
        { campaignId },
        { $set: { campaignId, internalLinks: internalLinks || [], updatedAt: new Date() } },
        { upsert: true }
      );
      return NextResponse.json({ ok: true });
    }

    return NextResponse.json({ ok: true });
  } catch (err) {
    return NextResponse.json({ error: err.message }, { status: 500 });
  }
}

2. Resolving Templates

When displaying the page on your site, use a helper function to replace the template tags with actual values.

// lib/pseo.js

/**
 * Resolve template at render time.
 * Replaces [var] and {var} with values, and [[anchor]] with links.
 */
export function resolveContent(html, variables = {}, links = [], currentSlug = '') {
  let out = html || '';

  // 1. Replace [var] and {var} with variable values
  if (variables && typeof variables === 'object') {
    out = out.replace(/\[(\w+)\]/g, (m, k) => (k in variables) ? variables[k] : m);
    out = out.replace(/\{(\w+)\}/g, (m, k) => (k in variables) ? variables[k] : m);
  }

  // 2. Replace [[anchor]] with links (internal + external)
  if (links?.length) {
    for (const link of links) {
      const anchor = link.anchor || link.keyword;
      const url = link.url || '';
      if (!anchor || !url) continue;

      // Skip self-links for internal links
      const isInternal = link.internal !== false;
      if (isInternal && currentSlug && (url === '/' + currentSlug || url.endsWith('/' + currentSlug))) continue;

      const rel = !isInternal && link.nofollow ? ' rel="nofollow"' : '';
      const escaped = anchor.replace(/[.*+?^$${}()|[\]\\]/g, '\\$&');
      out = out.replace(new RegExp('\\[\\[ ' + escaped + ' \\]\\]', 'g'), '<a href="' + url + '"' + rel + '>' + anchor + '</a>');
    }
  }

  return out;
}

Best Practices

  • Upsert by pageId: Never use the slug as a primary key. Slugs can change, but pageId is permanent.
  • Merge Links: When rendering, merge the page-specific internalLinks with your campaign-wide links for maximum coverage.
  • Cache Resolved HTML: Resolving templates is fast, but for high-traffic sites, you may want to cache the final HTML output.

Need help? Contact us at support@massblogger.com