Newsroom extras: product cards, sources, corrections, reminders and card badges

Embed
Preview of the card
<a href="https://forumulate.com/guides/release-newsroom-extras"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/99a52cc4-8da8-4ef7-bc50-f2282d1c6989/social-card.svg?v=3e7ae33eec8b" alt="Newsroom extras: product cards, sources, corrections, reminders and card badges" width="600" height="315"></a>

These are the pieces a market-news blog uses around its posts. Each works on its own, and each also works on the release fields of a drop post (see A release newsroom on a blog). They are:

  • the product card, a live card for a product sold elsewhere;

  • the sources block, which lists your citations and checks them for dead links;

  • the corrections block, a dated history of updates to the post;

  • follow a tag and Remind me, which send readers email;

  • the card badge, which prints a post field on the generated social card.

The three blocks can go in any space type, not only a blog.


1. Product card

A product card shows one product from another site. It has a picture, a name, a price, the live asks and bids from a marketplace, and Buy and Track buttons.

In the editor, type /product card. In Markdown, write:

:::productCard{url="https://shop.example.com/p/air-max-1" endpoint="https://api.example.com/cards/air-max-1"}:::

As block JSON:

{ "type": "productCard", "attrs": { "url": "https://shop.example.com/p/air-max-1", "endpoint": "https://api.example.com/cards/air-max-1" } }

Attribute

What it does

url

The product page. The card links to it. Required.

endpoint

Your JSON endpoint for the live data (contract below). Optional.

name, imageUrl

Your own words and picture. They are used when the live answer has none.

price, currency

A fallback retail price, for example 190 and USD.

layout

horizontal (picture beside), vertical (picture above) or compact. If you leave it out, the theme decides.

showMarket, showButtons

true or false. If you leave it out, the theme decides. If the theme doesn't say either, both are shown when the data exists.

buyLabel, trackLabel

Your own words for the button labels.

Where the data comes from

Your readers' browsers never contact your endpoint. While the page renders, our server fetches the data and puts the card into the HTML. This means:

  • The card is in the HTML before any script runs, so crawlers see it and nothing moves on the page as it loads.

  • The fetch goes through our SSRF-safe fetcher. Redirects are not followed, the timeout is 4 seconds and the answer can be at most 256 KB. An address on a private network is refused.

  • The product picture is served through our image proxy, so a reader never requests it from your host.

  • The answer is cached for 60 seconds by default, because asks and bids change quickly. To change this, send maxAge in your JSON, or a Cache-Control: max-age header. Values outside 15–900 seconds are clamped to that range.

  • If a refresh fails, the card keeps the last good answer and adds the note "Prices may be out of date". We try the endpoint again 30 seconds later. If no fetch has ever succeeded, the card shows the block's own name, imageUrl and price. If those are empty too, it shows a plain link to the product.

If you don't give an endpoint, the card reads the product page's own preview tags (Open Graph) instead: its title, description and picture.

The endpoint contract

We call your endpoint with GET and Accept: application/json. Only name is required. Keys we don't use are ignored, so your endpoint can also serve your own app.

{
  "name": "Air Max 1 “Big Bubble”",
  "url": "https://shop.example.com/p/air-max-1",
  "image": "https://cdn.example.com/am1.png",
  "brand": "Nike",
  "sku": "DZ4549-110",
  "description": "The 1987 original returns.",
  "price": { "amount": 190, "currency": "USD" },
  "availability": "preorder",
  "market": {
    "currency": "USD",
    "lowestAsk": 250,
    "highestBid": 230,
    "lastSale": 241,
    "asks": 12,
    "bids": 30,
    "updatedAt": "2026-10-21T10:00:00Z"
  },
  "buttons": [
    { "kind": "buy", "label": "Buy now", "url": "https://shop.example.com/buy/am1" },
    { "kind": "track", "label": "Track price", "url": "https://shop.example.com/track/am1" }
  ],
  "maxAge": 60
}

Key

Type

name

string, 1–200 characters. Required.

url, image

absolute http(s) URLs

brand, sku

strings (up to 120 and 80 characters)

