Skip to main content

Rich Link Previews (oEmbed)

Chirp supports oEmbed auto-discovery with a custom x_app extension, allowing external applications to provide rich, structured link previews when their URLs are shared in chat.

When a user pastes a link, Chirp already fetches OpenGraph meta tags for a basic preview. With oEmbed support, apps can deliver richer cards with app-specific data like trip dates, movie ratings, or booking details.

How It Works

  1. User pastes a link in a Chirp channel
  2. Chirp fetches the page HTML and parses OpenGraph tags (as usual)
  3. Chirp checks for a <link rel="alternate" type="application/json+oembed"> tag
  4. If found, Chirp fetches the oEmbed JSON endpoint
  5. If the response includes an x_app extension, Chirp renders a rich card
  6. Otherwise, it falls back to the standard OpenGraph preview

Implementing oEmbed for Your App

Step 1: Create an oEmbed Endpoint

Add a GET /api/oembed endpoint that accepts a url query parameter and returns JSON:

GET /api/oembed?url=https://yourapp.com/resource/123&format=json

Step 2: Return oEmbed JSON

Your endpoint should return standard oEmbed fields plus the x_app extension:

{
"version": "1.0",
"type": "rich",
"provider_name": "Your App Name",
"provider_url": "https://yourapp.com",
"provider_icon": "https://yourapp.com/favicon.svg",
"title": "Resource Title",
"description": "A short description of the resource",
"author_name": "Author Name",
"thumbnail_url": "https://yourapp.com/images/resource-cover.jpg",
"thumbnail_width": 800,
"thumbnail_height": 400,
"x_app": {
"app_type": "yourapp",
"resource_type": "resource",
"color": "#10b981",
"data": {
"custom_field_1": "value1",
"custom_field_2": 42
}
}
}

Step 3: Add Discovery Tag

In your HTML pages (via SSR or server-side rendering), add an oEmbed discovery <link> tag inside <head>:

<link
rel="alternate"
type="application/json+oembed"
href="https://yourapp.com/api/oembed?url=https%3A%2F%2Fyourapp.com%2Fresource%2F123&format=json"
title="Resource Title"
/>
tip

The href must be URL-encoded and point to your oEmbed endpoint with the current page URL as the url parameter.

oEmbed Response Fields

Standard Fields

FieldTypeRequiredDescription
versionstringYesMust be "1.0"
typestringYesMust be "rich"
titlestringYesResource title
provider_namestringNoYour app's display name
provider_urlstringNoYour app's base URL
provider_iconstringNoURL to your app's icon (shown in the card header)
descriptionstringNoShort description (max ~200 chars)
author_namestringNoCreator/author name
thumbnail_urlstringNoPreview image URL
thumbnail_widthnumberNoImage width in pixels
thumbnail_heightnumberNoImage height in pixels

The x_app Extension

The x_app object is what triggers Chirp's rich card rendering instead of a generic preview.

FieldTypeRequiredDescription
app_typestringYesUnique identifier for your app (e.g., "traveler", "watchradar")
resource_typestringYesType of resource (e.g., "trip", "media", "event_type")
colorstringNoAccent color for the card (hex, e.g., "#10b981")
dataobjectNoArbitrary structured data specific to your resource

Card Rendering

Chirp includes built-in card layouts for known app types. For unknown app types, a generic rich card is rendered with:

  • Colored left border (using x_app.color)
  • Provider icon and name
  • Title and description
  • Thumbnail image
  • Up to 4 data fields from x_app.data displayed as key-value pairs

Built-in Card Types

Traveler (app_type: "traveler")

For resource_type: "trip":

{
"x_app": {
"app_type": "traveler",
"resource_type": "trip",
"color": "#10b981",
"data": {
"location": "Tokyo, Japan",
"start_date": "2026-07-15",
"end_date": "2026-07-29",
"member_count": 4,
"activity_count": 23
}
}
}

Renders a card with a cover image, location pin, date range, member and activity counts.

WatchRadar (app_type: "watchradar")

For resource_type: "media":

{
"x_app": {
"app_type": "watchradar",
"resource_type": "media",
"color": "#667eea",
"data": {
"year": 2026,
"media_type": "tv",
"genres": ["Animation", "Fantasy", "Action"],
"rating": 8.5
}
}
}

Renders a horizontal card with poster, title + year, star rating, and genre pills.

MyCalBook (app_type: "mycalbook")

For resource_type: "event_type":

{
"x_app": {
"app_type": "mycalbook",
"resource_type": "event_type",
"color": "#6366f1",
"data": {
"durations": [15, 30, 60],
"location": "Google Meet",
"host": "alice"
}
}
}

Renders a card with duration pills, location, and host info.

VRChronicles (app_type: "vrchronicles")

For resource_type: "world":

{
"x_app": {
"app_type": "vrchronicles",
"resource_type": "world",
"color": "#4ecdc4",
"data": {
"vrc_world_id": "wrld_abc123",
"author": "WorldCreator",
"capacity": 32,
"performance": "Good",
"lighting": "Baked",
"platforms": ["PC", "Quest"],
"categories": ["Chill", "Social", "Music"],
"screenshot_count": 8
}
}
}

Renders a card with cover image, platform badges, author, capacity, performance/lighting info, and category tags.

Security

  • The oEmbed endpoint URL must be on the same domain as the original page URL. Cross-domain oEmbed endpoints are rejected to prevent SSRF.
  • Only HTTPS oEmbed endpoints are fetched.
  • Responses are limited to JSON content type.
  • A 5-second timeout is enforced on oEmbed fetches.

Example: Express.js Implementation

Here's a minimal example for a Node.js/Express app:

const router = require('express').Router();

// GET /api/oembed?url=...&format=json
router.get('/', async (req, res) => {
const { url, format } = req.query;

if (!url) return res.status(400).json({ error: 'url parameter is required' });
if (format && format !== 'json') return res.status(501).json({ error: 'Only JSON format supported' });

// Parse the URL to identify the resource
const parsed = new URL(url);
const match = parsed.pathname.match(/^\/resource\/(\d+)$/);
if (!match) return res.status(404).json({ error: 'Not found' });

// Fetch your resource data
const resource = await getResourceById(match[1]);
if (!resource) return res.status(404).json({ error: 'Not found' });

const baseUrl = `${req.protocol}://${req.get('host')}`;

res.json({
version: '1.0',
type: 'rich',
provider_name: 'My App',
provider_url: baseUrl,
provider_icon: `${baseUrl}/favicon.svg`,
title: resource.title,
description: resource.description,
thumbnail_url: resource.imageUrl,
thumbnail_width: 800,
thumbnail_height: 400,
x_app: {
app_type: 'myapp',
resource_type: 'resource',
color: '#3b82f6',
data: {
status: resource.status,
created_by: resource.author
}
}
});
});

module.exports = router;

Then in your SSR handler, inject the discovery tag:

const oembedUrl = `${baseUrl}/api/oembed?url=${encodeURIComponent(fullUrl)}&format=json`;
const linkTag = `<link rel="alternate" type="application/json+oembed" href="${oembedUrl}" title="${pageTitle}" />`;
// Inject into <head> alongside your OpenGraph meta tags
note

Your app should still serve OpenGraph meta tags. The oEmbed data enhances the preview but OpenGraph is used as a fallback and is also consumed by other platforms (Slack, Discord, Twitter, etc.).