Publishing · Developer reference
Custom Webhook
AvailableThe escape hatch for any platform without a native adapter. KwikScaleAI POSTs a signed JSON envelope to your endpoint every time an article is published or updated. You decide what to do with it — write to a headless CMS, trigger a build, post to Slack, sync to a CDN.
How it works
Whenever an article is approved for publishing, KwikScaleAI makes a single HTTP POST to the endpointUrl you configured. The body is a JSON envelope with an event field identifying whether this is a new article, an update, or a test. If you set a secret, we include an X-KwikScaleAI-Signature header — verify it before trusting the payload.
The adapter is designed to be stateless from your side: no OAuth dance, no polling, no webhooks to subscribe. Just expose an endpoint, point KwikScaleAI at it, done.
Prerequisites
- A publicly reachable HTTPS endpoint (we refuse plain HTTP).
- Ability to verify HMAC-SHA256 signatures in your backend — examples below for Node.js, Python, PHP, and Go.
- A strong random secret (at least 32 chars recommended). Store it in an env var, never in source control.
Setup guide
- In the KwikScaleAI dashboard, open Integrations → Custom Webhook → Connect.
- Paste your endpoint URL (must start with
https://) and give the target a label. - KwikScaleAI generates a strong secret for you automatically and shows it once. Copy it into an env var on your server —
KWIKSCALE_WEBHOOK_SECRETis the convention — before dismissing the modal. The secret is encrypted at rest and never displayed again; if you lose it, delete the target and create a new one. - Click Test connectionon the saved target row. KwikScaleAI sends a test ping. A 2xx response means you're done.
Configuration
| Parameter | Type | Required | Description |
|---|---|---|---|
| endpointUrl | string (URL) | Required | Public HTTPS URL that will receive the webhook POST. Must start with https://. |
| secret | string | Optional | HMAC-SHA256 signing secret. When set, KwikScaleAI sends X-KwikScaleAI-Signature: sha256=<hex>. Strongly recommended for production — skip only for internal testing. |
| timeoutMs | integer | Optional | Maximum time to wait for your endpoint to respond before aborting. Accepts 1000–60000.Default: 10000 |
| additionalHeaders | Record<string, string> | Optional | Extra headers to send with every request. Reserved keys (Authorization, Content-Type, User-Agent, X-KwikScaleAI-Signature, X-KwikScaleAI-Event) are silently stripped. |
| payloadShape | 'kwikscale-v1' | 'blogseo-compat' | Optional | Which body dialect to send. kwikscale-v1 is the original event-envelope shape with event in the body. blogseo-compat is a flat { article, main_image, website } payload that matches the BlogSEO schema — the event type moves to the X-KwikScaleAI-Eventheader. New targets created via the dashboard's v2 UI default to blogseo-compat.Default: kwikscale-v1 |
| contentFormat | 'markdown' | 'html' | Optional | Only applies when payloadShape is blogseo-compat. Selects which representation lands in article.content. The kwikscale-v1 shape always ships both contentMd and contentHtml.Default: markdown |
endpointUrlRequiredstring (URL)
Public HTTPS URL that will receive the webhook POST. Must start with https://.
secretOptionalstring
HMAC-SHA256 signing secret. When set, KwikScaleAI sends X-KwikScaleAI-Signature: sha256=<hex>. Strongly recommended for production — skip only for internal testing.
timeoutMsOptionalinteger
Maximum time to wait for your endpoint to respond before aborting. Accepts 1000–60000.
Default: 10000
additionalHeadersOptionalRecord<string, string>
Extra headers to send with every request. Reserved keys (Authorization, Content-Type, User-Agent, X-KwikScaleAI-Signature, X-KwikScaleAI-Event) are silently stripped.
payloadShapeOptional'kwikscale-v1' | 'blogseo-compat'
Which body dialect to send. kwikscale-v1 is the original event-envelope shape with event in the body. blogseo-compat is a flat { article, main_image, website } payload that matches the BlogSEO schema — the event type moves to the X-KwikScaleAI-Eventheader. New targets created via the dashboard's v2 UI default to blogseo-compat.
Default: kwikscale-v1
contentFormatOptional'markdown' | 'html'
Only applies when payloadShape is blogseo-compat. Selects which representation lands in article.content. The kwikscale-v1 shape always ships both contentMd and contentHtml.
Default: markdown
Webhook events
Three event types exist across both payload shapes:
article.published— a new article was approved and is being published for the first time.article.updated— a previously-published article has been updated (includescmsPostId).webhook.test— the “Test connection” button was pressed.
Where the event type lives depends on the configured payloadShape:
kwikscale-v1—eventis a field in the JSON body.blogseo-compat—eventrides on theX-KwikScaleAI-Eventrequest header. The body has noeventfield.
Payload reference — kwikscale-v1
The full envelope that hits your endpoint when payloadShape is kwikscale-v1. All timestamps are ISO-8601 UTC. Content is sent both as markdown (contentMd) and rendered HTML (contentHtml) so you can pick whichever your CMS stores natively.
{
"event": "article.published",
"timestamp": "2026-04-16T12:00:00.000Z",
"article": {
"title": "How We Doubled Organic Traffic in 90 Days",
"slug": "how-we-doubled-organic-traffic",
"metaDescription": "The exact playbook we used to 2x traffic...",
"contentMd": "# How We Doubled...\n\nLorem ipsum dolor sit amet...",
"contentHtml": "<h1>How We Doubled...</h1><p>Lorem ipsum...</p>",
"tags": ["seo", "case-study", "growth"],
"categories": ["Marketing"],
"publishedAt": "2026-04-16T12:00:00.000Z"
},
"cmsPostId": "42"
}| Parameter | Type | Required | Description |
|---|---|---|---|
| event | string | Required | One of article.published, article.updated, webhook.test. |
| timestamp | string (ISO 8601) | Required | Time the event was dispatched, in UTC. |
| article | object | Optional | The full article payload. Omitted on webhook.test. Contents detailed below. |
| cmsPostId | string | Optional | Present on article.updated. This is whatever ID your endpoint returned the first time the article was published — use it to locate the existing record in your CMS. |
eventRequiredstring
One of article.published, article.updated, webhook.test.
timestampRequiredstring (ISO 8601)
Time the event was dispatched, in UTC.
articleOptionalobject
The full article payload. Omitted on webhook.test. Contents detailed below.
cmsPostIdOptionalstring
Present on article.updated. This is whatever ID your endpoint returned the first time the article was published — use it to locate the existing record in your CMS.
The article object
| Parameter | Type | Required | Description |
|---|---|---|---|
| title | string | Required | Human-readable title for the article. |
| slug | string | Required | URL-safe slug (lowercase, hyphens). |
| metaDescription | string | Required | SEO meta description, 150–160 chars. Ready to drop into a <meta> tag. |
| contentMd | string | Required | Full article body as markdown. |
| contentHtml | string | Required | Full article body as sanitized HTML. |
| tags | string[] | Required | Tag names. Create them in your CMS if they don't already exist. |
| categories | string[] | Required | Category names. Create them in your CMS if they don't already exist. |
| publishedAt | string (ISO 8601) | Required | Intended publish time in UTC. Usually equals timestamp. |
titleRequiredstring
Human-readable title for the article.
slugRequiredstring
URL-safe slug (lowercase, hyphens).
metaDescriptionRequiredstring
SEO meta description, 150–160 chars. Ready to drop into a <meta> tag.
contentMdRequiredstring
Full article body as markdown.
contentHtmlRequiredstring
Full article body as sanitized HTML.
tagsRequiredstring[]
Tag names. Create them in your CMS if they don't already exist.
categoriesRequiredstring[]
Category names. Create them in your CMS if they don't already exist.
publishedAtRequiredstring (ISO 8601)
Intended publish time in UTC. Usually equals timestamp.
Payload reference — blogseo-compat
The shape v2-UI targets use by default. Flat { article, main_image, website } body — designed to drop into any handler already written for the BlogSEO webhook. The event type arrives on the X-KwikScaleAI-Event request header; the body has no event key.
{
"article": {
"id": "e2c9f0a3-7d2e-4b9f-9b7a-2b3d4e5f6a7b",
"slug": "how-we-doubled-organic-traffic",
"title": "How We Doubled Organic Traffic in 90 Days",
"content": "# How We Doubled...\n\nLorem ipsum dolor sit amet...",
"format": "markdown",
"published_at": "2026-04-16T12:00:00.000Z",
"main_image_url": "https://cdn.kwikscaleai.com/articles/e2c9.../hero.webp",
"locale": "en-US",
"keyword": "organic traffic case study"
},
"main_image": {
"url": "https://cdn.kwikscaleai.com/articles/e2c9.../hero.webp",
"alt": "Chart showing traffic growth over 90 days"
},
"website": {
"id": "4a1b0c2d-3e4f-5a6b-7c8d-9e0f1a2b3c4d",
"baseUrl": "https://example.com"
}
}| Parameter | Type | Required | Description |
|---|---|---|---|
| article.id | string | null | Required | KwikScaleAI article UUID. Stable across updates — use it as your upsert key if cmsPostId tracking is inconvenient. |
| article.slug | string | Required | URL-safe slug. |
| article.title | string | Required | Human-readable article title. |
| article.content | string | Required | Full article body, rendered as either markdown or HTML depending on the target's contentFormat config. The format sibling tells you which. |
| article.format | 'markdown' | 'html' | Required | Which representation contentis in. Matches the target's configured contentFormat. |
| article.published_at | string (ISO 8601) | Required | Intended publish time in UTC. |
| article.main_image_url | string | null | Required | Mirror of main_image.url at the top level for receivers that expect a flat field. |
| article.locale | string | Required | BCP-47 language tag.Default: en-US |
| article.keyword | string | null | Required | Primary target keyword the article was optimised for. Null when the article was generated without keyword targeting. |
| main_image.url | string | null | Required | Hero image URL, or null if the article has none. |
| main_image.alt | string | null | Required | Alt text for the hero image. |
| website.id | string | null | Required | KwikScaleAI site UUID the article belongs to. |
| website.baseUrl | string | null | Required | The site's public base URL (no trailing slash). Useful if your endpoint serves multiple connected sites. |
article.idRequiredstring | null
KwikScaleAI article UUID. Stable across updates — use it as your upsert key if cmsPostId tracking is inconvenient.
article.slugRequiredstring
URL-safe slug.
article.titleRequiredstring
Human-readable article title.
article.contentRequiredstring
Full article body, rendered as either markdown or HTML depending on the target's contentFormat config. The format sibling tells you which.
article.formatRequired'markdown' | 'html'
Which representation contentis in. Matches the target's configured contentFormat.
article.published_atRequiredstring (ISO 8601)
Intended publish time in UTC.
article.main_image_urlRequiredstring | null
Mirror of main_image.url at the top level for receivers that expect a flat field.
article.localeRequiredstring
BCP-47 language tag.
Default: en-US
article.keywordRequiredstring | null
Primary target keyword the article was optimised for. Null when the article was generated without keyword targeting.
main_image.urlRequiredstring | null
Hero image URL, or null if the article has none.
main_image.altRequiredstring | null
Alt text for the hero image.
website.idRequiredstring | null
KwikScaleAI site UUID the article belongs to.
website.baseUrlRequiredstring | null
The site's public base URL (no trailing slash). Useful if your endpoint serves multiple connected sites.
Verifying webhook signatures
When you set a secret, KwikScaleAI signs every request with HMAC-SHA256 over the raw JSON body and sends the hex digest in X-KwikScaleAI-Signature, prefixed with sha256=. Always verify the signature before doing anything with the payload — a missing or wrong signature means the request isn't from us.
import { createHmac, timingSafeEqual } from "crypto";
export async function POST(req: Request) {
const secret = process.env.KWIKSCALE_WEBHOOK_SECRET!;
const signature = req.headers.get("x-kwikscaleai-signature") ?? "";
const body = await req.text(); // raw text — do not parse before verifying
const expected =
"sha256=" +
createHmac("sha256", secret).update(body, "utf8").digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return new Response("invalid signature", { status: 401 });
}
const { event, article, cmsPostId } = JSON.parse(body);
// ... handle event
return Response.json({ publishedUrl: "https://example.com/blog/" + article.slug });
}Expected response
Any 2xx status is success. You can return an empty body, a plain-text message, or JSON. If you return JSON, two fields are read back into KwikScaleAI:
{
"publishedUrl": "https://example.com/blog/how-we-doubled-organic-traffic",
"cmsPostId": "42"
}| Parameter | Type | Required | Description |
|---|---|---|---|
| publishedUrl | string | Optional | Public URL of the live article. Shown in the dashboard so your team can click through. Strongly recommended. |
| cmsPostId | string | Optional | Whatever identifier your CMS uses for this post. We'll send it back as cmsPostId on future article.updated events so you can locate the record to update. |
publishedUrlOptionalstring
Public URL of the live article. Shown in the dashboard so your team can click through. Strongly recommended.
cmsPostIdOptionalstring
Whatever identifier your CMS uses for this post. We'll send it back as cmsPostId on future article.updated events so you can locate the record to update.
Handling updates vs. creates
Treat the incoming event the same way you'd treat an idempotent upsert:
article.publishedwith nocmsPostId→ create a new record, return its ID incmsPostId.article.updatedwith acmsPostId→ locate the record by that ID, replace its contents.
If you receive an article.updatedfor a post you can't find (e.g. the record was deleted on your side), create a new one and return the new ID — KwikScaleAI will store it for the next update.
Testing your webhook
The dashboard has a “Test connection” button that sends a webhook.test event. The envelope omits the article field, so your handler should be defensive:
if (payload.event === "webhook.test") {
return Response.json({ ok: true });
}
// normal handling below — article is guaranteed hereTimeouts and retries
Default timeout is 10 seconds (configurable 1–60 s). If your endpoint takes longer than the configured timeout, the publish attempt fails with NETWORK_ERROR. In v1 there's no automatic retry — failures surface in the dashboard and can be retried manually.
Reserved headers
These headers are always set by KwikScaleAI. Any entries in additionalHeaders matching these keys (case-insensitive) are silently stripped:
AuthorizationContent-Type(alwaysapplication/json)User-Agent(alwaysKwikScaleAI-publishing/1.0)X-KwikScaleAI-SignatureX-KwikScaleAI-Event(always set; carries the event type forblogseo-compattargets and mirrors the body'seventfield forkwikscale-v1targets)
Security best practices
- Always set a secret. Running without one in production is equivalent to leaving the endpoint unauthenticated.
- Verify the signature on the raw body. Parsing JSON before verifying (and then rebuilding the body) almost always produces a different byte sequence and breaks the check.
- Use HTTPS only.We refuse non-HTTPS endpoints at config time, but also: don't terminate TLS at an unauthenticated proxy.
- Store secrets in env vars. Never commit them. Rotate them if they leak by generating a new one and updating the dashboard.
- Log the event but sanitize the body. Article contents can be large — consider logging only
event,article.slug, andcmsPostId.
Capabilities
Troubleshooting
I get NETWORK_ERROR on every attempt
curl -I {endpointUrl} from another server). If curl works but the dashboard fails, the issue is likely TLS — an expired certificate or a cert chain the Node fetch runtime rejects.Signature mismatch even though my secret is correct
Test connection works but real publishes fail
webhook.test envelope is tiny; real articles can be hundreds of KB. Check your body-parser limit — Express defaults to 100 KB, Next.js route handlers to 1 MB. Raise it to at least 5 MB.My endpoint returns non-JSON and I get a success with no publishedUrl
Content-Type contains application/json. Return JSON with publishedUrl to get the live-link shortcut in the dashboard.How do I handle CORS?
FAQ
How do I verify that a webhook came from KwikScaleAI?
What HTTP status code should my endpoint return?
Does KwikScaleAI retry failed webhook deliveries?
Can I override the Authorization or Content-Type header via additionalHeaders?
What's the difference between article.published and article.updated?
What's the maximum request size?
Can I change the signing algorithm to SHA-512?
Why doesn't my blogseo-compat receiver see the event field?
I lost my secret. How do I rotate it?
Related
- GitHub integration— for static-site workflows where you'd otherwise write a webhook.
- WordPress REST API — native WP adapter (no webhook needed).
- Integrations overview — compare every platform side-by-side.
Ready to automate publishing?
Connect your site once. KwikScaleAI researches, writes, and publishes SEO content on autopilot.