Nuwtonic AI SEO Agent Logo
Nuwtonic

Integrations

Receive signed Nuwtonic content and optimization events at your own HTTPS endpoint.

Create the token and add the integration

Use webhooks when your CMS is custom. For managed platforms, use WordPress, Shopify, Webflow, or Ghost instead. Product walkthrough: User Guide.

Before using the webhook API, add the integration to the workspace that will send content and optimization fixes.

  1. Open Nuwtonic and select the workspace you want to connect.
  2. Open Integrations, find Webhook, and select Connect. Nuwtonic opens the workspace webhook settings.
  3. Create a public HTTPS endpoint in your CMS, backend, or automation. The endpoint must accept POST requests.
  4. Paste the endpoint into Endpoint URL and turn on Webhook status.
  5. Select Save settings. Nuwtonic creates the signing secret (the webhook token) automatically.
  6. Copy the signing secret when it appears and store it in your receiving system as a protected environment variable. The full secret is shown only when it is created or rotated.
  7. Select Send test webhook. If the test fails, confirm that the endpoint is public, accepts JSON POST requests, verifies the signature with the saved secret, and returns a 2xx response within 30 seconds.

If you lose the signing secret, select Rotate secret, copy the new value, and update your receiving system before sending more webhook requests. The old secret stops working after rotation.

The integration is ready only when it is enabled, the endpoint URL is saved, the signing secret is stored in your receiving system, and the test request succeeds.


Webhook Integration Guide

Use the webhook integration when you want Nuwtonic to send generated content and optimization instructions to your own CMS, backend, or automation.

You configure one webhook URL. The same URL receives:

  • generated articles that your system must publish;
  • optimization fixes that your system must apply;
  • completion notifications describing the final result.

Your endpoint must inspect the top-level event field and the contents of data.result before deciding what to do.

1. Configuration

Configure the integration with this credential structure:

{
  "webhook": {
    "url": "https://cms.example.com/webhooks/nuwtonic",
    "secret": "replace-with-a-long-random-secret",
    "enabled": true
  }
}

Configuration fields:

  • url is the public HTTPS endpoint that receives every webhook event.
  • secret signs each request with HMAC-SHA256. Configure it even in development because completion notifications require it.
  • enabled controls delivery. When false, publishing and fix application are skipped.

Use the nested webhook object shown above for all webhook integrations.

Your endpoint must:

  1. Accept POST requests with JSON bodies.
  2. Read and preserve the raw request body before parsing JSON.
  3. Verify the signature.
  4. Route the request using event.
  5. Make processing idempotent using the top-level event id.
  6. Return a 2xx response within 30 seconds.

2. Common request envelope

Every request uses this structure:

{
  "id": "evt_0f1d2c3b4a5e6f70",
  "event": "content.generated",
  "created": 1785058200,
  "data": {}
}

Common fields:

  • id: unique event identifier. Store it and do not process the same ID twice.
  • event: event type used to route the request.
  • created: Unix timestamp in seconds.
  • data: event-specific payload.

The two actionable event types are:

  • content.generated: publish the article only when data.result is an article object.
  • fix.apply: apply the supplied fixes to data.target_url.

Completion notifications are:

  • content.generated: publish-job summary when data.result.published_count exists.
  • fix.generated: fix-job completion status.

3. Headers and signature verification

Signed requests include:

Content-Type: application/json
X-ContentKit-Signature: t=1785058200,v1=<hex-hmac-sha256>
X-ContentKit-Timestamp: 1785058200

Signature algorithm

Build the signed value from the timestamp header, a period, and the exact raw request body:

signed_payload = X-ContentKit-Timestamp + "." + raw_request_body
signature = HMAC_SHA256(webhook_secret, signed_payload)

Compare the hexadecimal result with the v1 value from X-ContentKit-Signature using a constant-time comparison.

Do not parse and re-serialize the JSON before verification. Whitespace and key order are part of the signature.

Recommended checks:

  • reject requests with missing signature headers;
  • reject invalid signatures with 401;
  • reject timestamps older than five minutes to reduce replay risk;
  • store processed event IDs and return 2xx for already processed IDs.

Python verification example

import hashlib
import hmac
import time


