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
| Event | When it fires |
|---|---|
post.published | A post becomes visible — the moment you approve it in review, or the moment auto-publish publishes it |
post.pending_review | Generation finished on a blog without auto-publish, so an article is waiting for a human |
post.unpublished | A published post was taken down — it stops being served, and its WordPress copy goes back to a draft |
ping | Only 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
| Field | What to do with it |
|---|---|
markdown | The MD2 source. Take this if you own a renderer — a static-site generator, another CMS, your own pipeline |
html | What our pipeline rendered. Take this if you just want to display the article |
links | The contextual backlinks woven into the body, for your own reporting |
urls.canonical | Where the post lives on your domain |
urls.fragment | The same content as JSON, if you would rather fetch than store |
css / css_href | Styling for MD2 components — see below |
json_ld | Ready-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.
Reject anything without a valid signature Anyone who learns your URL can POST to it. The signature is what tells our request from theirs.
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.
Answer quickly We wait 10 seconds for a response. Queue the work and return
2xximmediately 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.
| Input | Value |
|---|---|
| secret | whsec_test_secret |
t | 1755600000 |
| body | {"id":"01994d5f-0000-7000-8000-000000000001","event":"post.published"} |
v1 | 35002ec4c139aa19a23fb3b87644595af369203fd353e1440d2f365912edede0 |
Retries
We keep trying until you answer 2xx, on this schedule:
| Attempt | Sent |
|---|---|
| 1 | immediately |
| 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.
- Zapier — Webhooks 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.
- Make — Custom webhook. Make shows the incoming structure after the first delivery, so press Send test while its "determine data structure" listener is running.
- n8n — Webhook node, method
POST. On a self-hosted n8n, remember the URL has to be reachable from the public internet (see below).
Requirements and limits
| Scheme | https only |
| Address | Must resolve to a public address — private, loopback, link-local and cloud-metadata ranges are refused, at DNS-resolution time |
| Redirects | Not followed. A 30x is recorded as the answer it is |
| Timeout | 10 seconds per attempt |
| Query strings | Allowed (Zapier and n8n URLs often carry one) |
| Credentials in the URL | Refused — authenticate with the signature |
| Log retention | The most recent 200 deliveries per endpoint |
| Scope | One 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 see | What it means |
|---|---|
| Retrying with no status code | We could not connect at all — DNS, TLS, firewall, or a timeout. The error text is in the log row |
403 or 401 from your server | Your receiver is rejecting us. Check that you are comparing against the raw body, and that you are using the current secret |
| Signature never matches | Almost always a re-serialized body. Verify the bytes as they arrived, before any JSON parsing |
422 when adding an endpoint | The URL failed validation — http, a private address, embedded credentials, or a #fragment |
| Endpoint went Paused on its own | Your server answered 410 Gone. Press Resume |
| Nothing arrives at all | Check the endpoint is Active and subscribed to the event you expect; post.published does not fire for posts still in review |