Blog publishing

For content platforms that write articles and publish them onto a host's Haven sites. Two scopes, two operations, two reads. An article you send as published goes live on the sites you name.

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

ScopeGrants
blog:readGET /blog-posts — the library, without article bodies
blog:writeupsert_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, and it is what returns each listing's photos, street address, descriptions and amenities, plus a collection site's logo, contact, social profiles and places. Both blog scopes are standard tier, so an approved application may hold them without a staff sign-off.

When the host approves blog:write, Haven's consent screen asks which site the blog is for. The default follows the same order as the host's SEO tab: a multi-unit site on a verified custom domain first, then the other address tiers. If the host has no multi-unit site, they pick a listing. GET /me returns that choice as site (kind, code, name, url). The grant still covers the whole account. Import the chosen collection with GET /property-groups/{code}, then each member listing with GET /properties/{code}. site is null when the host was not asked, or the site has since been deleted.

Discover the targets

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

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 listing's url is its public address. GET /properties/{code} adds every photo (photos), the street address (location), descriptions, amenities, logo, contact, social profiles and the host's author profile.

GET /property-groups/{code} is the collection in full: logoUrl, contactEmail, contactPhone, socialLinks, author, targetCities, cities, neighborhoods, and propertyCodes for the member listings. Contact fields are the values stored on the collection. An unset one is null.

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, and the first published delivery adds a Blog page to the site's navigation (at /blog) when it has none. A Blog page the host already has, including one they hid, is left as they set it. 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>
{
  "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",
  "authorPhotoUrl": "https://cdn.example.com/chase.jpg",
  "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: { "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 status — 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

curl "https://www.bookwithhaven.com/api/public/v1/blog-posts?external_id=18342" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
{
  "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.

Status

"status": "published" goes live on every site named in the delivery. The read-back includes url and siteUrls. "status": "draft" stays in the host's library, and both of those fields are empty until a later delivery publishes it. A draft is not a failed publish.

The host sees the article in Haven, badged as coming from a connected app, and can edit or unpublish it. They do not have to approve it before it is public.

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, the featuredImageUrl and the authorPhotoUrl are fetched and re-hosted, so the host's blog keeps working after your CDN stops serving them. If a cover or author photo cannot be fetched, the article keeps the one it already had. Keep alt text; it survives. Absolute https URLs only.

authorName and authorPhotoUrl set the byline. Omitted, they default to the host's own profile name and photo.

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
{ "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.