description

string, up to 500 characters

price

{ amount ≥ 0, currency: ISO 4217 }

availability

in_stock, out_of_stock, preorder, sold_out or discontinued

market

lowestAsk, highestBid, lastSale (amounts), asks, bids (counts), currency, updatedAt (ISO timestamp); every key is optional

buttons

up to 4 buttons, each { kind: buy, track or link, label (1–40 characters), url }. Without buttons, the card shows one View product link.

maxAge

seconds, 0–3600, clamped to 15–900

If an answer doesn't match the contract, it isn't used. To see why, open the block in the editor and choose Fetch preview, or call POST /api/orgs/{orgId}/newsroom/product-cards/preview with {"url": "…", "endpoint": "…"}. The response has error.issues, which names each path that is wrong, such as price.currency: An ISO 4217 currency code such as USD.

Structured data

A blog post adds a schema.org Product to its JSON-LD for each card. The Product has an Offer for the retail price and availability, and an AggregateOffer for the asks: lowPrice, highPrice and offerCount. It is built from the same answer the card shows.

Theme keys (theme.newsroom.productCard)

These keys follow the usual order: the space's value wins over the site's, and the site's wins over the default. A block's own attributes always win over all of them.

Key

Values

layout

horizontal (the default), vertical or compact

showMarket, showButtons

false hides them on every card that doesn't say otherwise

imageFit

contain (the default) or cover

radius

0–48 (px)

background, border, accent

hex colours. accent colours the price, the market figures and the primary button.

{ "newsroom": { "productCard": { "layout": "vertical", "accent": "#0B835B", "radius": 16 } } }

2. Sources

The sources block lists the citations a story stands on, in the same format every time: publisher, title (as a link), date and an optional note.

In the editor, type /sources. In Markdown, the block is one line with the attributes in a URL-encoded JSON data payload. Exporting a page writes this line for you:

:::sources{data="%7B%22items%22%3A%5B%7B%22publisher%22%3A%22Nike%20News%22%2C%22title%22%3A%22Air%20Max%20Day%22%2C%22url%22%3A%22https%3A%2F%2Fnews.example.com%2Fam-day%22%2C%22date%22%3A%222026-03-20%22%7D%5D%7D"}:::

As block JSON:

{
  "type": "sources",
  "attrs": {
    "items": [
      { "publisher": "Nike News", "title": "Air Max Day", "url": "https://news.example.com/am-day", "date": "2026-03-20" }
    ],
    "style": "numbered"
  }
}
  • style is numbered, list or compact.

  • heading replaces the "Sources" heading. Set it to "" to hide the heading.

  • markDead is described below.

A row without a URL or a title isn't shown, so you can save a half-written row. The sources also go into the post's JSON-LD as citation.

Every time a page or post is published or edited, we record the URLs its sources blocks cite. Once an hour, a scheduled check visits them:

  • a URL that has never been checked is visited straight away;

  • a URL that failed its last check is visited again after an hour;

  • a URL that worked is visited again after a day.

We use a HEAD request, and fall back to GET when that fails. These answers count as alive: any 2xx or 3xx, and 401, 403 and 429 (paywalls and bot walls — the page is there). These count as failed: 404, 410 and any 5xx, a name that doesn't resolve, a refused or timed-out connection, and an address on a private network.

One failure isn't enough to call a link dead. A link is marked dead after two failed checks in a row, and it stops being dead as soon as a check succeeds. A URL cited by ten stories is checked once.

Who sees dead links:

  • Staff always see them. In the editor, each source row shows Dead link with the HTTP status, and has a Check now button. On the live page, a member who sees the staff bar sees a red Dead link mark.

  • Readers see dead links only if you turn it on. Set theme.newsroom.sources.markDead: true, or set the block's own markDead. The link is then struck through, followed by "(link may be broken)" in the page's language. A block's markDead: false turns this off for that block, even when the theme turns it on.

