Publishing · Developer reference

Custom Webhook

Available

The 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

  1. In the KwikScaleAI dashboard, open Integrations → Custom Webhook → Connect.
  2. Paste your endpoint URL (must start with https://).
  3. 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.
  4. Optionally adjust the timeout (default 10 s) and add any extra headers your endpoint needs.
  5. Click Test connection. KwikScaleAI sends a webhook.testevent. A 2xx response means you're done.

Configuration

endpointUrlRequired

string (URL)

Public HTTPS URL that will receive the webhook POST. Must start with https://.

secretOptional

string

HMAC-SHA256 signing secret. When set, KwikScaleAI sends X-KwikScaleAI-Signature: sha256=<hex>. Strongly recommended for production — skip only for internal testing.

timeoutMsOptional

integer

Maximum time to wait for your endpoint to respond before aborting. Accepts 1000–60000.

Default: 10000

additionalHeadersOptional

Record<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 (includes cmsPostId).
  • webhook.test— the “Test connection” button was pressed. Envelope has no article field.

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.

POST {endpointUrl}
{
  "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"
}
eventRequired

string

One of article.published, article.updated, webhook.test.

timestampRequired

string (ISO 8601)

Time the event was dispatched, in UTC.

articleOptional

object

The full article payload. Omitted on webhook.test. Contents detailed below.

cmsPostIdOptional

string

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

titleRequired

string

Human-readable title for the article.

slugRequired

string

URL-safe slug (lowercase, hyphens).

metaDescriptionRequired

string

SEO meta description, 150–160 chars. Ready to drop into a <meta> tag.

contentMdRequired

string

Full article body as markdown.

contentHtmlRequired

string

Full article body as sanitized HTML.

tagsRequired

string[]

Tag names. Create them in your CMS if they don't already exist.

categoriesRequired

string[]

Category names. Create them in your CMS if they don't already exist.

publishedAtRequired

string (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.

app/api/webhook/route.ts
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:

Response body (optional, all fields optional)
{
  "publishedUrl": "https://example.com/blog/how-we-doubled-organic-traffic",
  "cmsPostId": "42"
}
publishedUrlOptional

string

Public URL of the live article. Shown in the dashboard so your team can click through. Strongly recommended.

cmsPostIdOptional

string

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.published with no cmsPostId → create a new record, return its ID in cmsPostId.
  • article.updated with a cmsPostId → 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:

typescript
if (payload.event === "webhook.test") {
  return Response.json({ ok: true });
}
// normal handling below — article is guaranteed here

Timeouts 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:

  • Authorization
  • Content-Type (always application/json)
  • User-Agent (always KwikScaleAI-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, and cmsPostId.

Capabilities

Update existing
Draft
Tags
Categories
Featured image
Scheduled publish
Delete

Troubleshooting

I get NETWORK_ERROR on every attempt
Either your endpoint is unreachable or it's timing out. Check from a third-party host (e.g. 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
You're most likely parsing the body before hashing. HMAC must run on the exact bytes we sent. In Node, use req.text() (not req.json()); in Express, add express.raw() for the webhook route; in Python, use request.get_data() not request.get_json(); in PHP, read from php://input before touching $_POST.
Test connection works but real publishes fail
The 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
That's expected. Any 2xx is success; we only parse JSON if the response's Content-Type contains application/json. Return JSON with publishedUrl to get the live-link shortcut in the dashboard.
How do I handle CORS?
You don't. Webhooks are server-to-server — no browser involved, no CORS applies. If you're seeing CORS errors, you've probably wired the webhook URL into a client-side fetch by mistake.

FAQ

How do I verify that a webhook came from KwikScaleAI?
Compute HMAC-SHA256 of the raw request body using the secret you configured. Compare the hex digest (prefixed with sha256=) to the X-KwikScaleAI-Signature header using a constant-time comparison. Reject any request where the signature is missing, malformed, or doesn't match.
What HTTP status code should my endpoint return?
Any 2xx response counts as success. Return 4xx for permanent failures (validation errors, auth problems) and 5xx for transient issues — though we do not currently auto-retry. If you return JSON with a publishedUrl field, we'll store it and link to the live article in the dashboard.
Does KwikScaleAI retry failed webhook deliveries?
Not automatically in v1. Failed deliveries are logged as errored publishing attempts and surfaced in the dashboard — you can trigger a manual retry from there. Automatic retries with exponential backoff are planned.
Can I override the Authorization or Content-Type header via additionalHeaders?
No. The keys authorization, content-type, user-agent, and x-kwikscaleai-signature are reserved. Entries in additionalHeaders matching those keys (case-insensitive) are silently dropped. Use the secret field for authentication — or add a custom non-reserved header like X-My-Token.
What's the difference between article.published and article.updated?
article.published is sent the first time an article reaches your endpoint. article.updated is sent when KwikScaleAI updates an already-published article — the envelope includes a cmsPostId field so you know which record to replace. Treat both as idempotent upserts if in doubt.
What's the maximum request size?
Article bodies can be large — hundreds of KB of HTML for long-form content. Make sure your endpoint's body parser allows at least 5 MB to be safe.
Can I change the signing algorithm to SHA-512?
Not yet. v1 is hardcoded to HMAC-SHA256 for simplicity. Algorithm selection is on the roadmap.

Related

Ready to automate publishing?

Connect your site once. KwikScaleAI researches, writes, and publishes SEO content on autopilot.

Get started free