def verify_webhook(raw_body: bytes, signature_header: str, timestamp: str, secret: str) -> bool:
    if not signature_header or not timestamp:
        return False

    parts = dict(
        part.split("=", 1)
        for part in signature_header.split(",")
        if "=" in part
    )
    if parts.get("t") != timestamp or not parts.get("v1"):
        return False

    try:
        timestamp_value = int(timestamp)
    except ValueError:
        return False

    if abs(int(time.time()) - timestamp_value) > 300:
        return False

    signed_payload = timestamp.encode() + b"." + raw_body
    expected = hmac.new(
        secret.encode(),
        signed_payload,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

Node.js verification example

import crypto from "node:crypto";

export function verifyWebhook(rawBody, signatureHeader, timestamp, secret) {
  if (!signatureHeader || !timestamp) return false;

  const parts = Object.fromEntries(
    signatureHeader
      .split(",")
      .map((part) => part.split("=", 2))
      .filter(([key, value]) => key && value)
  );

  if (parts.t !== timestamp || !parts.v1) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > 300) {
    return false;
  }

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const expectedBuffer = Buffer.from(expected, "hex");
  const receivedBuffer = Buffer.from(parts.v1, "hex");
  return (
    expectedBuffer.length === receivedBuffer.length &&
    crypto.timingSafeEqual(expectedBuffer, receivedBuffer)
  );
}

4. Generated article event

When a user publishes or saves generated content through the webhook integration, your endpoint receives one actionable content.generated event per article.

{
  "id": "evt_0f1d2c3b4a5e6f70",
  "event": "content.generated",
  "created": 1785058200,
  "data": {
    "job_id": "content-job-001",
    "workspace_id": "workspace-id",
    "domain": "best ai seo tools",
    "status": "SUCCESS",
    "summary": {
      "total": 1,
      "processed": 1,
      "failed": 0
    },
    "result": {
      "id": "generated-article-id",
      "keyword": "best ai seo tools",
      "title": "Best AI SEO Tools",
      "slug": "best-ai-seo-tools",
      "status": "publish",
      "content": "<h1>Best AI SEO Tools</h1><p>...</p>",
      "content_html": "<h1>Best AI SEO Tools</h1><p>...</p>",
      "meta_description": "A clear description for search results.",
      "category": "SEO",
      "categories": ["SEO"],
      "schema": {
        "@context": "https://schema.org",
        "@type": "Article"
      }
    }
  }
}

data.result is the generated article record. It is passed through from Nuwtonic, so optional fields can vary by generation workflow. Do not reject the complete event because an optional example field is absent.

At minimum, your publisher should:

  1. Validate the fields required by your CMS.
  2. Use result.id or the event ID as an idempotency key.
  3. Create or update the article.
  4. Respect result.status:
    • publish: make the article live;
    • draft: save it without making it live.
  5. Store and return the final CMS ID and article URL.

If slug is missing, Nuwtonic may derive it from keyword, but your system should still validate it before publishing.

Required response

Return a JSON response with the external resource ID and final article URL:

{
  "id": "cms-post-123",
  "link": "https://cms.example.com/blog/best-ai-seo-tools"
}

url is accepted instead of link:

{
  "id": "cms-post-123",
  "url": "https://cms.example.com/blog/best-ai-seo-tools"
}

Always return the real article URL. Responses without link or url cannot reliably identify the published article.

5. Publish completion notification

After all requested articles have been processed, the same URL can receive another content.generated event. This second event is a job summary and must not create another article.

Identify it by the presence of data.result.published_count and data.result.results.

{
  "id": "evt_91ba9852ea9146ab",
  "event": "content.generated",
  "created": 1785058260,
  "data": {
    "job_id": "publish-job-123",
    "workspace_id": "workspace-id",
    "domain": "best ai seo tools",
    "status": "SUCCESS",
    "summary": {
      "total": 1,
      "processed": 1,
      "failed": 0
    },
    "result": {
      "published_count": 1,
      "results": [
        {
          "keyword": "best ai seo tools",
          "status": "published",
          "post_id": "cms-post-123",
          "link": "https://cms.example.com/blog/best-ai-seo-tools",
          "category": "SEO",
          "categories": ["SEO"]
        }
      ],
      "selected_category": "SEO",
      "selected_categories": ["SEO"]
    }
  }
}

For this summary event:

  • record or display the job result if needed;
  • do not publish data.result as an article;
  • return any 2xx response.

6. Optimization fix event

When optimizations are pushed through the webhook integration, your endpoint receives:

