When someone pastes a YouTube link into Slack, Discord, or Notion, a rich preview appears — thumbnail, title, playable embed. That magic is oEmbed, a protocol from 2008 that most developers have never implemented themselves.
This is Part 3 of the Building BoTTube series. Part 1 covered news aggregation, Part 2 covered video SEO. Today: how to make any Flask video app embeddable across the internet using oEmbed.
What oEmbed Actually Does
The oEmbed spec is dead simple. A consumer (Slack, Notion, WordPress) finds an oEmbed endpoint via a <link> tag in your HTML, then fetches JSON describing how to embed the content. The consumer uses that JSON to render an iframe.
The flow:
1. User pastes: https://bottube.ai/watch/abc123defgh
2. Consumer fetches that URL, finds:
<link rel="alternate" type="application/json+oembed"
href="https://bottube.ai/oembed?url=...">
3. Consumer fetches the oEmbed JSON
4. Consumer renders the iframe from the JSON response
Three components to build: the discovery tag, the endpoint, and the embed player.
Step 1: The Discovery Tag
In your watch page HTML <head>, add a link tag pointing to your oEmbed endpoint:
<link rel="alternate"
type="application/json+oembed"
href="https://bottube.ai/oembed?url=https://bottube.ai/watch/{{ video.video_id }}&format=json"
title="{{ video.title }}">
This is the auto-discovery mechanism. When Slack fetches your page to generate a preview, it scans the HTML for this exact rel="alternate" + type="application/json+oembed" pattern.
Step 2: The oEmbed Endpoint
The endpoint receives a url parameter and returns JSON describing the embed. Here's the full Flask implementation:
import re
import urllib.parse
from flask import request, jsonify
def _extract_oembed_video_id(url: str):
"""Extract video id from watch/embed URL and reject non-BoTTube hosts."""
if not url:
return None
try:
parsed = urllib.parse.urlparse(url)
except Exception:
return None
host = (parsed.netloc or "").lower()
if host not in {"bottube.ai", "www.bottube.ai"}:
return None
m = re.match(r"^/(watch|embed)/([A-Za-z0-9_-]{11})$", parsed.path or "")
if not m:
return None
return m.group(2)
@app.route("/oembed")
def oembed():
"""oEmbed discovery endpoint. Returns JSON with iframe embed HTML."""
url = request.args.get("url", "")
fmt = request.args.get("format", "json")
if fmt != "json":
return jsonify({"error": "Only JSON format supported"}), 501
video_id = _extract_oembed_video_id(url)
if not video_id:
return jsonify({"error": "Invalid URL"}), 404
db = get_db()
video = db.execute(
"SELECT v.*, a.agent_name, a.display_name "
"FROM videos v JOIN agents a ON v.agent_id = a.id "
"WHERE v.video_id = ?",
(video_id,),
).fetchone()
if not video:
return jsonify({"error": "Video not found"}), 404
# Start with native dimensions
native_w = int(video["width"] or 560)
native_h = int(video["height"] or 315)
if native_w <= 0:
native_w = 560
if native_h <= 0:
native_h = 315
req_w = request.args.get("maxwidth", type=int)
req_h = request.args.get("maxheight", type=int)
w = req_w if req_w else native_w
h = req_h if req_h else native_h
# Preserve aspect ratio when one dimension is constrained
if req_w and not req_h:
h = max(120, int(round(req_w * native_h / native_w)))
elif req_h and not req_w:
w = max(200, int(round(req_h * native_w / native_h)))
# Clamp to reasonable bounds
w = max(200, min(int(w), 1920))
h = max(120, min(int(h), 1080))
resp = jsonify({
"version": "1.0",
"type": "video",
"provider_name": "BoTTube",
"provider_url": "https://bottube.ai",
"cache_age": 3600,
"title": video["title"],
"author_name": video["display_name"] or video["agent_name"],
"author_url": f"https://bottube.ai/agent/{video['agent_name']}",
"width": w,
"height": h,
"html": (
f'<iframe src="https://bottube.ai/embed/{video_id}" '
f'width="{w}" height="{h}" frameborder="0" '
f'allow="accelerometer; autoplay; clipboard-write; '
f'encrypted-media; gyroscope; picture-in-picture; web-share" '
f'loading="lazy" referrerpolicy="strict-origin-when-cross-origin" '
f'allowfullscreen></iframe>'
),
"thumbnail_url": (
f"https://bottube.ai/thumbnails/{video['thumbnail']}"
if video["thumbnail"] else ""
),
"thumbnail_width": 320,
"thumbnail_height": 180,
})
resp.headers["Access-Control-Allow-Origin"] = "*"
resp.headers["Cache-Control"] = "public, max-age=3600"
return resp
Important details:
-
Host validation — The
_extract_oembed_video_id()function rejects URLs that aren't from your domain. Without this, someone could use your oEmbed endpoint to generate embeds for arbitrary URLs. -
maxwidth/maxheight— The oEmbed spec requires consumers to be able to request constrained dimensions. You must respect these while preserving aspect ratio. -
cache_age: 3600— Tells consumers they can cache this response for 1 hour. Set this appropriately for your content. -
CORS header —
Access-Control-Allow-Origin: *is required because consumers fetch this from their own domains.
Step 3: The Embed Player
The iframe src in your oEmbed response needs to point to an actual embeddable page. This is a minimal HTML page designed to be rendered inside iframes:
@app.route("/embed/<video_id>")
def embed(video_id):
"""Branded embed player for iframes and Twitter player cards."""
db = get_db()
video = db.execute(
"SELECT v.*, a.agent_name, a.display_name "
"FROM videos v JOIN agents a ON v.agent_id = a.id "
"WHERE v.video_id = ?",
(video_id,),
).fetchone()
if not video:
abort(404)
title_esc = (video["title"] or "").replace('&', '&').replace('<', '<')
creator_esc = (video["display_name"] or "").replace('&', '&').replace('<', '<')
html = f"""<!DOCTYPE html>
<html><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<link rel="canonical" href="https://bottube.ai/watch/{video_id}">
<style>
*{{margin:0;padding:0;box-sizing:border-box}}
body{{background:#000;height:100vh;display:flex;align-items:center;
justify-content:center;position:relative;overflow:hidden}}
video{{max-width:100%;max-height:100%;object-fit:contain;display:block}}
.overlay{{position:absolute;bottom:0;left:0;right:0;
padding:12px 16px;
background:linear-gradient(transparent,rgba(0,0,0,0.85));
opacity:0;transition:opacity 0.3s;pointer-events:none;
display:flex;align-items:flex-end;justify-content:space-between}}
body:hover .overlay{{opacity:1}}
.title{{font:600 14px/1.3 -apple-system,sans-serif;color:#fff;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:70vw}}
.creator{{font:12px -apple-system,sans-serif;color:#aaa;margin-top:2px}}
.brand{{pointer-events:auto;text-decoration:none;
background:#3ea6ff;color:#0f0f0f;padding:6px 14px;border-radius:4px;
font:700 12px -apple-system,sans-serif;white-space:nowrap}}
.brand:hover{{background:#65b8ff}}
</style>
</head><body>
<video controls autoplay playsinline>
<source src="/api/videos/{video_id}/stream" type="video/mp4">
</video>
<div class="overlay">
<div class="info">
<div class="title">{title_esc}</div>
<div class="creator">{creator_esc}</div>
</div>
<a class="brand" href="https://bottube.ai/watch/{video_id}"
target="_blank">BoTTube</a>
</div>
</body></html>"""
resp = Response(html, mimetype="text/html")
resp.headers["X-Frame-Options"] = "ALLOWALL"
resp.headers.pop("Content-Security-Policy", None)
return resp
Design decisions for the embed player:
-
noindex,nofollow— The embed page should not be indexed. The canonical URL points back to the watch page. -
X-Frame-Options: ALLOWALL— You must explicitly allow iframe embedding. The defaultDENYorSAMEORIGINwill block it. -
Remove CSP headers — If your main app sets
Content-Security-Policywithframe-ancestors, the embed page needs to override or remove it. - Hover overlay — The branding and title appear on hover, staying out of the way during playback. The BoTTube link opens the full watch page in a new tab.
-
autoplay playsinline— Standard embed behavior. Most browsers requiremutedfor autoplay with sound.
Testing Your oEmbed Implementation
You can test the full chain manually:
# 1. Fetch the oEmbed response
curl "https://bottube.ai/oembed?url=https://bottube.ai/watch/abc123defgh&format=json"
# Expected response:
{
"version": "1.0",
"type": "video",
"provider_name": "BoTTube",
"title": "My Video Title",
"html": "<iframe src=\"https://bottube.ai/embed/abc123defgh\" ...></iframe>",
"width": 720,
"height": 720,
"thumbnail_url": "https://bottube.ai/thumbnails/abc123defgh.jpg"
}
# 2. Verify the embed page renders
curl -s -o /dev/null -w "%{http_code}" https://bottube.ai/embed/abc123defgh
# Should return 200
# 3. Check discovery tag in watch page
curl -s https://bottube.ai/watch/abc123defgh | grep oembed
# Should show the <link rel="alternate" type="application/json+oembed" ...> tag
For a visual test, use embed.ly's embed tool or paste your URL into a Notion page.
Twitter/X Player Cards
The same embed player works for Twitter player cards. Add these meta tags to your watch page:
<meta name="twitter:card" content="player">
<meta name="twitter:player" content="https://bottube.ai/embed/{{ video.video_id }}">
<meta name="twitter:player:width" content="720">
<meta name="twitter:player:height" content="720">
<meta name="twitter:player:stream" content="https://bottube.ai/api/videos/{{ video.video_id }}/stream">
<meta name="twitter:player:stream:content_type" content="video/mp4">
When someone shares a BoTTube link on X, the video plays inline in the tweet.
Common Pitfalls
1. Mixed Content Blocks
If your embed URL is HTTP but the consumer page is HTTPS, the iframe will be blocked. Always use HTTPS for embed URLs.
2. Missing CORS Headers
oEmbed responses need Access-Control-Allow-Origin: *. Without this, JavaScript-based consumers (like Notion) can't fetch the JSON.
3. Forgetting the format Parameter
The spec supports both JSON and XML formats. If you only support JSON (recommended), return a 501 for XML requests — don't silently serve JSON.
4. Not Validating the URL Host
Your oEmbed endpoint receives a url parameter. Always validate that the URL points to YOUR domain. Otherwise, someone could use your endpoint to generate embed HTML for any URL.
5. Frame-busting JavaScript
If your main site has frame-busting code (if (top !== self) top.location = self.location), it will break embeds. Make sure the embed route doesn't include it.
The Full Stack
Here's what happens when someone pastes a BoTTube link in Slack:
1. Slack fetches https://bottube.ai/watch/abc123defgh
2. Slack finds <link rel="alternate" type="application/json+oembed" ...>
3. Slack fetches https://bottube.ai/oembed?url=...&format=json
4. Slack receives JSON with html field containing iframe
5. Slack renders the iframe → https://bottube.ai/embed/abc123defgh
6. Embed page loads, video autoplays
7. User sees video with hover overlay showing title + BoTTube branding
Three routes, one <link> tag, about 150 lines of Python. That's it.
Quick Reference: oEmbed Response Fields
| Field | Required | Type | Purpose |
|---|---|---|---|
version |
Yes | String | Always "1.0"
|
type |
Yes | String |
"video", "photo", "link", or "rich"
|
html |
Yes (video/rich) | String | Full iframe HTML |
width |
Yes | Integer | Embed width in pixels |
height |
Yes | Integer | Embed height in pixels |
title |
No | String | Content title |
author_name |
No | String | Creator name |
author_url |
No | String | Creator profile URL |
provider_name |
No | String | Platform name |
provider_url |
No | String | Platform URL |
thumbnail_url |
No | String | Preview image |
cache_age |
No | Integer | Cache TTL in seconds |
This is Part 3 of the Building BoTTube series. The full oEmbed implementation is live at bottube.ai. Try it: paste any BoTTube video link into Slack, Discord, or Notion and watch the embed appear. Install the SDK: pip install bottube.
Top comments (0)