How to Check description and transcript Fields in a Join Container's jsonLd

This FAQ explains how to check, for a Join widget present on a page, that each video exposes both a description and a transcript in its jsonLd. These fields are used for SEO, since Google reads them.

1. Retrieve the jsonLd

Once you have the widget’s alias and teamId, call Join’s public API. No authentication is required:

https://api.stories.studio/v3/teams/{teamId}/containers/{alias}?isPublished=true&jsonLd=true&language={language}

Example — you can simply paste the URL into a browser:

https://api.stories.studio/v3/teams/YOUR_TEAM_ID/containers/YOUR_ALIAS?isPublished=true&jsonLd=true&language=LANG

2. Where are description and transcript?

They are in jsonLd.itemListElement[]. Each entry has an item object, which represents one video:

{
  "jsonLd": {
    "itemListElement": [
      {
        "position": 1,
        "item": {
          "name": "Story name",
          "description": "Video description...",
          "transcript": "Audio transcript...",
          "contentUrl": "https://stories.join-stories.com/.../story.mp4"
        }
      }
    ]
  }
}
  • description → video description
  • transcript → audio transcript

3. How do you know if a key is “properly filled”?

A key is OK if the key exists AND its value contains real visible text.

⚠️

Gotcha: a value such as "", " " only spaces, &nbsp;, an empty HTML tag like <p></p>, or invisible characters counts as empty, even if the cell appears to contain “something” visually. Always check that there is actual readable text.

4. A video without a transcript — is that a problem?

Not always. If the story contains only images and no video with audio, the absence of a transcript is normal.

You can check this in stories[].pages[].elements.mainAsset.contentType:

contentTypeMeaningTranscript expected?
video/...the story is a video✅ yes
image/...the story is image-only❌ no

➡️ Only report an issue for video stories without a transcript.

5. What about languages, such as en / fr?

Each language must be requested separately using the &language= parameter. The same story may exist in en and fr, or only in one of the two.

  • Normal response → the language exists.
  • 404 error → this language does not exist for this widget.

So you need to make one API call per language you want to check.

6. Checklist

  1. For each language, open the API URL.
  2. In jsonLd.itemListElement[], for each item, check whether description and transcript contain real text.
  3. If transcript is empty, check in stories[] that the story is actually a video before concluding that there is an issue.

7. Example JS script

This script runs as-is in the browser console with F12 or with Node 18+, with no dependencies:

// --- Parameters to fill in ---
const teamId = "YOUR_TEAM_ID"      // e.g. extracted from the widget subdomain
const alias = "YOUR_ALIAS"         // e.g. data-join-widget-alias
const languages = ["LANG_1", "LANG_2"]  // e.g. ["en", "fr"]

// --- Checks whether a value contains real visible text ---
function hasContent(value) {
  if (typeof value !== "string") return false
  const stripped = value
    .replace(/<[^>]*>/g, "")        // HTML tags
    .replace(/&[a-z0-9#]+;/gi, "")  // HTML entities (&nbsp; ...)
    .replace(/[-‏⁠­]/g, "") // invisible characters
    .replace(/\s/g, "")             // spaces
  return stripped.length > 0
}

// --- Story media type (video / images_only / mixed) ---
function mediaKind(story) {
  const pages = story?.pages ?? []
  let hasVideo = false, hasImage = false
  for (const p of pages) {
    const ct = p?.elements?.mainAsset?.contentType ?? ""
    if (ct.startsWith("video/")) hasVideo = true
    else if (ct.startsWith("image/")) hasImage = true
  }
  if (hasVideo && hasImage) return "mixed"
  if (hasVideo) return "video"
  if (hasImage) return "images_only"
  return "unknown"
}

// --- Extracts the story ID from contentUrl ---
function storyId(item) {
  const m = (item?.contentUrl ?? "").match(
    /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i,
  )
  return m ? m[1].toLowerCase() : ""
}

// --- Check ---
for (const lang of languages) {
  const url = 'https://api.stories.studio/v3/teams/' + teamId
    + '/containers/' + alias
    + '?isPublished=true&jsonLd=true&language=' + lang
  const res = await fetch(url)
  if (res.status === 404) {
    console.log('[' + lang + '] ❌ language missing for this widget')
    continue
  }
  const data = await res.json()
  const stories = new Map((data.stories ?? []).map((s) => [s._id?.toLowerCase(), s]))
  const items = data.jsonLd?.itemListElement ?? []
  for (const li of items) {
    const item = li.item ?? {}
    const id = storyId(item)
    const kind = mediaKind(stories.get(id))
    const hasDesc = hasContent(item.description)
    const hasTransc = hasContent(item.transcript)
    // missing transcript = issue ONLY if the story is a video
    const transcProblem = !hasTransc && kind === "video"
    const descIcon = hasDesc ? "✅" : "❌"
    const transcIcon = hasTransc ? "✅" : (transcProblem ? "❌ MISSING" : "— (normal)")
    console.log(
      '[' + lang + '] pos=' + li.position + ' "' + (item.name ?? "") + '" '
      + '(' + kind + ') description=' + descIcon + ' transcript=' + transcIcon
    )
  }
}

Example output:

[LANG_1] pos=1 "Story name" (video) description=✅ transcript=✅
[LANG_1] pos=2 "Another story" (images_only) description=✅ transcript=— (normal)
[LANG_2] ❌ language missing for this widget