Related API calls:

  • GET /api/orgs/{orgId}/spaces/{spaceId}/newsroom/source-links?status=dead lists a space's cited links, dead ones first.

  • POST /api/orgs/{orgId}/newsroom/source-links/status with {"urls": […]} returns the verdict for each URL.

  • POST /api/orgs/{orgId}/newsroom/source-links/check with {"url": "…"} checks a URL again now.

To set the default style for every sources block, use theme.newsroom.sources.style.


3. Corrections

The corrections block is the post's visible "Updated" history. Each entry is a date, your note ("Release moved from Nov 7 to Nov 14.") and an optional summary of what changed, which readers can open. Entries are shown newest first.

{
  "type": "corrections",
  "attrs": {
    "items": [
      { "date": "2026-11-01", "note": "Release moved from Nov 7 to Nov 14.", "diff": "- Releases November 7.\n+ Releases November 14." }
    ],
    "style": "banner"
  }
}

In Markdown it is :::corrections{data="…"}:::, written the same way as sources.

  • style is list (a rule down the side) or banner (a tinted box).

  • heading replaces the "Updates" heading.

  • theme.newsroom.corrections.style sets the default style.

Drafting from the last edit. A blog post already keeps its revisions: each edit saves the text it replaced, along with the editor's reason for the edit. In the post editor, choose Draft from the last edit. This adds an entry dated today. The note is the reason from the last edit, and the summary is what that edit changed, as - old and + new lines. The corrections and sources blocks themselves are left out of the summary.

The same draft is available from GET /api/orgs/{orgId}/spaces/{spaceId}/blog/{topicId}/corrections/suggest. It writes nothing. Field values, such as releaseDate, aren't part of a post's revisions, so if a release date moved, say so in the note.

Structured data. The post's dateModified is never earlier than its newest correction. If you date a correction today, crawlers are told the post changed today.


4. Follow a tag

Readers can follow one tag of a blog. They then get an email each time a post with that tag is published.

  • On a tag page (/b/{org}/{space}/tag/{tag}, or {blog}/tag/{tag} on your domain). The page shows a follow control if the blog's front page includes a subscribe band or a subscribe menu (a subscribe band with menu: true and frontDoor.subscribeStyle: "menu" or "email-popover"; see the Blog guide). On a tag page, both are scoped to that tag by default. A front page without either shows only the RSS link.

  • Through the API: POST /api/public/orgs/{orgId}/spaces/{spaceId}/blog/subscribe with {"email": "…", "scope": {"tags": ["sneakers"]}}.

Following a tag works like the rest of the blog's newsletter. The reader confirms by email before anything is sent (double opt-in), every email has a one-click unsubscribe link (RFC 8058), and the confirmation email is sent in the language of the page the reader subscribed from.

If a reader who already follows one tag follows another, they get both. A stranger who knows their address can add tags but can't take any away.