{
  "id": "evt_214a2f9bdc294df1",
  "event": "fix.apply",
  "created": 1785059000,
  "data": {
    "target_url": "https://cms.example.com/blog/best-ai-seo-tools",
    "summary": {
      "total": 4,
      "processed": 4,
      "failed": 0
    },
    "fixes": [
      {
        "type": "meta",
        "location": "<head>",
        "action": "replace",
        "suggested_value": "<title>Best AI SEO Tools for 2026</title>"
      },
      {
        "type": "content_replacement",
        "location": "Introduction",
        "location_heading": "H2",
        "action": "replace",
        "current_value": "Old introduction text.",
        "suggested_value": "Updated introduction text.",
        "reasoning": "The updated introduction answers the search intent directly."
      },
      {
        "type": "image_alt",
        "location": "https://cdn.example.com/images/seo-dashboard.jpg",
        "action": "replace",
        "current_value": "",
        "suggested_value": "SEO analytics dashboard showing keyword growth"
      },
      {
        "type": "schema",
        "location": "<head>",
        "action": "after",
        "suggested_value": "{\"@context\":\"https://schema.org\",\"@type\":\"Article\"}"
      }
    ]
  }
}

Use data.target_url to find the existing page or article. Do not apply a fix to another page when the URL cannot be resolved uniquely.

Fix object fields

Each item in data.fixes contains:

  • type: required fix type.
  • location: required target or placement instruction.
  • action: before, after, replace, or add.
  • suggested_value: required new value.
  • current_value: optional value observed during the audit.
  • location_heading: optional heading or element hint.
  • reasoning: optional explanation for the recommendation.

Optional values are omitted from the JSON rather than sent as null.

Fix types

meta

Updates SEO metadata. suggested_value normally contains either:

<title>Updated SEO title</title>

or:

<meta name="description" content="Updated search description">

Update SEO metadata rather than renaming the visible article unless your CMS intentionally uses the same field for both.

schema

Adds or updates JSON-LD. suggested_value contains a serialized JSON object.

Validate it with a JSON parser before saving it. Render it as:

<script type="application/ld+json">...</script>

Do not insert an equivalent schema block twice.

content_addition

Adds HTML content relative to location. Common placements include end of content or a named heading.

Before adding it, check whether equivalent content already exists.

content_replacement

Replaces existing content. When current_value is present, only replace content that still matches it. If it has changed since the audit, skip the fix and report the conflict.

structural

Changes document structure, such as headings or sections. Use location, location_heading, and current_value together. Skip ambiguous targets.

image_alt

Updates image alt text:

  • location is the image URL;
  • current_value is the previous alt text when available;
  • suggested_value is the new alt text.

Match the image uniquely. Preserve the image itself, file ID, dimensions, caption, and URL.

internal_link

Adds or updates an internal link. Preserve the surrounding text and only change the uniquely identified anchor.

faq

Adds FAQ content. Manual fix requests may use type: "faq".

High-level SEO requests usually produce two separate fixes instead:

  1. content_addition containing visible FAQ HTML;
  2. schema containing FAQPage JSON-LD.

Support both representations.

Applying fixes safely

Recommended processing order:

  1. Resolve target_url to exactly one editable resource.
  2. Load the latest version from your CMS.
  3. Verify every supplied current_value.
  4. Apply metadata and schema.
  5. Apply image alt fixes.
  6. Apply additions and links.
  7. Apply replacements and structural changes.
  8. Save using optimistic locking or version checks when available.
  9. Publish only after the complete update succeeds.

Skip a fix instead of guessing when its target is missing or ambiguous.

Required response

After applying the fixes, return:

{
  "id": "fix-operation-456",
  "link": "https://cms.example.com/blog/best-ai-seo-tools"
}

url is accepted instead of link.

Return 2xx only after your system has applied or intentionally and safely skipped the fixes. Return a non-2xx response when the complete operation should be treated as failed.

7. Fix completion notification

After the actionable fix.apply request finishes, the same URL can receive a fix.generated completion event:

{
  "id": "evt_65e5d1e6c83d4040",
  "event": "fix.generated",
  "created": 1785059060,
  "data": {
    "job_id": "optimization-job-456",
    "workspace_id": "workspace-id",
    "domain": "https://cms.example.com/blog/best-ai-seo-tools",
    "status": "SUCCESS",
    "summary": {
      "total": 1,
      "processed": 1,
      "failed": 0
    },
    "result": {
      "status": "success",
      "id": "fix-operation-456",
      "link": "https://cms.example.com/blog/best-ai-seo-tools"
    }
  }
}

A failed job uses:

{
  "event": "fix.generated",
  "data": {
    "status": "FAILED",
    "summary": {
      "total": 1,
      "processed": 0,
      "failed": 1
    },
    "result": {
      "error": "Webhook returned error status 500"
    }
  }
}

