Webhooks

Register an HTTPS endpoint and we POST a signed JSON body to it every time one of your posts reaches a lifecycle event. The payload carries the article twice — as MD2 markdown source and as rendered HTML — plus its SEO metadata, its contextual backlinks, and the URLs it lives at.

That makes this the "works with anything" path: Zapier, Make, n8n, a Slack relay, a static-site rebuild, another CMS, or a fifteen-line script. No SDK, no WordPress, no polling.

Events

EventWhen it fires
post.publishedA post becomes visible — the moment you approve it in review, or the moment auto-publish publishes it
post.pending_reviewGeneration finished on a blog without auto-publish, so an article is waiting for a human
post.unpublishedA published post was taken down — it stops being served, and its WordPress copy goes back to a draft
pingOnly from the Send test button. Never sent for real content, and not subscribable

post.pending_review is the one worth wiring into Slack: it carries the finished article, so a reviewer can read it where they already are and only open the dashboard to approve.

Subscribe to post.unpublished if anything downstream keeps a copy of the article. It carries the same post payload as post.published, with status now archived: that is the signal to drop the post from your own index, cache or mirror. Without it, a takedown here leaves your copy live — the one place we cannot reach. A post that is published again fires post.published a second time, with its original published_at.

The request

POST https://your-endpoint.example.com/hook
Content-Type: application/json
User-Agent: baas-webhooks/1
Baas-Signature: t=1755600000,v1=35002ec4c139aa19a23fb3b87644595af369203f…
Baas-Event: post.published
Baas-Delivery: 01994d5f-1e77-7a31-bd2b-9c0a3f5e2d10
Baas-Endpoint: 01994d5e-2b09-7f14-8c77-5a1e6b0d4c22

Baas-Delivery is the event id, and it is stable: it does not change across our retries or a manual resend from the dashboard. Record the ids you have processed and you can treat this channel as at-least-once, which is what it is.

Payload

Every event has the same envelope; only data changes.

{
  "id": "01994d5f-1e77-7a31-bd2b-9c0a3f5e2d10",
  "event": "post.published",
  "created": "2026-08-19T09:00:00Z",
  "api_version": "2026-08-19",
  "org_id": "01a015d1-31bc-7659-b631-9e0a093995ca",
  "site_id": "01a015d1-770a-776e-bb9e-92c4f6d48d87",
  "blog_id": "01a0160b-d40b-7182-b8ac-5fc11458e933",
  "endpoint_id": "01994d5e-2b09-7f14-8c77-5a1e6b0d4c22",
  "data": {
    "blog": {
      "id": "01a0160b-d40b-7182-b8ac-5fc11458e933",
      "name": "Engineering Blog",
      "base_path": "/blog",
      "locale": "en",
      "site_name": "Technical Inside",
      "domain": "technicalinside.com"
    },
    "post": {
      "id": "05e646c2-8c8b-49d5-84f6-787085af1ad3",
      "status": "published",
      "locale": "en",
      "slug": "kubernetes-cost-optimization",
      "title": "Kubernetes cost optimization",
      "excerpt": "Where the money actually goes.",
      "markdown": ":::callout{severity=info}\nMD2 source\n:::",
      "html": "<div class=\"md2-callout\">…</div>",
      "word_count": 1420,
      "reading_minutes": 7,
      "published_at": "2026-08-19T09:00:00Z",
      "created_at": "2026-08-19T08:41:02Z",
      "updated_at": "2026-08-19T09:00:00Z",
      "seo": {
        "title_tag": "Kubernetes cost optimization",
        "meta_description": "Where the money actually goes.",
        "canonical": "https://technicalinside.com/blog/kubernetes-cost-optimization"
      },
      "json_ld": { "@type": "BlogPosting" },
      "links": [
        {
          "url": "https://technicalinside.com/pricing",
          "anchor_text": "our pricing",
          "target_title": "Pricing",
          "position": 1
        }
      ],
      "urls": {
        "path": "/blog/kubernetes-cost-optimization",
        "canonical": "https://technicalinside.com/blog/kubernetes-cost-optimization",
        "render": "https://api.dailysmith.com/v1/render/pk_test_.../blog/kubernetes-cost-optimization",
        "fragment": "https://api.dailysmith.com/v1/render/pk_test_.../blog/kubernetes-cost-optimization?format=fragment"
      },
      "css": "/* blog theme */ .baas-wrap, [data-baas-blog] { --md2-bg: #0a1128; … }",
      "css_href": "https://api.dailysmith.com/v1/sdk/md2.css"
    }
  }
}

Fields worth explaining

FieldWhat to do with it
markdownThe MD2 source. Take this if you own a renderer — a static-site generator, another CMS, your own pipeline
htmlWhat our pipeline rendered. Take this if you just want to display the article
linksThe contextual backlinks woven into the body, for your own reporting
urls.canonicalWhere the post lives on your domain
urls.fragmentThe same content as JSON, if you would rather fetch than store
css / css_hrefStyling for MD2 components — see below
json_ldReady-made structured data for the page's <head>

Verifying the signature

