Webhook Integration

Connect your own publishing endpoint to SEOMind. Your server must implement the protocol below; a generic webhook receiver that only returns HTTP 200 is not sufficient.

Protocol version: 2026-08-01

Quick start for you and your AI coding agent

SEOMind pushes articles to YOUR website. Your website stores the content and serves the public blog pages. This works with Next.js, Express, other frameworks and existing CMSs. A Next.js receiver uses this same Webhook integration; there is no separate SEOMind Next.js read API or npm package to install.

  1. Generate one secret using the command below. Keep it on the receiving server and paste the identical value into SEOMind.
  2. Implement the public POST endpoint, signature verification, connection test, durable article storage and public article page. Use the Next.js reference below if applicable.
  3. Deploy the receiver before testing. Enter its full HTTPS endpoint in Publishing Integrations → Custom Blog (Webhook).
  4. Run Test Connection, then Save Integration. Finally test publication and updates with a disposable article on your own test site.

Generate the Webhook Secret

Recommended format for NEW integrations: 32 cryptographically random bytes (256 bits), encoded as exactly 64 lowercase hexadecimal characters. Allowed characters: 0-9 and a-f; pattern: ^[0-9a-f]{64}$. This is not a 64-bit secret, a password you invent, an API URL, or the per-request signature.

Run ONE of these equivalent commands locally. Each run generates a different secret; generate once and use the same output on both sides.

Bash / macOS / Linux (requires OpenSSL):

openssl rand -hex 32

Node.js (also works in PowerShell):

node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"

Store the output as the receiving server's environment variable:

SEOMIND_WEBHOOK_SECRET=PASTE_THE_GENERATED_64_CHARACTER_VALUE_HERE
SEOMIND_SITE_URL=https://example.com

Paste only the generated value into SEOMind's Webhook Secret field, without the variable name, quotes, spaces or newline. In Next.js use .env.local locally and your hosting provider's server environment settings in production; redeploy after setting them. Never use a NEXT_PUBLIC_ prefix, commit the secret, put it in a URL or expose it to browser JavaScript. Ask AI to execute the random generator locally; do not ask a language model to invent random characters or paste the production secret into its prompt.

The existing protocol accepts a nonempty shared string; 64-character hex is the recommended generation convention, not a new restriction on existing connections. Preserve an existing working secret. HMAC uses the literal 64-character UTF-8 string as its key, NOT Buffer.from(secret, 'hex'). To rotate it, coordinate the same replacement on both sides, then retest and save the integration.

1. Configure your endpoint

FieldWhat to provide
Webhook EndpointThe public URL on your server that receives JSON POST requests.
Webhook SecretA shared secret used for HMAC signature verification, not an Authorization Bearer token.

Enter your public Webhook Endpoint and a strong, random Webhook Secret. Store the same secret securely on your server. Use HTTPS in production. SEOMind blocks private network targets and does not follow redirects.

All events are JSON POST requests to the same endpoint. Test Connection checks the response format and whether the returned website identity matches your project’s Primary Website. Save Integration runs these checks again. Testing does not save the connection.

2. Verify each request

The x-content-engine-signature header contains a lowercase hexadecimal HMAC-SHA256 digest of the exact UTF-8 request body, using your Webhook Secret. There is no sha256= prefix and no Bearer token. Validate the signature before processing the event.

import { createHmac, timingSafeEqual } from 'node:crypto';

function verifySignature(rawBody, signature, secret) {
  if (!secret || typeof signature !== 'string' || !/^[a-f0-9]{64}$/.test(signature)) return false;
  const expected = createHmac('sha256', secret).update(rawBody).digest();
  return timingSafeEqual(expected, Buffer.from(signature, 'hex'));
}
// rawBody must be the original request bytes, not JSON.stringify(parsedBody).
// After signature validation, parse JSON and dispatch by its event field.

Reject invalid signatures. Validate the signed timestamp against an appropriate replay window and handle repeat deliveries without creating duplicate content. A five-minute window in either direction is a practical receiver default; keep your server clock synchronized. The timestamp is inside the signed JSON body, not a separate header. Verify the raw bytes first, then parse JSON and check timestamp. SEOMind creates a new timestamp and signature for each send, including retries.