Completion events are notifications only. Record their status if needed and return any 2xx response.

8. Single-endpoint routing example

from fastapi import FastAPI, HTTPException, Request

app = FastAPI()
WEBHOOK_SECRET = "replace-with-the-configured-secret"


@app.post("/webhooks/nuwtonic")
async def nuwtonic_webhook(request: Request):
    raw_body = await request.body()
    signature = request.headers.get("X-ContentKit-Signature", "")
    timestamp = request.headers.get("X-ContentKit-Timestamp", "")

    if not verify_webhook(raw_body, signature, timestamp, WEBHOOK_SECRET):
        raise HTTPException(status_code=401, detail="Invalid webhook signature")

    payload = await request.json()
    event_id = payload.get("id")
    event = payload.get("event")
    data = payload.get("data") or {}

    if not event_id:
        raise HTTPException(status_code=400, detail="Webhook event ID is missing")

    if await event_was_processed(event_id):
        return {"status": "already_processed"}

    if event == "content.generated":
        result = data.get("result") or {}

        if "published_count" in result and "results" in result:
            await record_publish_summary(payload)
            await mark_event_processed(event_id)
            return {"status": "summary_recorded"}

        post = await publish_or_update_article(result)
        await mark_event_processed(event_id)
        return {"id": post.id, "link": post.url}

    if event == "fix.apply":
        operation = await apply_fixes(
            target_url=data.get("target_url"),
            fixes=data.get("fixes") or [],
        )
        await mark_event_processed(event_id)
        return {"id": operation.id, "link": operation.url}

    if event == "fix.generated":
        await record_fix_completion(payload)
        await mark_event_processed(event_id)
        return {"status": "completion_recorded"}

    # Acknowledge unknown notification events so future additions do not
    # create repeated delivery failures. Log them for later review.
    await record_unknown_event(payload)
    await mark_event_processed(event_id)
    return {"status": "ignored", "reason": "unsupported_event"}

The storage and CMS functions in this example are placeholders that must be implemented for your system.

9. Delivery and retry behavior

Actionable deliveries:

  • content.generated article events use a 30-second timeout.
  • fix.apply events use a 30-second timeout.
  • Any HTTP status from 200 through 299 is accepted.
  • A non-2xx response causes that publish or fix operation to fail.
  • Direct actionable deliveries do not currently guarantee automatic webhook retries.

Completion notifications:

  • use a 30-second timeout;
  • retry failures up to three times with exponential backoff;
  • can therefore arrive more than once.

Because retries and network ambiguity can always produce duplicates, idempotency is required for every event.

10. HTTP response guidance

Use these responses so users receive clear results:

  • 200 or 201: operation completed successfully.
  • 202: accepted only if your system can safely finish asynchronously and has already persisted the operation.
  • 400: payload is invalid or required fields are missing.
  • 401: signature is missing or invalid.
  • 404: target_url cannot be resolved.
  • 409: content changed after the audit and cannot be safely updated.
  • 422: a fix is valid JSON but unsupported by your CMS.
  • 429: temporary rate limit; include Retry-After where possible.
  • 500 or 503: temporary server failure.

Return clear error JSON:

{
  "error": "target_not_found",
  "message": "No editable article matches data.target_url."
}

Do not return 2xx with an error body. Nuwtonic treats every 2xx response as successful.

11. Production checklist

  • Use a public HTTPS endpoint.
  • Store the webhook secret securely.
  • Verify signatures against the raw body.
  • Enforce a timestamp tolerance.
  • Deduplicate using the event ID.
  • Distinguish actionable articles from publish summaries.
  • Return the final article URL for publish requests.
  • Resolve fix target URLs exactly.
  • Check current_value before replacing content.
  • Preserve image references when changing alt text.
  • Validate and deduplicate JSON-LD.
  • Log event ID, event type, target URL, response status, and processing result.
  • Keep processing below 30 seconds or persist work before returning 202.
  • Monitor non-2xx responses and completion events.

12. Current limitations

  • One URL receives all publish, fix, and completion events; separate event URLs are not supported.
  • The article object is a pass-through record, so optional fields vary by generation workflow.
  • The same content.generated event name is used for both actionable article delivery and publish completion summaries. Inspect data.result before processing.
  • The webhook integration sends instructions; your receiver is responsible for CMS-specific updates, conflict handling, publishing, and rollback.
  • Returning 202 does not provide a later callback API for your system to report its own asynchronous result.
  • Direct article and fix deliveries do not currently guarantee automatic retries.