Every request carries Baas-Signature: t=<unix-seconds>,v1=<hex>, where the hex is HMAC-SHA256 over the string <t>.<raw request body>, keyed with your endpoint's signing secret.

  1. Reject anything without a valid signature

    Anyone who learns your URL can POST to it. The signature is what tells our request from theirs.

  2. Check the timestamp

    Reject requests older than about 5 minutes — that is our recommended tolerance. The timestamp is inside the MAC, so a replayer cannot rewrite it.

  3. Answer quickly

    We wait 10 seconds for a response. Queue the work and return 2xx immediately rather than processing inline.

Node

import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.BAAS_WEBHOOK_SECRET;

// express.raw keeps the exact bytes — express.json() would not.
app.post('/hook', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('Baas-Signature') ?? '';
  const parts = Object.fromEntries(
    header.split(',').map((p) => p.trim().split('='))
  );
  if (!parts.t || !parts.v1) return res.status(400).end();

  // Replay window.
  const age = Math.abs(Date.now() / 1000 - Number(parts.t));
  if (!Number.isFinite(age) || age > 300) return res.status(400).end();

  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(`${parts.t}.`)
    .update(req.body)
    .digest('hex');

  // Constant-time: a plain === leaks the MAC one byte at a time.
  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(parts.v1, 'utf8');
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(400).end();
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Idempotency: event.id is stable across retries and manual resends.
  console.log(event.event, event.id, event.data.post?.title);
  res.status(200).json({ ok: true });
});

app.listen(9099);

Go

func verify(secret string, header string, body []byte, now time.Time) bool {
	var ts, sig string
	for _, part := range strings.Split(header, ",") {
		k, v, ok := strings.Cut(strings.TrimSpace(part), "=")
		if !ok {
			continue
		}
		switch k {
		case "t":
			ts = v
		case "v1":
			sig = v
		}
	}
	if ts == "" || sig == "" {
		return false
	}
	unix, err := strconv.ParseInt(ts, 10, 64)
	if err != nil {
		return false
	}
	if d := now.Sub(time.Unix(unix, 0)); d > 5*time.Minute || d < -5*time.Minute {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	fmt.Fprintf(mac, "%s.", ts)
	mac.Write(body)
	want := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(want), []byte(sig))
}

A test vector

Both snippets must produce this signature. If yours does not, the bug is in your implementation, not in the delivery.

InputValue
secretwhsec_test_secret
t1755600000
body{"id":"01994d5f-0000-7000-8000-000000000001","event":"post.published"}
v135002ec4c139aa19a23fb3b87644595af369203fd353e1440d2f365912edede0

Retries

We keep trying until you answer 2xx, on this schedule:

AttemptSent
1immediately
2+10 seconds
3+30 seconds
4+2 minutes
5+5 minutes
6+15 minutes
7+1 hour
8+3 hours

That is eight attempts across roughly four and a half hours. Every attempt reuses the same Baas-Delivery id and the same body, freshly signed.

Anything that is not a 2xx is retried — a 404, a 500, a timeout, a connection refused. The delivery log in the dashboard shows the status code your server returned and the first 2 KB of its response body, so you can debug from our side of the wire.

Every delivery is also visible in the blog's Webhooks card with a Resend button, which re-sends the original body under the original id.

Rotating the secret

Rotate secret issues a new one and shows it once. The change takes effect immediately — the very next delivery is signed with the new secret — so update your receiver first, or accept a short window of rejected requests.

We store the secret encrypted and genuinely cannot show it again. If you lose it, rotation is the only path back.

Zapier, Make and n8n

All three give you a URL to POST to; paste it into the endpoint field.

  • ZapierWebhooks by Zapier → Catch Raw Hook. Use the raw variant if you want to verify the signature; Catch Hook parses the body and loses the exact bytes.
  • MakeCustom webhook. Make shows the incoming structure after the first delivery, so press Send test while its "determine data structure" listener is running.
  • n8nWebhook node, method POST. On a self-hosted n8n, remember the URL has to be reachable from the public internet (see below).

Requirements and limits

Schemehttps only
AddressMust resolve to a public address — private, loopback, link-local and cloud-metadata ranges are refused, at DNS-resolution time
RedirectsNot followed. A 30x is recorded as the answer it is
Timeout10 seconds per attempt
Query stringsAllowed (Zapier and n8n URLs often carry one)
Credentials in the URLRefused — authenticate with the signature
Log retentionThe most recent 200 deliveries per endpoint
ScopeOne endpoint belongs to one blog. A site with several blogs registers one endpoint per blog; the payload carries blog_id, site_id and org_id so a single receiver can route them

Troubleshooting

What you seeWhat it means
Retrying with no status codeWe could not connect at all — DNS, TLS, firewall, or a timeout. The error text is in the log row
403 or 401 from your serverYour receiver is rejecting us. Check that you are comparing against the raw body, and that you are using the current secret
Signature never matchesAlmost always a re-serialized body. Verify the bytes as they arrived, before any JSON parsing
422 when adding an endpointThe URL failed validation — http, a private address, embedded credentials, or a #fragment
Endpoint went Paused on its ownYour server answered 410 Gone. Press Resume
Nothing arrives at allCheck the endpoint is Active and subscribed to the event you expect; post.published does not fire for posts still in review
enro