3. Handle the connection test

Respond within 10 seconds. This event must not create or update articles.

{
  "event": "connection_test",
  "timestamp": "2026-09-26T10:00:00.000Z",
  "sideEffectFree": true
}

Return a successful HTTP status and this JSON shape. Replace the example domain with the website receiving published content; it must match the project’s Primary Website.

{
  "siteIdentity": "https://example.com",
  "protocolVersion": "2026-08-01",
  "capabilities": [
    "publish",
    "update"
  ]
}

publish is required. Declare update only if implemented. A correct response confirms protocol compatibility; your server remains responsible for validating signatures.

4. Publish an article

Each request contains one article. Use contentHtml or contentMarkdown according to your CMS. The slug is a suggested URL path; tags is an array of strings. seoTitle and featuredImage may be null. Your response must identify the article actually created on your website.

The current publishing flow sends article.publish. The publication identifier is also provided in the x-content-engine-publication-id header.

{
  "publicationId": "publication-uuid",
  "event": "article.publish",
  "timestamp": "2026-09-26T10:00:00.000Z",
  "scheduledAt": null,
  "article": {
    "title": "Example article",
    "slug": "example-article",
    "contentMarkdown": "# Example article\n\nContent...",
    "contentHtml": "<h1>Example article</h1><p>Content...</p>",
    "seoTitle": "Example article",
    "seoDescription": "An example description.",
    "tags": [
      "example"
    ],
    "featuredImage": null
  }
}

Respond within 15 seconds, after the article has been published. Return a successful HTTP status and the following JSON. An empty response, HTTP 202 alone, or published: false is not a successful publication confirmation.

{
  "externalArticleId": "your-cms-article-id",
  "canonicalUrl": "https://example.com/blog/example-article",
  "published": true
}

Keep a durable mapping from publicationId to your CMS article ID. If the same publication is retried, return the existing result instead of creating another article. Do not confirm publication before it succeeds. Enforce a database UNIQUE constraint on publicationId within the receiving site's integration scope and handle concurrent deliveries transactionally. An in-memory Map, a cache alone, or local files on an ephemeral deployment cannot provide durable deduplication. If your CMS is separate from your database, use its idempotency key or a recoverable delivery record; a transaction in your own database cannot make a remote CMS write atomic.

5. Update an existing article

Updates use event: "article.update" with publicationId, externalArticleId, timestamp, and the same article fields shown above. There is no scheduledAt field. Update the article identified by externalArticleId and return the same successful response shape as publishing.

An update can reuse the publication ID with new content. Deduplicate identical retries without discarding later edits just because they share a publication ID. For update deduplication, compare the target article and a stable hash of the article content; do not include timestamp because it changes on retries. Serialize writes for one target article so simultaneous updates cannot corrupt its stored content.

{
  "publicationId": "publication-uuid",
  "externalArticleId": "your-cms-article-id",
  "event": "article.update",
  "timestamp": "2026-09-26T11:00:00.000Z",
  "article": {
    "title": "Updated example article",
    "slug": "example-article",
    "contentMarkdown": "# Updated example article",
    "contentHtml": "<h1>Updated example article</h1>",
    "seoTitle": "Updated example article",
    "seoDescription": "Updated description.",
    "tags": [
      "example"
    ],
    "featuredImage": null
  }
}

6. Test your integration

  1. Implement signature validation and the connection_test response on your server.

  2. In Integrations, choose Custom Blog (Webhook), enter the endpoint and secret, and click Test Connection. This test does not publish an article.

  3. Click Save Integration after the test passes. Saving rechecks the connection and project website identity.

  4. Open an article and use Publish to send real content to your website. Check your server logs, the returned article URL, and the article itself.

  5. Edit the published article and use Update Website to verify your update handler.

Connection testing only checks the handshake. Successful article creation and updates must also be tested against your CMS. Never log the shared secret or include it in error responses.

7. Next.js App Router receiver reference

