Webhook Reference
Production reference for the Custom Webhook integration — handshake, signing, every event, every field, response contract, database schemas, and receiver examples in 6 languages.
This is the end-to-end reference for theStacc's Custom Webhook integration. It covers everything a developer needs to ship a production receiver: how authentication works, every event we send, every field we put in the payload, what to return, and ready-to-paste receiver + database schema examples.
Quick orientation: if you're configuring this from theStacc's dashboard, start at Connect Platforms → Custom Webhook. This page is for the developer who needs to build (or audit) the receiving endpoint.
When to use a webhook#
Choose the Custom Webhook integration when:
- Your site is built on a custom stack — Next.js, Astro, Remix, SvelteKit, Rails, Django, Laravel, etc. (anything that's not WordPress / Ghost / Webflow / Shopify).
- You want full control over what happens when a blog publishes — write to your own DB, trigger a rebuild, push to a CDN, fan out to other systems.
- You already have a CMS but want theStacc to write into it via your own API, not theStacc's direct integrations.
If you use WordPress, Ghost, Webflow, or Shopify, use those integrations instead — they handle authentication, image uploads, and field mapping for you.
How the handshake works#
theStacc uses stateless per-request authentication via a shared secret — the same model as Stripe, GitHub, and Slack webhooks. There is no OAuth dance, no token exchange, no TLS-mutual-auth. The "handshake" happens once during setup: both sides record the same secret, then every subsequent request is independently signed and verified.
Step 1 — One-time setup#
The developer pastes a Webhook URL + Secret Key into the Settings → Publishing → Add Webhook dialog. theStacc stores the secret encrypted; the developer keeps a copy in an env var on their server. After this, both sides hold the same secret.

Step 2 — Per request (every blog publish, sync, unpublish, delete)#
theStacc serializes the payload to JSON, computes sig = HMAC-SHA256(secret, body), and POSTs the body to your webhook URL with the signature in the X-Webhook-Signature header. Your receiver reads the raw body bytes (not the parsed JSON), recomputes the same HMAC with its own copy of the secret, and compares. If the two signatures match, the request is authentic — process it and respond 200 with the live URL + your CMS id. If they don't, return 401.

Both sides compute the same signature independently. If they match, the receiver knows the request really came from theStacc and wasn't modified in transit. That's the entire authentication mechanism.
The shared secret is the entire defense. Anyone who guesses your webhook URL but doesn't know the secret cannot forge a request. Treat it like a database password — store in env vars, rotate if leaked, never commit to git.
30-minute production setup#
A field-tested path from "we picked Webhook" to "first blog publishes live":
- Generate a secret —
openssl rand -hex 32(or any 32+ char random string). - Add an env var to your hosting platform:
STACC_WEBHOOK_SECRET=<the secret>. - Add a database table for incoming blogs (schema templates below).
- Add a receiver route at e.g.
POST /api/stacc-webhook(examples in 6 languages below). - Deploy. Note the public URL.
- In theStacc: Settings → Publishing → Custom Webhook → enter URL + secret + click Test Connection. Green check = working.
- Click Sample Payload to validate your full publish handler with a fake blog (
blog_idwill start withpreview-). - Approve any blog and publish. First real blog should land in your DB within a few seconds.
If anything fails, the Troubleshooting section below covers every error we've seen.
Authentication: HMAC-SHA256 signing#
When you configure a webhook secret in theStacc, every request includes a signature in the X-Webhook-Signature header.
How theStacc generates the signature#
# Exactly what runs in production (services/blog/.../api/v1/blogs.py)
import hmac, hashlib, json
payload_bytes = json.dumps(payload, separators=(',', ':')).encode()
signature = hmac.new(
secret.encode(),
payload_bytes,
hashlib.sha256,
).hexdigest()
headers = {
"Content-Type": "application/json",
"X-Webhook-Signature": signature, # 64-char hex string
}
Critical detail: the JSON is serialized with separators=(',', ':') — compact, no whitespace. This produces a different byte stream than json.dumps(payload) (which adds spaces). Your receiver MUST hash the raw bytes received over the wire, not parse-and-re-serialize the JSON, or signatures won't match.
Properties of the signature#
| Property | Behavior |
|---|---|
| Deterministic | Same secret + same body bytes → always the same signature |
| Body-bound | Even one byte different in body → completely different signature |
| Length | Always 64 hex characters (256 bits) |
| Format | Lowercase hex, no sha256= prefix |
Replay-attack note#
HMAC alone doesn't prevent replay — if an attacker captures a signed request, the signature still validates if they replay the exact same bytes. theStacc embeds a fresh published_at UTC timestamp in every blog event, so receivers can defend by rejecting requests older than ~5 minutes:
const sentAt = new Date(body.published_at);
const ageSec = (Date.now() - sentAt.getTime()) / 1000;
if (ageSec > 300) return new Response('Too old, possible replay', { status: 401 });
For most receivers this isn't necessary — the dominant threat is "someone guessed our webhook URL" and HMAC fully defends against that.
All events theStacc emits#
Five event types. Branch on the event field at the top of your handler.
| Event | When it fires | Idempotent? | Body shape |
|---|---|---|---|
test.ping | User clicks Test Connection | ✅ | { event, message, timestamp } |
blog.published | First successful publish of a blog | ✅ via blog_id | Full blog payload (see below) |
blog.updated | Re-publish or content sync of an existing blog | ✅ via blog_id | Full blog payload — same shape as blog.published |
blog.unpublished | User unpublishes from theStacc | ✅ | { event, blog_id, title } |
blog.deleted | User deletes a published blog | ✅ | { event, blog_id, title } |
A note on preview- prefixed blog_ids: when the user clicks Sample Payload in the dashboard, theStacc fires a real-shaped blog.published event but with blog_id: "preview-00000000-0000-0000-0000-000000000000". Your handler should detect the prefix and skip the actual database write so test runs don't pollute production data.
test.ping payload#
{
"event": "test.ping",
"message": "This is a test from theStacc",
"timestamp": "2026-04-30T12:00:00Z"
}
Respond 200 {"ok": true}. Do not require title, slug, or content for this event — there are none.
blog.published / blog.updated payload#
{
"event": "blog.published",
"blog_id": "8f3e1d2c-49ab-4d10-9e7f-7c0bf298faa4",
"title": "10 SEO mistakes to avoid in 2026",
"slug": "10-seo-mistakes-to-avoid-in-2026",
"content": "<h2>Introduction</h2><p>If you've been running SEO...</p>",
"excerpt": "A short summary of the blog post.",
"excerpt_short": "A short summary of the blog post.",
"meta_title": "10 SEO mistakes to avoid in 2026",
"meta_description": "Avoid these 10 common SEO pitfalls...",
"featured_image_url": "https://cdn.thestacc.com/blogs/abc123.jpg",
"categories": ["SEO"],
"tags": ["seo", "2026", "marketing"],
"keyword": "seo mistakes 2026",
"published_at": "2026-04-30T12:00:00Z",
"images": [
{ "url": "https://cdn.thestacc.com/blog_images/proj-x/blog-y/illustration-1.png", "alt": "How keyword cannibalization happens" },
{ "url": "https://cdn.thestacc.com/blog_images/proj-x/blog-y/illustration-2.png", "alt": "Site audit checklist diagram" }
]
}
blog.unpublished / blog.deleted payload#
{
"event": "blog.unpublished",
"blog_id": "8f3e1d2c-49ab-4d10-9e7f-7c0bf298faa4",
"title": "10 SEO mistakes to avoid in 2026"
}
For these events, look up the post in your CMS using the blog_id (which you should have stored when handling the original blog.published) and delete or hide it.
Field reference#
Every field theStacc sends in a blog.published / blog.updated event:
| Field | Type | Always sent? | Practical limits | What it is |
|---|---|---|---|---|
event | string | ✅ | enum: blog.published | blog.updated | blog.unpublished | blog.deleted | test.ping | Use this to route in your handler. |
blog_id | string (UUID) | ✅ | 36 chars, or 44 chars if preview- prefixed | theStacc's stable identifier. Use as your dedup / foreign key. |
title | string | ✅ | typically 50–120 chars | Blog title. Not HTML-escaped — receiver renders it. |
slug | string | ✅ | up to 50 chars, lowercase, hyphenated | URL-safe identifier. Stable across re-publishes unless user edits it. |
content | string (HTML) | ✅ | typically 4 000–15 000 chars | Full blog HTML. Includes <h2>, <p>, <ul>, <a>, <img>, etc. |
excerpt | string | "" | ✅ (may be empty) | typically 100–300 chars | Short summary, plain text. |
excerpt_short | string | "" | ✅ (may be empty) | hard cap ≤ 256 chars | Truncated copy of excerpt (word-boundary trimmed, ellipsis appended when shorter). Use this when forwarding to a CMS with a strict Excerpt cap — notably Webflow's default 256-char Excerpt collection field. Falls back to meta_description when excerpt is empty. |
meta_title | string | null | ✅ | up to 70 chars (SEO best practice) | Optimized SEO title. May equal title. |
meta_description | string | null | ✅ | up to 160 chars (SEO best practice) | Optimized SEO description. |
featured_image_url | string (URL) | null | ✅ | https://cdn.thestacc.com/... or your uploaded image | Public CDN URL. Always reachable. |
categories | string[] | ✅ (may be []) | typically 0–3 items, each ≤ 50 chars | One blog can have multiple categories. |
tags | string[] | ✅ (may be []) | typically 0–10 items, each ≤ 30 chars | Loose keywords. |
keyword | string | null | ✅ | typically 1–8 words | Focus keyword the blog targets. |
published_at | string (ISO 8601 UTC) | ✅ | always Z-suffixed UTC | When theStacc finalized this publish. |
images | {url, alt}[] | ✅ (may be []) | typically 0–4 items | New — May 2026. Every <img> URL embedded in content body, in document order, deduped. Use to self-host body images: iterate, download from url, upload to your storage, then content.replaceAll('src="{url}"', 'src="{new_url}"'). Does not include featured_image_url — that's a separate top-level field and most CMSes already have a dedicated hero slot for it. See Self-hosting images below. |
Field length is not enforced at the API level. Postgres
TEXTcolumns are unbounded, and theStacc-generated content stays well within the practical limits above. If you want hard caps in your DB schema, the limits in the table are safe defaults.
Response contract#
Your receiver MUST return a JSON body. theStacc reads two fields:
{
"ok": true,
"url": "https://your-cms.com/blog/10-seo-mistakes-to-avoid-in-2026",
"id": "your-internal-cms-post-id"
}
| Field | Required for | What theStacc does with it |
|---|---|---|
HTTP status 2xx | every request | Anything else (4xx, 5xx, 3xx) marks the publish as failed and surfaces an error toast in theStacc. |
url (or published_url) | a clickable "View live post" link to appear in theStacc | Stored against the blog. If omitted, theStacc shows a *"Sent to webhook — your receiver didn't return a public URL"* warning. |
id | future updates / unpublishes to target the right CMS record | Stored as external_post_id. Sent back unchanged on subsequent blog.updated / blog.unpublished events. |
For preview / test calls (test.ping or blog_id starts with preview-), it's fine to skip both fields and return {"ok": true, "skipped": true}.
Self-hosting images (optional)#
New — May 18, 2026. The payload now carries an additive
imagesarray listing every image URL incontent, making it straightforward to mirror blog body images into your own storage as part of your CMS-side processing. Backward compatible: receivers that don't readimagesare unaffected; every other field is byte-for-byte identical to before this addition. This entire section is what's new on the page — if you've integrated previously, this is the only change you need to read.
By default, every <img> inside content and the featured_image_url point at theStacc's CDN, served to your visitors directly. That's the simplest setup and works out of the box.
For deeper CMS integration, many sites prefer to host blog images on their own storage alongside the rest of their media library — the images array makes that mirror in one pass, with no HTML parsing required.
When self-hosting helps#
- Same origin as the rest of your site. Images load from your CDN / domain, sharing the same caching, latency, and edge-rules as the surrounding page.
- Cleaner CMS integration. Images live inside your media library — searchable, taggable, and re-usable in other content the same way as any other asset.
- Self-contained CMS records. Once mirrored, each post is wholly owned by your CMS with no external image references to track in your asset audits.
How it works#
The images field is a list of {url, alt} objects — one per <img> in the content body, in document order, with duplicates collapsed. Each url is byte-for-byte identical to what's in the corresponding src= attribute inside content, so a plain string find/replace handles the swap reliably.
let content = body.content;
// 1. Mirror every body image.
for (const img of body.images || []) {
const bytes = await fetch(img.url).then(r => r.arrayBuffer());
const newUrl = await uploadToMyStorage(bytes, suggestFilename(img.url));
// The URL string IS the stable identifier — find/replace by full src=.
content = content.replaceAll(`src="${img.url}"`, `src="${newUrl}"`);
}
// 2. Mirror the featured image separately. It's a top-level field, not
// in images[] — most CMSes have a dedicated featured-image slot.
let featuredUrl = body.featured_image_url;
if (featuredUrl) {
const bytes = await fetch(featuredUrl).then(r => r.arrayBuffer());
featuredUrl = await uploadToMyStorage(bytes, suggestFilename(featuredUrl));
}
// 3. Store `content` (with rewritten src=) and `featuredUrl` in your CMS.
That's the entire pattern. No HTML parser, no DOM library, no positional tracking — the URL is the identifier.
Python receiver#
content = body["content"]
# 1. Mirror body images.
for img in body.get("images", []):
blob = httpx.get(img["url"]).content
new_url = upload_to_my_storage(blob, suggest_filename(img["url"]))
content = content.replace(f'src="{img["url"]}"', f'src="{new_url}"')
# 2. Mirror the featured image separately.
featured_url = body.get("featured_image_url")
if featured_url:
blob = httpx.get(featured_url).content
featured_url = upload_to_my_storage(blob, suggest_filename(featured_url))
# 3. Persist content + featured_url to your CMS.
Security: allowlist your fetch host#
Whenever a server-side handler downloads URLs from any external source, standard practice is to allowlist trusted hosts before issuing the request. For theStacc images, the trusted host is cdn.thestacc.com:
const ALLOWED_HOSTS = new Set(['cdn.thestacc.com']);
async function safeFetch(url) {
const parsed = new URL(url);
if (parsed.protocol !== 'https:') throw new Error(`Refusing non-HTTPS URL: ${url}`);
if (!ALLOWED_HOSTS.has(parsed.hostname)) throw new Error(`Untrusted host: ${parsed.hostname}`);
return fetch(url);
}
for (const img of body.images || []) {
const bytes = await safeFetch(img.url).then(r => r.arrayBuffer());
// …then upload + find/replace as before.
}
Same shape in Python:
from urllib.parse import urlparse
ALLOWED_HOSTS = {"cdn.thestacc.com"}
def safe_fetch(url: str) -> bytes:
parsed = urlparse(url)
if parsed.scheme != "https":
raise ValueError(f"Refusing non-HTTPS URL: {url}")
if parsed.hostname not in ALLOWED_HOSTS:
raise ValueError(f"Untrusted host: {parsed.hostname}")
return httpx.get(url).content
Apply the same allowlist to featured_image_url. With it in place, every body and featured image fetches through a single auditable choke point.
Notes#
- Featured image is NOT in
images[]. It's a separate top-level field (featured_image_url). theStacc strips the inline duplicate fromcontentbefore sending so the page doesn't render the same image twice. - Duplicate URLs are deduped. If the same body image appears twice, the array lists it once. Your
content.replaceAll(…)swaps every occurrence in one pass anyway. altmay be empty. Decorative images and images the generator didn't caption ship withalt: "". Pass it through verbatim to preserve accessibility on your re-hosted copy.- Backward compatible. Receivers that ignore
imagescontinue working unchanged — every other field is byte-for-byte identical to before this field was added. data-image-idis not used. We intentionally do not inject any per-image identifier attribute into the<img>tags insidecontent— the existing HTML shape is preserved exactly. The URL itself is the stable identifier.images[]mirrorscontentverbatim. URLs reflect exactly what's in eachsrc=attribute, so a one-to-one find/replace works without any normalization step. The host allowlist above handles any unusual value (relative path,data:URI, etc.) uniformly without per-shape special-casing.- SEO-friendly, globally unique basenames — flat storage works. Body images use slug-based basenames like
{slug}-{blog_id_short8}-{uuid8}.png— e.g.solar-installation-rajasthan-rooftop-5d0035a4-2c857f8b.png. Theslugportion is derived from the image's alt text (capped at 60 chars, lowercase, ASCII-only) so receivers that use the URL as their CDN-facing filename get SEO value for free.blog_id_short8is the first 8 hex chars of the blog UUID anduuid8is a random 8-hex suffix — together 64 bits of entropy, so the basename alone is guaranteed unique across every blog you ever receive. If your CMS or storage layer prefers a flat directory (no per-blog subfolders),url.split('/').pop()gives you a collision-free key directly. Images uploaded before each naming-convention change keep their original basenames — receivers handle all formats transparently because the find/replace mirror loop operates on the full URL string regardless of basename shape. Historical formats you may encounter on older blogs:{blog_id}_{YYYYMMDD_HHMMSS}_{8-hex}.png(May 18 – May 20 2026) and{YYYYMMDD_HHMMSS}_{8-hex}.png(pre-May 18 2026, Gemini-era).
Migrating blogs already published with theStacc's URLs#
If you have blogs already in your CMS with theStacc CDN URLs and you want to switch them to self-hosted images:
- Open each blog in theStacc → click Publish to re-publish.
- The webhook fires as
blog.updatedwith the newimagesarray. - Your receiver runs the mirror loop above and updates the post (same
blog_id→ same CMS record). - The CMS post now stores your URLs instead of theStacc's.
There's no bulk-republish API — for most accounts this is a one-shot migration of a handful of posts.
Database schema templates#
Pick the database you already use. Each schema below maps directly to the webhook payload — no transformation needed.
Postgres#
CREATE TABLE thestacc_blogs (
blog_id UUID PRIMARY KEY,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
content TEXT NOT NULL,
excerpt TEXT,
meta_title TEXT,
meta_description TEXT,
featured_image_url TEXT,
keyword TEXT,
categories TEXT[] NOT NULL DEFAULT '{}',
tags TEXT[] NOT NULL DEFAULT '{}',
published_at TIMESTAMPTZ NOT NULL,
last_event TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
cms_post_id TEXT,
is_unpublished BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX idx_thestacc_blogs_slug ON thestacc_blogs(slug);
CREATE INDEX idx_thestacc_blogs_published_at ON thestacc_blogs(published_at DESC);
CREATE INDEX idx_thestacc_blogs_active ON thestacc_blogs(published_at DESC) WHERE is_unpublished = FALSE;
MySQL / MariaDB#
CREATE TABLE thestacc_blogs (
blog_id CHAR(36) PRIMARY KEY,
title VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
content LONGTEXT NOT NULL,
excerpt TEXT,
meta_title VARCHAR(255),
meta_description VARCHAR(500),
featured_image_url VARCHAR(2048),
keyword VARCHAR(255),
categories JSON NOT NULL,
tags JSON NOT NULL,
published_at DATETIME(3) NOT NULL,
last_event VARCHAR(50) NOT NULL,
received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
cms_post_id VARCHAR(255),
is_unpublished BOOLEAN NOT NULL DEFAULT FALSE,
INDEX idx_slug (slug),
INDEX idx_published_at (published_at DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Prisma (Postgres-flavored)#
model ThestaccBlog {
blogId String @id @map("blog_id") @db.Uuid
title String
slug String @unique
content String
excerpt String?
metaTitle String? @map("meta_title")
metaDescription String? @map("meta_description")
featuredImageUrl String? @map("featured_image_url")
keyword String?
categories String[]
tags String[]
publishedAt DateTime @map("published_at")
lastEvent String @map("last_event")
receivedAt DateTime @default(now()) @map("received_at")
updatedAt DateTime @updatedAt @map("updated_at")
cmsPostId String? @map("cms_post_id")
isUnpublished Boolean @default(false) @map("is_unpublished")
@@index([slug])
@@index([publishedAt(sort: Desc)])
@@map("thestacc_blogs")
}
MongoDB#
db.createCollection("thestaccBlogs", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["blog_id", "title", "slug", "content", "published_at", "last_event"],
properties: {
blog_id: { bsonType: "string" },
title: { bsonType: "string" },
slug: { bsonType: "string" },
content: { bsonType: "string" },
excerpt: { bsonType: ["string", "null"] },
meta_title: { bsonType: ["string", "null"] },
meta_description: { bsonType: ["string", "null"] },
featured_image_url: { bsonType: ["string", "null"] },
keyword: { bsonType: ["string", "null"] },
categories: { bsonType: "array", items: { bsonType: "string" } },
tags: { bsonType: "array", items: { bsonType: "string" } },
published_at: { bsonType: "date" },
last_event: { bsonType: "string" },
cms_post_id: { bsonType: ["string", "null"] },
is_unpublished: { bsonType: "bool" }
}
}
}
});
db.thestaccBlogs.createIndex({ blog_id: 1 }, { unique: true });
db.thestaccBlogs.createIndex({ slug: 1 }, { unique: true });
db.thestaccBlogs.createIndex({ published_at: -1 });
Receiver examples#
Each example is production-ready — handles every event type, verifies the HMAC signature, dedupes via blog_id, and returns the response shape theStacc expects.
Next.js (App Router)#
// app/api/stacc-webhook/route.ts
import crypto from 'crypto';
const SECRET = process.env.STACC_WEBHOOK_SECRET!;
export async function POST(request: Request) {
// CRITICAL: read raw bytes BEFORE parsing JSON.
// request.json() consumes the stream and re-serializing
// would change byte order/whitespace → signature mismatch.
const rawBody = await request.text();
const sig = request.headers.get('x-webhook-signature') || '';
const expected = crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
// Decode both before comparing. Buffer.from with invalid hex returns
// a shorter buffer (Node stops at the first non-hex char) which would
// make timingSafeEqual throw RangeError instead of returning false —
// crashing the receiver on any garbage probe.
const sigBuf = Buffer.from(sig, 'hex');
const expBuf = Buffer.from(expected, 'hex');
const valid =
sigBuf.length === expBuf.length
&& crypto.timingSafeEqual(sigBuf, expBuf);
if (!valid) {
return new Response('Invalid signature', { status: 401 });
}
const body = JSON.parse(rawBody);
if (body.event === 'test.ping') {
return Response.json({ ok: true });
}
if (body.event === 'blog.published' || body.event === 'blog.updated') {
if (body.blog_id?.startsWith('preview-')) {
return Response.json({ ok: true, skipped: true });
}
const post = await db.thestaccBlog.upsert({
where: { blogId: body.blog_id },
create: {
blogId: body.blog_id,
title: body.title,
slug: body.slug,
content: body.content,
excerpt: body.excerpt || null,
metaTitle: body.meta_title || null,
metaDescription: body.meta_description || null,
featuredImageUrl: body.featured_image_url || null,
keyword: body.keyword || null,
categories: body.categories || [],
tags: body.tags || [],
publishedAt: new Date(body.published_at),
lastEvent: body.event,
},
update: {
title: body.title,
slug: body.slug,
content: body.content,
excerpt: body.excerpt || null,
metaTitle: body.meta_title || null,
metaDescription: body.meta_description || null,
featuredImageUrl: body.featured_image_url || null,
keyword: body.keyword || null,
categories: body.categories || [],
tags: body.tags || [],
publishedAt: new Date(body.published_at),
lastEvent: body.event,
isUnpublished: false,
},
});
return Response.json({
ok: true,
url: `https://example.com/blog/${post.slug}`,
id: post.blogId,
});
}
if (body.event === 'blog.unpublished' || body.event === 'blog.deleted') {
await db.thestaccBlog.update({
where: { blogId: body.blog_id },
data: { isUnpublished: true, lastEvent: body.event },
});
return Response.json({ ok: true });
}
return Response.json({ error: 'Unknown event' }, { status: 400 });
}
Express#
import express from 'express';
import crypto from 'crypto';
const app = express();
const SECRET = process.env.STACC_WEBHOOK_SECRET;
// CRITICAL: capture raw body BEFORE express.json() parses it.
app.post('/api/stacc-webhook',
express.raw({ type: 'application/json' }),
async (req, res) => {
const raw = req.body; // Buffer
const sig = req.get('x-webhook-signature') || '';
const expected = crypto.createHmac('sha256', SECRET).update(raw).digest('hex');
// Length-check the decoded buffers BEFORE timingSafeEqual —
// invalid hex produces a short buffer and timingSafeEqual throws
// on mismatched lengths.
const sigBuf = Buffer.from(sig, 'hex');
const expBuf = Buffer.from(expected, 'hex');
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
return res.status(401).send('Invalid signature');
}
const body = JSON.parse(raw.toString('utf8'));
// ... handle body.event as in the Next.js example
res.json({ ok: true });
}
);
Astro (server endpoint)#
// src/pages/api/stacc-webhook.ts
import type { APIRoute } from 'astro';
import crypto from 'crypto';
export const prerender = false; // server-rendered, not static
const SECRET = import.meta.env.STACC_WEBHOOK_SECRET;
export const POST: APIRoute = async ({ request }) => {
const raw = await request.text();
const sig = request.headers.get('x-webhook-signature') || '';
const expected = crypto.createHmac('sha256', SECRET).update(raw).digest('hex');
// Length-check decoded buffers before timingSafeEqual — invalid hex
// produces a short buffer and timingSafeEqual throws on length mismatch.
const sigBuf = Buffer.from(sig, 'hex');
const expBuf = Buffer.from(expected, 'hex');
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
return new Response('Invalid signature', { status: 401 });
}
const body = JSON.parse(raw);
// ... handle events as in Next.js example
return new Response(JSON.stringify({ ok: true }), {
headers: { 'Content-Type': 'application/json' },
});
};
Python / FastAPI#
import hmac, hashlib, os
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
SECRET = os.environ["STACC_WEBHOOK_SECRET"].encode()
@app.post("/api/stacc-webhook")
async def receive(request: Request):
raw = await request.body() # bytes — DON'T re-serialize
sig = request.headers.get("x-webhook-signature", "")
expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig):
raise HTTPException(401, "Invalid signature")
import json
body = json.loads(raw)
if body["event"] == "test.ping":
return {"ok": True}
if body["event"] in ("blog.published", "blog.updated"):
if body.get("blog_id", "").startswith("preview-"):
return {"ok": True, "skipped": True}
# ... upsert into your DB
return {
"ok": True,
"url": f"https://example.com/blog/{body['slug']}",
"id": body["blog_id"],
}
if body["event"] in ("blog.unpublished", "blog.deleted"):
# ... soft-delete by blog_id
return {"ok": True}
raise HTTPException(400, "Unknown event")
Python / Flask#
import hmac, hashlib, os
from flask import Flask, request, abort, jsonify
app = Flask(__name__)
SECRET = os.environ["STACC_WEBHOOK_SECRET"].encode()
@app.post("/api/stacc-webhook")
def receive():
raw = request.get_data()
sig = request.headers.get("X-Webhook-Signature", "")
expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig):
abort(401)
body = request.get_json(force=True)
# ... same event branching as FastAPI example
return jsonify({"ok": True})
PHP#
<?php
$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $raw, getenv('STACC_WEBHOOK_SECRET'));
if (!hash_equals($expected, $sig)) {
http_response_code(401);
exit('Invalid signature');
}
$body = json_decode($raw, true);
switch ($body['event']) {
case 'test.ping':
echo json_encode(['ok' => true]);
break;
case 'blog.published':
case 'blog.updated':
if (str_starts_with($body['blog_id'] ?? '', 'preview-')) {
echo json_encode(['ok' => true, 'skipped' => true]);
break;
}
// ... upsert into DB
echo json_encode([
'ok' => true,
'url' => "https://example.com/blog/{$body['slug']}",
'id' => $body['blog_id'],
]);
break;
case 'blog.unpublished':
case 'blog.deleted':
// ... soft-delete
echo json_encode(['ok' => true]);
break;
default:
http_response_code(400);
echo json_encode(['error' => 'Unknown event']);
}
Idempotency & retries#
theStacc retries are manual only. If your endpoint times out (15s) or returns 5xx, the publish fails immediately and the user sees a "Failed to publish" toast — the user clicks Republish to retry.
Two practical implications:
- Use
blog_idas a dedup key. Even though theStacc doesn't auto-retry, the *user* might click Republish multiple times. UPSERT onblog_idso duplicate clicks converge on the same row instead of creating two posts.
- Return 2xx fast, do heavy work async. If your CMS write involves image processing, search re-indexing, or CDN purging, run those in a background job after responding. A 15-second sync write will time out theStacc's call.
// Example: respond fast, queue the heavy work
export async function POST(request: Request) {
// ... verify signature, parse body ...
await db.thestaccBlog.upsert({ /* ... */ }); // fast — just writes the row
// Fire-and-forget: image upload, ISR revalidate, search index, etc.
enqueueBackgroundJob('process-blog', body.blog_id);
return Response.json({ ok: true, url, id });
}
Limits & operational rules#
| Rule | Value | Why |
|---|---|---|
| Response timeout | 15 seconds | Slow CMS writes will fail. Return 2xx fast. |
| HTTP status accepted | 2xx (200, 201, 202) | 3xx, 4xx, 5xx all fail the publish. |
| Redirects | Rejected | Anti-SSRF: a public webhook receiver could 302 to internal addresses. |
| HTTPS required | Yes | http:// URLs and private IP ranges rejected at save time. |
| Custom headers | Up to 20 key/value pairs | For bearer tokens / API keys. Keys ≤ 100 chars, values ≤ 1000. |
| Webhook secret length | 16+ characters recommended | We don't enforce a minimum, but anything shorter is brute-forceable. |
| Auto-retries | None | A failed publish stays failed until the user clicks Republish. |
| Concurrent publishes | Possible | If a user bulk-publishes, your endpoint may receive multiple requests in parallel. Use UPSERT, not INSERT. |
| Payload size | Typically < 50 KB | Driven by content length. Hard cap is 1 MB on theStacc side. |
Troubleshooting#
Concrete error → cause → fix:
| What you see in theStacc | Cause | Fix |
|---|---|---|
| "Could not reach the URL" on Test Connection | Receiver not deployed, wrong URL, or blocked by firewall | curl your URL from outside your network. Check hosting platform logs. |
| "Webhook returned redirect 301/302" | Your route redirects (e.g., HTTP→HTTPS, or trailing-slash redirect) | Configure theStacc with the FINAL URL. Receivers must respond directly with 2xx. |
| "Webhook returned 401" on Test Connection | Signature verification failing on your side | Likely re-serialization bug. Hash the raw body bytes, not parsed JSON. |
| "Webhook returned 4xx" on real publish but 2xx on Test | Your handler errors on blog.published but not on test.ping | Run Sample Payload in theStacc — it sends the real shape with preview- prefix so your full handler runs without writing real data. |
| "Sent to webhook — your receiver didn't return a public URL" | You returned 2xx but no url field in the response | Add url to your response JSON. theStacc displays it in the dashboard. |
| Publish "succeeds" but post never appears on site | You returned 2xx without actually writing to your DB | Check your server logs — your handler probably threw an error after the response was sent. |
| "Webhook URL must be HTTPS" at save time | URL starts with http:// or points to localhost / 127.0.0.1 / 192.168.x | Deploy publicly with HTTPS. For local dev, use ngrok or a similar tunnel. |
| Some publishes succeed, others time out | Your handler is slow under load | Move the heavy work (image upload, search reindex) to a background queue. Respond 2xx in < 1s. |
Security best practices#
- Always set a webhook secret. Without one, anyone who guesses your URL can post fake blogs.
- Use
crypto.timingSafeEqual/hmac.compare_digestwhen comparing signatures — plain===is timing-attack vulnerable. - Hash the raw body, not the parsed JSON. Re-serialization changes whitespace and breaks signatures.
- Store the secret in env vars only. Never commit to git, never include in client-side code.
- Rotate the secret on suspected leak. Generate a new one, update both your env var and theStacc's integration settings, redeploy. theStacc starts using the new secret on the next request.
- Run your receiver behind HTTPS-only. Add HSTS if you control the domain.
- Rate-limit the receiver. Even with HMAC, a flood of unauthenticated requests can DoS your endpoint while you reject them. Cloudflare / your hosting platform usually does this automatically.
FAQ — questions clients ask#
How does the handshake work?
There's no traditional handshake — it's stateless per-request authentication. The "handshake" is just storing a shared secret on both sides, once, during setup. Every subsequent request is independently authenticated via HMAC. See How the handshake works above.
What identifies a request as coming from theStacc?
The X-Webhook-Signature header verified against your secret. Nothing else is reliable — anyone can fake the body content, the User-Agent, or the source IP.
Does the signature change over time?
The signature is deterministic — same secret + same body bytes = same signature, every time. In practice every request has a unique signature because every payload contains a unique blog_id and published_at timestamp. See Properties of the signature.
What's the maximum title / slug / content length?
We don't enforce length limits at the API level — theStacc-generated content stays well within reasonable bounds (titles ~120 chars, slugs ~50 chars, content ~15 000 chars). If your CMS or DB needs hard caps, the Field reference table lists safe defaults.
What if my receiver is slow / down when a publish happens?
The publish fails for that specific blog. The user sees a "Failed to publish" toast and can click Republish once your endpoint is healthy. There's no automatic retry queue.
Can I get historical blogs through the webhook?
No — webhooks are forward-only. For backfills, use the Public Blog API which lets you fetch all your published blogs.
Do I need to handle test.ping separately?
Yes — it has no blog fields. If your handler tries to read title or content from a test.ping event, it'll error out and Test Connection will fail. Branch on event first.
Can I receive webhooks from staging and production into the same endpoint?
Yes. theStacc doesn't tag the source. If you need to differentiate, set different Authorization custom headers in each integration, or use different webhook URLs.
How do I rotate the webhook secret?
Generate a new secret, update your env var, redeploy, then update theStacc's integration. There's a brief window (< 30s) where one publish could fail if it lands between updates — schedule rotation during quiet hours, or use a temporary "accept either secret" check during the transition.
Can theStacc post to multiple webhook URLs?
Yes — set up multiple Custom Webhook integrations on the same project. Each gets its own URL, secret, and headers. theStacc fires every published blog at every active integration.
What happens if I delete a blog in theStacc?
We send event: "blog.deleted" with the blog_id and title. Your handler should remove or hide the post in your CMS. If the receiver fails, the blog stays deleted in theStacc but lingers on your site — periodic reconciliation via the Public Blog API is a defensive option.
Next steps#
- Set up the integration in Connect Platforms → Custom Webhook
- Reference the Public Blog API if you also want to backfill historical content
- See the Static Site Integration guide for static-site-specific patterns