# Blog publishing

For content platforms that write articles and publish them onto a host's Haven sites. Two scopes, two operations, two reads, and one rule that shapes everything else: the host decides whether your articles go live.

## What a Haven blog is

Not one site with posts. A host has a **blog library** — a collection of articles — and publishes each article to any number of their sites: individual listing pages and **collections**, the landing pages that front several listings. One article can be live at several addresses at once. Haven picks one of them as canonical so the copies do not compete in search, and that is the URL you should record.

This is why the integration is account-scoped rather than site-scoped. One authorization covers every site the host owns, and each delivery names the sites it should appear on.

## Scopes

| Scope        | Grants                                                                                |
| ------------ | ------------------------------------------------------------------------------------- |
| `blog:read`  | `GET /blog-posts` — the library, without article bodies                               |
| `blog:write` | `upsert_blog_post` and `unpublish_blog_post`, and choosing which sites each one is on |

Request `listings:read` as well; it is what lets you discover where to publish. Both blog scopes are standard tier, so an approved application may hold them without a staff sign-off.

## Discover the targets

Every delivery names its targets by reference code. Two reads give you the full set:

```bash
curl https://www.bookwithhaven.com/api/public/v1/properties \
  -H "Authorization: Bearer $ACCESS_TOKEN"

curl https://www.bookwithhaven.com/api/public/v1/property-groups \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

A collection's `propertyCodes` lists its member listings, `url` is its public address, and `hasBlog` says whether the host has attached a blog to it yet — you may publish either way; the first delivery to a site without a blog creates one. `visible: false` marks a collection the host has hidden; a delivery to it succeeds and shows nothing publicly, which is worth telling the host rather than retrying.

## Deliver an article

```
POST /api/public/v1/operations/upsert_blog_post
Idempotency-Key: <uuid>
```

```json
{
  "externalId": "18342",
  "title": "Where to Stay in Gatlinburg: 7 Cabins With a View",
  "contentHtml": "<h2>…</h2><p>…</p><figure><img src=\"https://cdn.example.com/hero.webp\" alt=\"Cabin deck\"><figcaption>…</figcaption></figure>",
  "excerpt": "…",
  "tags": ["gatlinburg cabins", "smoky mountains"],
  "featuredImageUrl": "https://cdn.example.com/hero.webp",
  "authorName": "Chase Gillmore",
  "status": "published",
  "publishedAt": "2026-09-15T12:00:00Z",
  "propertyCodes": ["b41c9e07"],
  "propertyGroupCodes": ["7f3a12c9"]
}
```

**`externalId` is your idempotency key for the article**, distinct from the request's `Idempotency-Key`. Send the same value on every later delivery of the same article and Haven updates the post you filed rather than creating a second one. It is scoped to your application, so it can never collide with another app's ids or with the host's own articles.

At least one of `propertyCodes` and `propertyGroupCodes` is required. Send both lists in full on every delivery: the set you send replaces the set on file.

The response is the standard [operation envelope](03-operations.md): `{ "operation": "upsert_blog_post", "status": "applied", "count": 2 }`, with `propertyCode` present when the article went to exactly one listing. The article itself — slug, live URL, and whether it was published or held — is read back in the next step. Reading back is one extra request, and it is the same request you will make to reconcile your copy later, so the two paths never disagree.

## Read back the URL

```bash
curl "https://www.bookwithhaven.com/api/public/v1/blog-posts?external_id=18342" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

```json
{
  "success": true,
  "value": {
    "items": [
      {
        "slug": "where-to-stay-in-gatlinburg-7-cabins-with-a-view",
        "title": "Where to Stay in Gatlinburg: 7 Cabins With a View",
        "status": "published",
        "externalId": "18342",
        "url": "https://www.smokymountaincabins.com/blog/where-to-stay-in-gatlinburg-7-cabins-with-a-view",
        "siteUrls": [
          "https://www.smokymountaincabins.com/blog/where-to-stay-in-gatlinburg-7-cabins-with-a-view",
          "https://www.bookwithhaven.com/properties/b41c9e07/blog/where-to-stay-in-gatlinburg-7-cabins-with-a-view"
        ],
        "propertyCodes": ["b41c9e07"],
        "propertyGroupCodes": ["7f3a12c9"],
        "publishedAt": "2026-09-15T12:00:00Z",
        "featuredImageUrl": "https://assets.bookwithhaven.com/…/hero.webp"
      }
    ],
    "nextCursor": null
  }
}
```

`url` is the canonical address: submit it to search engines and link to it from other articles. `siteUrls` lists every page the article answers on, for a verifier that wants to check them all. A `draft` has no public page, so both are empty until it is published.

`external_id` matches only articles your application delivered. Another app's article ids are never returned to you, and yours are never returned to it.

## Why a publish can come back as a draft

**Articles arrive as drafts unless the host has switched on direct publishing for that blog.** A delivery with `"status": "published"` to a blog where they have not is accepted, filed as a draft in the host's library, and reads back with `"status": "draft"`. Nothing failed. The host sees your article in Haven, badged as coming from a connected app, and publishes it themselves — or flips the switch so your next delivery goes straight out.

Show this to your customer as a state, not an error. The host controls it from the blog's settings in Haven, and it applies per blog, so a manager may let you publish directly to their portfolio site while reviewing everything bound for an individual listing.

## The body

`contentHtml` is HTML, up to 500 KB. Semantic tags only: `h2`–`h4`, `p`, `ul`, `ol`, `li`, `a`, `strong`, `em`, `blockquote`, `table` and its parts, `figure` around `img` with a `figcaption`. On arrival Haven converts it to Markdown and stores that, which is what lets the host edit your article in their own editor afterwards.

Three things happen to it that you should know about:

- **Styling is discarded.** `class`, `style` and `id` attributes are removed, along with `script`, `iframe` and event handlers. The article renders in the host's own typography and colours, on every site, and there is no way to send your own. This is what the host was promised on the consent screen.
- **`h1` becomes `h2`.** The title is the page's only `h1`.
- **Images are copied to Haven's CDN.** Every `img` and the `featuredImageUrl` are fetched and re-hosted, so the host's blog keeps working after your CDN stops serving them. Keep `alt` text; it survives. Absolute `https` URLs only.

Links are kept as sent. Links back to the host's own listing pages are fine and encouraged.

## Update, and the slug

Send the same `externalId` with the new body. Every field you send is updated. **The slug is set on the first delivery and never changes**, whatever the title becomes — by then the URL is indexed and linked.

## Unpublish

```
POST /api/public/v1/operations/unpublish_blog_post
```

```json
{ "externalId": "18342" }
```

Returns the article to draft on every site and keeps it, slug included, so a later `upsert_blog_post` brings it back to the same address. Deletion is not offered: the host may have edited the article since it arrived, and it is theirs.

`404` for an `externalId` you never delivered to this account.

## What you cannot do

Set the canonical URL, structured data, meta tags or sitemap entries — Haven generates all of them from the host's own settings, the same way it does for a host-written post. Read article bodies. Touch an article you did not deliver. Publish to a site the host has not connected you to.

## Rate limits

Blog writes share one budget family, `blog_write`, across both operations. See [Rate limits](../03-conventions/07-rate-limits.md).