Create app/api/webhooks/seomind/route.js (or src/app/api/webhooks/seomind/route.js when your app uses src). Its public URL is https://YOUR_DOMAIN/api/webhooks/seomind. Use the Node.js runtime. This is a Webhook receiver, not an article-reading client.

The HTTP/signature/handshake code below is complete. You must implement the two imported storage adapters before deploying it. They must write to your actual database/CMS and public article route; do not replace them with fake success responses. Do not add interactive login, CSRF forms or a browser redirect to this endpoint; authenticate every request with the HMAC signature instead.

import { createHmac, timingSafeEqual } from 'node:crypto';
import { publishOnce, updateExisting } from '@/lib/seomind-publishing';

export const runtime = 'nodejs';
const json = (body, status = 200) => Response.json(body, { status });

export async function POST(request) {
  const secret = process.env.SEOMIND_WEBHOOK_SECRET;
  const site = process.env.SEOMIND_SITE_URL;
  if (!secret || !site) return json({ message: 'Receiver environment is not configured.' }, 503);
  const rawBody = Buffer.from(await request.arrayBuffer());
  const signature = request.headers.get('x-content-engine-signature') || '';
  if (!/^[a-f0-9]{64}$/.test(signature)) return json({ message: 'Invalid signature.' }, 401);
  const expected = createHmac('sha256', secret).update(rawBody).digest();
  if (!timingSafeEqual(expected, Buffer.from(signature, 'hex'))) return json({ message: 'Invalid signature.' }, 401);

  let payload;
  try { payload = JSON.parse(rawBody.toString('utf8')); }
  catch { return json({ message: 'Invalid JSON.' }, 400); }
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return json({ message: 'Invalid payload.' }, 400);
  const sentAt = typeof payload.timestamp === 'string' ? Date.parse(payload.timestamp) : NaN;
  if (!Number.isFinite(sentAt) || Math.abs(Date.now() - sentAt) > 300000) return json({ message: 'Expired timestamp.' }, 400);

  if (payload.event === 'connection_test') {
    return json({ siteIdentity: site, protocolVersion: '2026-08-01', capabilities: ['publish', 'update'] });
  }
  if (!['article.publish', 'article.update'].includes(payload.event)) return json({ message: 'Unsupported event.' }, 400);
  if (typeof payload.publicationId !== 'string' || !payload.publicationId) return json({ message: 'publicationId is required.' }, 400);
  if (request.headers.get('x-content-engine-publication-id') !== payload.publicationId) return json({ message: 'Publication ID mismatch.' }, 400);
  const a = payload.article;
  if (!a || typeof a.title !== 'string' || !a.title.trim() || typeof a.slug !== 'string' || !a.slug || typeof a.contentHtml !== 'string' || typeof a.contentMarkdown !== 'string' || !Array.isArray(a.tags) || a.tags.some(tag => typeof tag !== 'string')) return json({ message: 'Invalid article fields.' }, 422);
  if (payload.event === 'article.update' && (typeof payload.externalArticleId !== 'string' || !payload.externalArticleId)) return json({ message: 'externalArticleId is required.' }, 400);

  try {
    const result = payload.event === 'article.publish'
      ? await publishOnce({ publicationId: payload.publicationId, article: a })
      : await updateExisting({ publicationId: payload.publicationId, externalArticleId: payload.externalArticleId, article: a });
    if (!result || typeof result.externalArticleId !== 'string' || typeof result.canonicalUrl !== 'string' || result.published !== true) throw new Error('Publication was not confirmed');
    return json({ externalArticleId: result.externalArticleId, canonicalUrl: result.canonicalUrl, published: true });
  } catch {
    return json({ message: 'Could not confirm publication. Check the receiver using the publication ID.' }, 500);
  }
}

Implement lib/seomind-publishing with these contracts:

AdapterRequired behavior
publishOnce({ publicationId, article })Validate field types and length limits for your CMS. Atomically create and publish the article and save its ID/URL mapping, or return the completed mapping on a duplicate. Resolve slug collisions according to your CMS and return the actual resulting URL.
updateExisting({ publicationId, externalArticleId, article })Confirm that the ID belongs to this integration/site and matches the stored mapping. Update that article instead of creating another one. Preserve its existing public URL. Apply later edits even when publicationId is unchanged.
Both return{ externalArticleId: String(cmsId), canonicalUrl: 'https://your-site/actual-path', published: true } only after the public content is committed and available. Throw on storage failure.

