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://). - Generate a random secret (e.g.
openssl rand -hex 32) and paste it into Secret. Save a copy somewhere safe — you'll need it on your server. - Optionally adjust the timeout (default 10 s) and add any extra headers your endpoint needs.
- Click Test connection. KwikScaleAI sends a
webhook.testevent. 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) are silently stripped. |
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) are silently stripped.
Webhook events
Every request includes an event field. There are three values in v1:
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. Envelope has noarticlefield.
Payload reference
The full envelope that will hit your endpoint. 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.
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-Signature
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?
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.