Every way of publishing sends the email to the tag's followers, along with the blog's other subscribers:

  • POST …/blog/{id}/publish;

  • POST …/blog/{id}/state with {"state": "PUBLISHED"} (the studio's list view);

  • POST …/items/{id}/publish;

  • a scheduled publish.

Each subscriber gets at most one email per post. To publish without sending email, send notify: false to the first two. …/state returns mailed, the number of emails it queued.


5. Remind me

When a post's release date is still ahead, a Remind me box appears under the post. A reader gives their email address, confirms it, and we email them a set number of hours before the release.

Key (theme.newsroom.reminders)

Default

What it does

enabled

true

Set it to false to hide the box and refuse requests.

field

releaseDate

The field that holds the release date. A dotted path, such as release.date, also works.

leadHours

24

How many hours before the release the email goes out (1–720).

The field can hold a date (2026-11-14, which means the start of that day in UTC), a full timestamp, or an object with a date key.

Through the API: POST /api/public/orgs/{orgId}/spaces/{spaceId}/blog/{topicId}/remind with {"email": "…", "locale": "de"}.

  • If the org has a captcha set up, include captchaToken.

  • A signed-in reader with a verified address is set up straight away. The response has confirmed: true.

  • If the post has no release date in the future, the response is 422. The check uses the database clock.

A reminder is a newsletter subscription to that one post, so it uses the newsletter's double opt-in, its 7-day confirmation window and its one-click unsubscribe. A sweep runs every 10 minutes and claims each reminder before sending it. If the release date moves, readers get a new reminder for the new date. If the date stays the same, nobody gets a second reminder.

The two emails are Confirm a release reminder (blog.reminder-confirm) and Release reminder (blog.release-reminder). You can change them under Settings → Email templates, including a version for each language. Their variables are post.title, post.excerpt, release.date (written in the reader's language) and link.


6. The card badge

A generated social card, the …/social-card.png image shown when a post is shared, can print one post field as a badge. For example, it can show the release date or the price.

To set the badge for a whole space, add badge to the card template. In the studio this is in SEO → Card badge. Through the API, call PUT /api/orgs/{orgId}/spaces/{spaceId}/seo:

{
  "socialCard": {
    "badge": { "field": "releaseDate", "label": "Drops", "position": "bottom-right", "color": "#0B835B" }
  }
}

Key

What it does

field

The post field to print, such as releaseDate or retailPrice. A dotted path also works.

label

A word in front of the value, such as "Drops" or "Retail".

format

auto (the default) works out the type: a date is written as a date, a money field ({min, max, currency}) as a price or price range, and anything else as text. You can also force date, money or text.

currency

The currency for a field that is a plain number.

locale

The language dates and prices are written in. The default is English.

position

bottom-right (the default), top-right or bottom-left.

color, textColor

The badge's colour and its text colour. By default they are the card's accent and background colours.

Send "badge": null to remove the badge.

A post can override the space's badge with customization.socialBadge, which is in the post's Style settings in the studio:

{ "customization": { "socialBadge": { "text": "Sold out" } } }

You can use field to print a different field, text to print your own words, label to change the label, or hide: true to remove the badge from this post's card. Send null to go back to the space's badge. If the field is empty, the card has no badge rather than an empty one.

The card URL changes whenever the post is edited, so if a release date moves, sites that show the card fetch the new picture. The card's fonts come with the API image, so the badge text appears on every card it renders.

In the API

Every operation below is also reachable from the other surfaces without a dedicated command. forumulate api <Operation> calls it from the CLI, the JavaScript SDK has a typed method for each one (the last column names it; the Python and Go clients are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP call_api tool. Where a CLI command or MCP tool exists too, the last column names it. Operation names link to the API reference.

Newsroom

Operation

Request

What it does

Shortcuts

Newsroom_previewCard

POST /orgs/{orgId}/newsroom/product-cards/preview

Preview the product card

client.newsroom.previewCard()

Newsroom_linkStatus

POST /orgs/{orgId}/newsroom/source-links/status

Create the status in an organization (newsroom source links)

client.newsroom.linkStatus()

Newsroom_checkLink

POST /orgs/{orgId}/newsroom/source-links/check

Create the check in an organization

client.newsroom.checkLink()

Newsroom_listLinks

GET /orgs/{orgId}/spaces/{spaceId}/newsroom/source-links

List source links in a space

client.newsroom.listLinks()

Newsroom_suggestCorrection

GET /orgs/{orgId}/spaces/{spaceId}/blog/{topicId}/corrections/suggest

Get the suggest in a space

client.newsroom.suggestCorrection()

Public Newsroom

Operation

Request

What it does

Shortcuts

PublicNewsroom_resolve

POST /public/orgs/{orgId}/newsroom/resolve

Resolve the newsroom

client.public.newsroom.resolve()

PublicNewsroom_remind

POST /public/orgs/{orgId}/spaces/{spaceId}/blog/{topicId}/remind

Create the remind in a space

client.public.newsroom.remind()

Space Settings seo

Operation

Request

What it does

Shortcuts

SpaceSettings_getSeo

GET /orgs/{orgId}/spaces/{spaceId}/seo

List SEO settings in a space

client.spaces.settings.getSeo()

SpaceSettings_setSeo

PUT /orgs/{orgId}/spaces/{spaceId}/seo

Replace SEO settings in a space

client.spaces.settings.setSeo()

The Forumulate teamUpdated