Open app

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.

Webhook one-time setup — developer pastes URL + Secret Key into theStacc dashboard, theStacc stores it encrypted
Webhook one-time setup — developer pastes URL + Secret Key into theStacc dashboard, theStacc stores it encrypted

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.

Webhook per-request signing flow — theStacc signs body with HMAC-SHA256 and sends X-Webhook-Signature header; receiver verifies and returns 200 OK with url and id
Webhook per-request signing flow — theStacc signs body with HMAC-SHA256 and sends X-Webhook-Signature header; receiver verifies and returns 200 OK with url and id

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

  1. Generate a secretopenssl rand -hex 32 (or any 32+ char random string).
  2. Add an env var to your hosting platform: STACC_WEBHOOK_SECRET=<the secret>.
  3. Add a database table for incoming blogs (schema templates below).
  4. Add a receiver route at e.g. POST /api/stacc-webhook (examples in 6 languages below).
  5. Deploy. Note the public URL.
  6. In theStacc: Settings → Publishing → Custom Webhook → enter URL + secret + click Test Connection. Green check = working.
  7. Click Sample Payload to validate your full publish handler with a fake blog (blog_id will start with preview-).
  8. 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#

PropertyBehavior
DeterministicSame secret + same body bytes → always the same signature
Body-boundEven one byte different in body → completely different signature
LengthAlways 64 hex characters (256 bits)
FormatLowercase 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.

EventWhen it firesIdempotent?Body shape
test.pingUser clicks Test Connection{ event, message, timestamp }
blog.publishedFirst successful publish of a blog✅ via blog_idFull blog payload (see below)
blog.updatedRe-publish or content sync of an existing blog✅ via blog_idFull blog payload — same shape as blog.published
blog.unpublishedUser unpublishes from theStacc{ event, blog_id, title }
blog.deletedUser 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:

FieldTypeAlways sent?Practical limitsWhat it is
eventstringenum: blog.published | blog.updated | blog.unpublished | blog.deleted | test.pingUse this to route in your handler.
blog_idstring (UUID)36 chars, or 44 chars if preview- prefixedtheStacc's stable identifier. Use as your dedup / foreign key.
titlestringtypically 50–120 charsBlog title. Not HTML-escaped — receiver renders it.
slugstringup to 50 chars, lowercase, hyphenatedURL-safe identifier. Stable across re-publishes unless user edits it.
contentstring (HTML)typically 4 000–15 000 charsFull blog HTML. Includes <h2>, <p>, <ul>, <a>, <img>, etc.
excerptstring | ""✅ (may be empty)typically 100–300 charsShort summary, plain text.
excerpt_shortstring | ""✅ (may be empty)hard cap ≤ 256 charsTruncated 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_titlestring | nullup to 70 chars (SEO best practice)Optimized SEO title. May equal title.
meta_descriptionstring | nullup to 160 chars (SEO best practice)Optimized SEO description.
featured_image_urlstring (URL) | nullhttps://cdn.thestacc.com/... or your uploaded imagePublic CDN URL. Always reachable.
categoriesstring[]✅ (may be [])typically 0–3 items, each ≤ 50 charsOne blog can have multiple categories.
tagsstring[]✅ (may be [])typically 0–10 items, each ≤ 30 charsLoose keywords.
keywordstring | nulltypically 1–8 wordsFocus keyword the blog targets.
published_atstring (ISO 8601 UTC)always Z-suffixed UTCWhen theStacc finalized this publish.
images{url, alt}[]✅ (may be [])typically 0–4 itemsNew — 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 TEXT columns 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"
}
FieldRequired forWhat theStacc does with it
HTTP status 2xxevery requestAnything 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 theStaccStored against the blog. If omitted, theStacc shows a *"Sent to webhook — your receiver didn't return a public URL"* warning.
idfuture updates / unpublishes to target the right CMS recordStored 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 images array listing every image URL in content, 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 read images are 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 from content before 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.
  • alt may be empty. Decorative images and images the generator didn't caption ship with alt: "". Pass it through verbatim to preserve accessibility on your re-hosted copy.
  • Backward compatible. Receivers that ignore images continue working unchanged — every other field is byte-for-byte identical to before this field was added.
  • data-image-id is not used. We intentionally do not inject any per-image identifier attribute into the <img> tags inside content — the existing HTML shape is preserved exactly. The URL itself is the stable identifier.
  • images[] mirrors content verbatim. URLs reflect exactly what's in each src= 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. The slug portion 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_short8 is the first 8 hex chars of the blog UUID and uuid8 is 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:

  1. Open each blog in theStacc → click Publish to re-publish.
  2. The webhook fires as blog.updated with the new images array.
  3. Your receiver runs the mirror loop above and updates the post (same blog_id → same CMS record).
  4. 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:

  1. Use blog_id as a dedup key. Even though theStacc doesn't auto-retry, the *user* might click Republish multiple times. UPSERT on blog_id so duplicate clicks converge on the same row instead of creating two posts.
  1. 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#

RuleValueWhy
Response timeout15 secondsSlow CMS writes will fail. Return 2xx fast.
HTTP status accepted2xx (200, 201, 202)3xx, 4xx, 5xx all fail the publish.
RedirectsRejectedAnti-SSRF: a public webhook receiver could 302 to internal addresses.
HTTPS requiredYeshttp:// URLs and private IP ranges rejected at save time.
Custom headersUp to 20 key/value pairsFor bearer tokens / API keys. Keys ≤ 100 chars, values ≤ 1000.
Webhook secret length16+ characters recommendedWe don't enforce a minimum, but anything shorter is brute-forceable.
Auto-retriesNoneA failed publish stays failed until the user clicks Republish.
Concurrent publishesPossibleIf a user bulk-publishes, your endpoint may receive multiple requests in parallel. Use UPSERT, not INSERT.
Payload sizeTypically < 50 KBDriven by content length. Hard cap is 1 MB on theStacc side.

Troubleshooting#

Concrete error → cause → fix:

What you see in theStaccCauseFix
"Could not reach the URL" on Test ConnectionReceiver not deployed, wrong URL, or blocked by firewallcurl 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 ConnectionSignature verification failing on your sideLikely re-serialization bug. Hash the raw body bytes, not parsed JSON.
"Webhook returned 4xx" on real publish but 2xx on TestYour handler errors on blog.published but not on test.pingRun 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 responseAdd url to your response JSON. theStacc displays it in the dashboard.
Publish "succeeds" but post never appears on siteYou returned 2xx without actually writing to your DBCheck your server logs — your handler probably threw an error after the response was sent.
"Webhook URL must be HTTPS" at save timeURL starts with http:// or points to localhost / 127.0.0.1 / 192.168.xDeploy publicly with HTTPS. For local dev, use ngrok or a similar tunnel.
Some publishes succeed, others time outYour handler is slow under loadMove the heavy work (image upload, search reindex) to a background queue. Respond 2xx in < 1s.

Security best practices#

  1. Always set a webhook secret. Without one, anyone who guesses your URL can post fake blogs.
  2. Use crypto.timingSafeEqual / hmac.compare_digest when comparing signatures — plain === is timing-attack vulnerable.
  3. Hash the raw body, not the parsed JSON. Re-serialization changes whitespace and breaks signatures.
  4. Store the secret in env vars only. Never commit to git, never include in client-side code.
  5. 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.
  6. Run your receiver behind HTTPS-only. Add HSTS if you control the domain.
  7. 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#