# Unfurling links

Roam apps can receive matching links from new messages and attach rich preview
cards without Roam fetching the destination. This is useful for incident,
ticket, document, and deployment links whose best summary is available only to
your app.

## 1. Register domains

Domains are registered on your app, not on the Developer page itself:

1. Open **Roam Administration → Developer** (gear icon → Settings).
2. On the **API Clients** tab, click **Edit** next to your app under **API Keys**
   or **OAuth Apps**. (No app yet? Click **Add API Key** or **Create OAuth App**,
   then **Create**.)
3. In the app's left sidebar, open the **Webhooks** tab and scroll to **Link
   Unfurling Domains**.
4. Add up to five domains and click **Save**.

Exact hosts match only themselves. A leading wildcard matches exactly one
additional label:

| Registration | Matches | Does not match |
|---|---|---|
| `status.example.com` | `status.example.com` | `www.status.example.com` |
| `*.example.com` | `status.example.com` | `example.com`, `a.status.example.com` |

Roam canonicalizes entries to lowercase ASCII hostnames. Schemes, paths, ports,
IP addresses, localhost, public suffixes, and misplaced wildcards are rejected.
Domain changes may take up to one minute to propagate in production.

Works with **API Key** and **OAuth** clients. Personal Access Token clients
cannot register unfurl domains (or call `chat.unfurl`). OAuth installations
inherit the domains configured on their parent OAuth app.

## 2. Subscribe to link events

On the same app:

1. **Permissions** tab: enable `links:read` (and `links:write` for step 3).
2. **Webhooks** tab: enter your endpoint under **Webhook URL**, turn on
   [`chat.link.shared`](/docs/webhooks/chat-link-shared) under **Subscribed
   Events**, and click **Save**.

Existing OAuth installations keep their old scopes until they
[re-authorize](/docs/guides/oauth), so they won't receive link events or be able
to call `chat.unfurl` until then.

See [Configuring Webhooks](/docs/webhooks/webhooks#static-configuration) for the
rest of the Webhooks tab, or subscribe programmatically with
[`/webhook.subscribe`](/docs/webhooks/webhook-subscribe).

Each delivery uses the common webhook envelope. Its `data.links` list contains only URLs matching that
installation's registrations, uniquely and in message order.

The event fires for newly created text and markdown messages only. It does not
fire for edits or messages sent by that same app installation, preventing an
unfurl response loop. Organization apps see member chats **or** public groups;
personal installations need the owner in the chat — [Chat](/docs/guides/chat).

## 3. Attach previews

Request `links:write` and call [`POST /chat.unfurl`](/docs/api/chat-unfurl) with
the event's `chatId`, `messageTimestamp`, and one or more exact URL keys:

```json
{
  "chatId": "8f3b9c2e-1a4d-4e7b-9c0a-2b6d1f5e3a7c",
  "messageTimestamp": 1748906400000000,
  "unfurls": {
    "https://status.example.com/incidents/123": {
      "title": "Incident 123",
      "description": "Investigating elevated errors",
      "siteName": "PagerDuty"
    }
  }
}
```

Every URL is checked against the message's current text and the app's current
domain registrations. If any URL fails, the whole request is rejected with
`cannot_unfurl_url` and no cards change. App cards replace Roam-generated cards
for the same exact URL, preserve unrelated previews, and appear in message-link
order. The latest valid app update wins.

`favicon` and `image.url` must be `https` display URLs. Roam's server does not
fetch them; clients render them with the existing link-preview card.

Successful unfurls currently persist as an ordinary message edit (version bump).
Clients may show an **Edited** label on the original message, and subscribers
with `chat:history` receive a `chat.message` edit event. Suppressing the Edited
badge for preview-only writes is planned as a follow-up.