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.
- Open Nuwtonic and select the workspace you want to connect.
- Open Integrations, find Webhook, and select Connect. Nuwtonic opens the workspace webhook settings.
- Create a public HTTPS endpoint in your CMS, backend, or automation. The endpoint must accept
POSTrequests. - Paste the endpoint into Endpoint URL and turn on Webhook status.
- Select Save settings. Nuwtonic creates the signing secret (the webhook token) automatically.
- 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.
- Select Send test webhook. If the test fails, confirm that the endpoint is public, accepts JSON
POSTrequests, verifies the signature with the saved secret, and returns a2xxresponse 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:
urlis the public HTTPS endpoint that receives every webhook event.secretsigns each request with HMAC-SHA256. Configure it even in development because completion notifications require it.enabledcontrols delivery. Whenfalse, publishing and fix application are skipped.
Use the nested webhook object shown above for all webhook integrations.
Your endpoint must:
- Accept
POSTrequests with JSON bodies. - Read and preserve the raw request body before parsing JSON.
- Verify the signature.
- Route the request using
event. - Make processing idempotent using the top-level event
id. - Return a
2xxresponse 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 whendata.resultis an article object.fix.apply: apply the supplied fixes todata.target_url.
Completion notifications are:
content.generated: publish-job summary whendata.result.published_countexists.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
2xxfor 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:
- Validate the fields required by your CMS.
- Use
result.idor the event ID as an idempotency key. - Create or update the article.
- Respect
result.status:publish: make the article live;draft: save it without making it live.
- 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.resultas an article; - return any
2xxresponse.
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, oradd.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:
locationis the image URL;current_valueis the previous alt text when available;suggested_valueis 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:
content_additioncontaining visible FAQ HTML;schemacontainingFAQPageJSON-LD.
Support both representations.
Applying fixes safely
Recommended processing order:
- Resolve
target_urlto exactly one editable resource. - Load the latest version from your CMS.
- Verify every supplied
current_value. - Apply metadata and schema.
- Apply image alt fixes.
- Apply additions and links.
- Apply replacements and structural changes.
- Save using optimistic locking or version checks when available.
- 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.generatedarticle events use a 30-second timeout.fix.applyevents use a 30-second timeout.- Any HTTP status from
200through299is accepted. - A non-
2xxresponse 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:
200or201: 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_urlcannot 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; includeRetry-Afterwhere possible.500or503: 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_valuebefore 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-
2xxresponses 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.generatedevent name is used for both actionable article delivery and publish completion summaries. Inspectdata.resultbefore processing. - The webhook integration sends instructions; your receiver is responsible for CMS-specific updates, conflict handling, publishing, and rollback.
- Returning
202does 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.
