# 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.

:::callout{severity=info title="Where to set it up"}
Open a blog in the dashboard and find the **Webhooks** card. Add an endpoint
URL, tick the events you want, and press **Add endpoint**. The signing
secret is shown **once**, right there — copy it before you leave the page.
:::

## 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.

```json
{
  "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>` |

:::callout{severity=info title="Why the stylesheet is a link, not a string"}
`html` may contain MD2 components (callouts, steps, comparisons, charts).
Those need two stylesheets: your blog's **palette**, which is a few hundred
bytes and arrives inline as `css`, and the **component stylesheet**, which is
about 40 KB, identical for every post of every blog, and changes only when we
ship a new renderer.

So `css_href` points at it instead of inlining it. Fetch it once, cache it,
and render the post inside an element carrying both classes:

```html
<link rel="stylesheet" href="https://api.dailysmith.com/v1/sdk/md2.css">
<style>/* the payload's `css` value */</style>

<div class="baas-wrap md2"><!-- the payload's `html` --></div>
```

Both fields are **absent** when a post contains no MD2 components — plain
markdown needs neither.
:::

## 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.

:::callout{severity=warning title="Sign the raw bytes, not a re-serialized object"}
Verify against the request body **exactly as it arrived**. If your framework
parses JSON and you re-encode it, key order and whitespace change and the
signature will not match. Every example below reads the raw body first.
:::

::::steps

:::step[Reject anything without a valid signature]
Anyone who learns your URL can POST to it. The signature is what tells our
request from theirs.
:::

:::step[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.
:::

:::step[Answer quickly]
We wait **10 seconds** for a response. Queue the work and return `2xx`
immediately rather than processing inline.
:::

::::

### Node

```js
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

```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.

:::callout{severity=warning title="410 Gone means unsubscribe"}
The one exception. If your endpoint answers **410 Gone**, we stop retrying
*and* switch the endpoint off, with the reason recorded in the dashboard.
That is HTTP's own meaning for the status, and it lets you tear down an
automation without coming back here. Re-enable it with **Resume** when the
receiver is back.
:::

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 |

:::callout{severity=nit title="Why the address rules are strict"}
Your URL is fetched by our servers, from inside our network. If we followed
redirects or allowed private addresses, anyone could use this form to make us
fetch our own internal services and report back — the classic SSRF. The check
runs on the resolved IP for every connection, not just on the string you
typed.
:::

## 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 |