Also create or reuse /blog and /blog/[slug], SEO metadata, canonical links and your sitemap. Sanitize HTML with your site's established sanitizer before rendering. Revalidate/invalidate the list, article and sitemap caches after a committed publish/update. Use the cache APIs supported by your installed Next.js version. A database row alone does not mean your website has a public article page. If update is not implemented, remove update from capabilities and reject article.update explicitly.

8. Copy this task to your AI coding agent

Use Copy documentation at the top to give the AI this entire protocol together with the following task. Never include the actual secret.

Implement SEOMind publishing in this website using the attached protocol.
First inspect this repository's framework/version, database/CMS, existing blog routes, authentication middleware and deployment model. Reuse its existing architecture and package manager.
Generate SEOMIND_WEBHOOK_SECRET using a cryptographic random generator: 32 random bytes encoded as 64 lowercase hex characters. Keep it local/server-side; do not include it in source control, browser bundles, reports or logs. Tell the owner where to copy it securely into SEOMind.
Implement POST /api/webhooks/seomind with raw-body HMAC verification and signed timestamp checks. Use the literal secret string as the key. Implement connection_test without side effects; siteIdentity must be this project's Primary Website and protocolVersion must match the attached spec.
Implement article.publish with persistent, concurrency-safe idempotency by publicationId and actual public article creation. Implement article.update by externalArticleId plus mapping ownership; preserve URLs and allow later edits with the same publicationId. Do not use an in-memory store or return fake success.
Persist the payload fields needed by this CMS, render public list/detail pages, handle optional images and tags, sanitize rendered content, and refresh caches/sitemap. Return the real externalArticleId, canonicalUrl and published:true only after success. No API key or secret in client components.
Add tests for valid/invalid signatures, modified raw bytes, stale timestamps, side-effect-free connection tests, duplicate and concurrent publish requests, same-ID later updates, unknown/mismatched article IDs and storage failures. Run them before reporting completion.
Deliver: exact files changed, environment variable names, deployment instructions, final endpoint URL, what to enter in SEOMind, and test results. Mark any unfinished database/CMS adapter clearly. Do not publish real articles merely to test the connection.

Receiver acceptance checklist

  • Missing/incorrect signature returns 401/403 and never touches article storage. A changed byte invalidates the signature.
  • connection_test returns the correct website/protocol/capabilities in under 10 seconds, creating no article.
  • The first article.publish creates one public article and returns its real URL in under 15 seconds. Repeating or concurrently sending the same publicationId does not create another.
  • article.update changes that same article, preserves its URL, and reflects a second edit with the same publicationId.
  • Storage failure never returns published:true. A lost HTTP response can be recovered through the saved publication mapping.
  • Public list/detail pages, images, SEO tags and sitemap work after cache refresh. Run real content tests only on a site/article approved for testing.

Images and troubleshooting

Return JSON errors with a message string. Use 401/403 for failed authentication, 400/422 for invalid input and 5xx for temporary server/storage failures. The current publish delivery service retries network and 5xx failures after approximately 1 minute and then 5 minutes, with at most 3 attempts; worker scheduling can add delay. Authentication, protocol and other client failures need correction rather than blind retries. A failed response can occur after your CMS has committed the article, which is why durable idempotency is mandatory.

Images are referenced by URLs in the article content and featuredImage. The current payload does not include the old top-level media or schemaMarkup fields. To host images yourself, download them and replace the URLs before publishing.

  • Connection rejected: check protocolVersion, capabilities, and siteIdentity.

  • Signature mismatch: check the shared secret and verify the original request bytes.

  • Timeout or redirect: use the final public endpoint and complete processing within the response deadline.

  • Uncertain publication result: inspect your CMS and publication ID before retrying; the request may have succeeded even if its response was lost.

These examples describe SEOMind’s current custom website publishing protocol. The older article.published payload is not the current publishing contract.