
As users post messages containing links into Slack, we attach simple previews, adding context and continuity to conversations. With a Slack app, you can customize these link unfurls further, turning brief reading experiences into something richer.
Bring your own content and make it thrive: actionable, interactive, and easily addressed by URL. Or just keep it simple and let your content stand vivaciously alone.
There are at least two kinds of unfurls.
Slack app unfurling - When Slack encounters links your app is associated with, we send your app an event & your app decides what to do next with it. It's an exciting opportunity to start an interactive message workflow.
Classic unfurling - When Slack spots a link, we crawl it, devour its metadata, and spit out a mostly uniform summary.
If you've already read Everything you ever wanted to know about unfurling but were afraid to ask, then you might not actually know everything yet. Let's start with what's new.
Teach links new tricks by using the Events API and Web API together. Of course, you'll need your very own Slack app.
Don't know if you're ready to get started? Read our announcement about app unfurls.

Here's how to unfurl links with the greatest of ease:
link_shared events.links:read and links:write scopes to perform all operations.link_shared event, giving your app the hints it needs to do its thing.chat.unfurl to attach custom unfurling behavior to the original message. Like a cherry on top of an already special sundae, you can make the messages interactive too.Let's review that song and dance in more detail.
You'll need a Slack app and a server Slack can reach to follow along. If you want to develop locally, consider using a connection proxying tool as described in this tutorial.
Set your app up to use the Events API. You'll also be using the Web API — to unfurl links you'll need both the links:read and links:write permission scopes.
Before installing your app, be sure and set up your event subscriptions and register your domain.
Your app will be notified when a message containing a URL its interested in is posted to a channel it has access to. The vehicle for this notification is an Events API-only event type, link_shared.
These events do not contain the message itself, but instead just the info about the message you need to provide it unfurl behavior: the message's ts value, the channel it appeared in, and which URLs it contained matching your registered domains.
There are two kinds of event subscriptions: "Team Events" and "Bot Events". Slack app unfurling only supports "Team Events." Subscriptions to "Team Events" yield events perspectival to the installing user.
Your app will receive events as messages are posted in channels one or more installing users can see — including all of a team's public channels but only the private channels and direct messages the installing users may access.
Your app can't unfurl links it's not party to. If Person A installed the app, and Persons B & C are direct messaging each other, your app can't unfurl links in that conversation.
Slack apps can act as unfurlers for up to five registered domains. Register your domains on the Event Subscriptions page of your app's configuration.
Each domain you submit will be matched to URLs by a few heuristics:
example.com and another.example.com are valid, example is not and nor is .com).http:// or https://). Slack will not unfurl decidedly ambiguous domain and URL mentions.https://example.com:23/skidoo), Slack will still consider it a clean match to a registered example.com.Here's another way of looking at the matching rules:
| Domain | URL in a message | Matches? |
|---|---|---|
| example.com | https://example.com | Yes |
| example.com | http://www.example.com | Yes |
| example.com | example.com | No |
| example.com | https://example.com/some/path?yes=indeed | Yes |
| example.com | http://another.example.com/some/other/path?no=exit | Yes |
| example.com | https://someotherexample.com | No |
| another.example.com | https://another.example.com | Yes |
| another.example.com | https://example.com | No |
| example | http://example | No |
| 127.0.0.1 | https://127.0.0.1 | No |
| .com | http://example.com | No, No, No. |
Domain etiquette: You should own these domains and if you don't, you must follow all the terms, conditions, rules, policies, proclamations, warnings, and edicts around a domain. Be courteous, be kind, be helpful.
Be sure and leave out the protocol part of a URL when registering your domain. Discard any http:// or https:// or path or query string components, please.
Finally, Unicode domains are not yet supported.
Before proceeding, make sure your Slack app has been installed on the team you're working against and that installation included the links:read and links:write scopes (or the bot scope if that's more relevant to your subscriptions).
Adding or removing domains requires re-installation of your Slack app. Every time an app is installed, the installing user is agreeing to those specifically mentioned domains.
You've set everything up: your Slack app, the Events API, a link_shared event subscription, and you've registered your domains. You've installed your Slack app on a team, specifically requesting the scopes you needed and after you've registered your domains.
You have the knowledge. You have the back end. You have the power. You are ready to receive and react to link_shared event deliveries.
When a user shares a link in a channel that matches your criteria, you'll receive an event shaped like this:
{
"token": "XXYYZZ",
"team_id": "TXXXXXXXX",
"api_app_id": "AXXXXXXXXX",
"event": {
"type": "link_shared",
"channel": "Cxxxxxx",
"user": "Uxxxxxxx",
"message_ts": "123456789.9875",
"links": [
{
"domain": "example.com",
"url": "https://example.com/12345"
},
{
"domain": "example.com",
"url": "https://example.com/67890"
},
{
"domain": "another-example.com",
"url": "https://yet.another-example.com/v/abcde"
}
]
},
"type": "event_callback",
"authed_users": [
"UXXXXXXX1",
"UXXXXXXX2"
],
"event_id": "Ev08MFMKH6",
"event_time": 123456789
}
For detail on most of these fields, consult the Events API field guide.
You'll want to pay extra special attention to these fields while working with link_shared events:
token - As with all Events API events, this value is a shared secret between you and Slack. You'll find it in your App Credentials section of the Basic Information app configuration page. Compare this value with the one given to you by Slack and only process the event if they match.event - this is where all the stuff you're looking for lurks. It's an object.Let's crack that event open:
| Field | Type | Required? | Description |
|---|---|---|---|
type |
string | Yes | The specific name of the event described by its adjacent fields. This field will always be link_shared for this flavor of unfurling event.
Example: |
channel |
string | Yes | The scene of the crime! This is where the link was mentioned. It's a channel ID, which means it might begin with C for a public channel, D for a direct message. But really, your app shouldn't care what it starts with. You'll need this mostly opaque value when using chat.unfurl to attach your content & behavior to a message.
Examples: |
user |
string | Yes | The user ID belonging to the user that posted the message mentioning the link. If the content you unfurl requires some kind of authority to post in a channel, compare this value with your explicitly authenticated records before proceeding. See authenticated unfurls for more info.
Examples: |
message_ts |
string | Yes | Almost all Slack messages have their own kind of quasi-timetsamp quasi-ID value called ts. You'll find the message that mentioned the link's ts value here and you need to provide it as part of chat.unfurl, along with channel. |
links |
array | Yes |
A collection of key/value pairs indicating the specific matching domains and URLs referenced in the invoking message. Each array item is a simple hash containing two fields:
|
Once you've received this event it's time to do decide what to do next. Most likely, you'll use those url values to look up what to display to the user. Maybe you'll make an API call to another service. Maybe you'll just reference your app's own base of knowledge and never even hit another service at all.
Be sure and respond with a friendly HTTP 200 OK to the event as quickly as possible. Do not wait to wrestle an unfurl with chat.unfurl before telling Slack you received the event. You'll probably want to enqueue behavior like this.
Consider this link_shared event a kind of ping. Now it's up to you to pong with chat.unfurl.
chat.unfurlThe Web API method chat.unfurl takes a message identifier, a pointer to the channel where it was mentioned, and your wonderful content.
The links:write scope requested when users install your Slack app grants it the ability to leverage user tokens to attach additional content to messages mentioning your registered domains.
Sorry, bot user tokens cannot use chat.unfurl directly. You must still request the links:write permission scope and use the installing user's token instead.
Most of the chat.unfurl parameters are required:
token - the user token granted to you when installing your app for the team you're acting on behalf of.channel - the channel ID belonging to where the message mentioning your links happenedts - the unique timestamp / message ID belonging to the message mentioning your linksunfurls - a URL-encoded string on JSON, detailing the unfurl attachments you'll provide for each mentioned URL. See below.user_auth_required - (optional) require the user posting the link to authenticate with your app first. See authenticated unfurls below.The provided ts value must correspond to a message in the specified channel. Additionally, the message must contain a fully-qualified URL pointing to a domain that is already registered and associated with your Slack app. All of these things must be true to use chat.unfurl.
unfurls parameterThe third important parameter to provide to chat.unfurl is unfurls.
The unfurls parameter expects a URL-encoded string of JSON. Unlike chat.postMessage's attachments parameter, it does not expect a JSON array but instead, a hash keyed on the specific URLs you're offering an unfurl for.
Each defined URL may have a single attachment, including message buttons. All the typical formatting available to you in message attachments hold true, except you can have only one per URL.
You could send the parameter very simple JSON like this:
{
"https://example.com/": {
"text": "Every day is the test."
}
}
And then prepare that for a unfurls parameter like:
unfurls=%7B%22https%3A%2F%2Fexample.com%2F%22%3A%7B%22text%22%3A%22Every%20day%20is%20the%20test.%22%7D%7D
Sending it along with your channel ID and message ts like so:
POST /api/chat.unfurl?token=xoxa-1234abcdefghijklmnop
channel=C123456&ts=123456789.9875&unfurls=%7B%22https%3A%2F%2Fexample.com%2F%22%3A%7B%22text%22%3A%22Every%20day%20is%20the%20test.%22%7D%7D
If your attempt to attach your unfurl to the message is successful, you'll be the proud winner of a rather generic HTTP 200 OK response:
{
"ok": true
}
Your work here is done, unless you're conducting interactive message operations.
Of course, there are error responses too. You'll receive them if you forget to include ts, channel, and properly-formed unfurls attachments. See the errors section of chat.unfurl for more info.
That's it. You made a link unfurl and added to the complexity of the universe in your own way.
Since the attachments you provide as link unfurls are just like other Slack app-enabled message attachments, you can make them interactive with message buttons too.
Let's build on the knowledge gained so far and, provided you've set yourself up to use message buttons already, let's examine a more complicated example.
Let's say your app received an event detailing a match for https://figment.example.com/imagine. This is a service you provide to help stimulate the imagination. At this specific URL, you generate a random imagination exercise to stimulate the working mind.

This imagination machine might construct its JSON hash response to something resembling this:
{
"https://figment.example.com/imagine": {
"title": "Let's pretend we're on a rocket ship to Neptune",
"text": "The planet Neptune looms near. What do you want to do?",
"callback_id": "imagine_001",
"attachment_type": "default",
"fallback": "Pretend your rocket ship is approaching Neptune. What do you want to do next?",
"actions": [
{
"name": "decision",
"value": "orbit",
"style": "primary",
"text": "Orbit",
"type": "button"
},
{
"name": "decision",
"value": "land",
"text": "Attempt to land",
"type": "button"
},
{
"name": "decision",
"value": "self_destruct",
"text": "Self destruct",
"type": "button",
"style": "danger",
"confirm": {
"title": "Are you sure you want to self destruct?",
"text": "Maybe you should attempt to land instead. You might crash.",
"ok_text": "Yes, self destruct",
"dismiss_text": "No thanks"
}
}
]
}
}
}
And then URL encode that JSON and stuff it into one extra long unfurls parameter, along with your channel and ts:
POST /api/chat.unfurl?token=xoxa-1234abcdefghijklmnop
channel=C123456&ts=123456789.9875&unfurls=%7B%22https%3A%2F%2Ffigment.example.com%2Fimagine%22%3A%20%7B%22title%22%3A%20%22Let%27s%20pretend%20we%27re%20on%20a%20rocket%20ship%20to%20Neptune%22%2C%22text%22%3A%20%22The%20planet%20Neptune%20looms%20near.%20What%20do%20you%20want%20to%20do%3F%22%2C%22callback_id%22%3A%20%22imagine_001%22%2C%22attachment_type%22%3A%20%22default%22%2C%22fallback%22%3A%20%22Pretend%20your%20rocket%20ship%20is%20approaching%20Neptune.%20What%20do%20you%20want%20to%20do%20next%3F%22%2C%22actions%22%3A%20%5B%7B%22name%22%3A%20%22decision%22%2C%22value%22%3A%20%22orbit%22%2C%22style%22%3A%20%22primary%22%2C%22text%22%3A%20%22Orbit%22%2C%22type%22%3A%20%22button%22%7D%2C%7B%22name%22%3A%20%22decision%22%2C%22value%22%3A%20%22land%22%2C%22text%22%3A%20%22Attempt%20to%20land%22%2C%22type%22%3A%20%22button%22%7D%2C%7B%22name%22%3A%20%22decision%22%2C%22value%22%3A%20%22self_destruct%22%2C%22text%22%3A%20%22Self%20destruct%22%2C%22type%22%3A%20%22button%22%2C%22style%22%3A%20%22danger%22%2C%22confirm%22%3A%20%7B%22title%22%3A%20%22Are%20you%20sure%20you%20want%20to%20self%20destruct%3F%22%2C%22text%22%3A%20%22Maybe%20you%20should%20attempt%20to%20land%20instead.%20You%20might%20crash.%22%2C%22ok_text%22%3A%20%22Yes%2C%20self%20destruct%22%2C%22dismiss_text%22%3A%20%22No%20thanks%22%7D%7D%5D%7D%7D%7D
Now your interactive unfurl is firmly attached to the originating message, complete with buttons.
Almost everything you already know about message buttons is true for these kind of buttons too.
Almost everything. Read on.
When a user decides to click on one of your buttons, we'll send your action URL an invocation payload just as usual.
The only catch is that the original_message field will only contain the message attachment your app added when providing the unfurl. You still can't see the entire message.
Here's an example invocation:
{
"actions": [
{
"name": "decision",
"type": "button",
"value": "self_destruct"
}
],
"callback_id": "response_123",
"team": {
"id": "T123456",
"domain": "example"
},
"channel": {
"id": "C123456",
"name": "generators"
},
"user": {
"id": "U061F7AUR",
"name": "exemplar"
},
"action_ts": "123456791.2111",
"message_ts": "123456789.9875",
"attachment_id": "1",
"token": "xxx",
"is_app_unfurl": true,
"original_message": {
"attachments": [
{
"callback_id": "imagine_001",
"fallback": "Pretend your rocket ship is approaching Neptune. What do you want to do next?",
"text": "The planet Neptune looms near. What do you want to do?",
"title": "Let's pretend we're on a rocket ship to Neptune",
"id": 1,
"actions": [
{
"name": "decision",
"value": "orbit",
"style": "primary",
"text": "Orbit",
"type": "button"
},
{
"name": "decision",
"value": "land",
"text": "Attempt to land",
"type": "button"
},
{
"name": "decision",
"value": "self_destruct",
"text": "Self destruct",
"type": "button",
"style": "danger",
"confirm": {
"title": "Are you sure you want to self destruct?",
"text": "Maybe you should attempt to land instead. You might crash.",
"ok_text": "Yes, self destruct",
"dismiss_text": "No thanks"
}
}
],
"bot_id": "B123456",
"app_unfurl_url": "https://figment.example.com/imagine",
"is_app_unfurl": true
}
]
},
"response_url": "https://hooks.slack.com/actions/T123456/XXXX/XXXX"
}
Looks like the user decided for the ship to self destruct after all.
Here's a closer look at the most relevant fields in this flavor of interactive message:
| Field | Type | Required? | Description |
|---|---|---|---|
is_app_unfurl |
boolean | No | When set to true, this invocation is related to a Slack app unfurl your app is registered to handle. When absent or false, it's a standard message action. |
original_message |
object | No | Contains attachments relevant to link unfurling from the original message that started this flow. See below. |
Within an action invocation's original_message field you'll find an array containing only the attachments relevant to your unfurling behavior — in fact they are the same attachments you provided in the original chat.unfurl call that initiated this workflow. But we've decorated them with some helpful fields.
Each attachment field in original_message's attachments array contains:
| Field | Type | Required? | Description |
|---|---|---|---|
app_unfurl_url |
string | No | This is the URL that sparked this flurry of activity. Tie this to a URL received in a link_shared event you processed. |
is_app_unfurl |
boolean | No | When set to true, this specific attachment is related to a Slack app unfurl your app is registered to handle. When absent or false, it's a standard message attachment. |
From this point forward, you can use the response_url and all the other tools in the interactive message toolbox to evolve this workflow. What next will befall our intrepid interstellar travelers?
Of course, not all links are wild and free, full of content anyone can see. Some links require you to pay the piper or validate your identity and authority to access.
We provide a helpful way to ask the user posting a link to your service to authenticate before proceeding with an unfurl the whole channel can see.
If you react to a link_shared event with a call to chat.unfurl with the user_auth_required parameter set to true, instead of displaying custom unfurl attachments, Slack displays an ephemeral message encouraging the user to install your app:

By selecting Install from App Directory, users will be taken to your application's installation or configuration page — even if it's not part of the app directory. That's confusing, we know. We'll keep making the installation steps in this sequence more intuitive.
Unless you're building an internal integration, you'll likely want to provide an Add to Slack on your app's home page that requests the links:read and links:write scopes.
This way you use the OAuth sequence to validate the authority of the team member to unfurl privileged content within a channel for everyone to see. During the callback step where the user returns to your website, you would capture any needed additional information about the user about their identity on your service. What you need to do is up to you and the context of your content. Avoid surprising users by doing something unexpected.
After installation, the next time the user posts a message mentioning your links (or even retroactively), you can provide unfurl attachments.
Subtlety makes life secretly spicy. In case you missed it:
http or https is required.original_message, relevant to the links you provided unfurling behavior for.link_shared events don't contain the original message; your app just learns about any links that match your registered domains.Imagine the figure of a fiddlehead fern frond unfurling, its universe within unraveled— revealed.
In addition to Slack app unfurling, we generate simpler content previews by default. Learn more about classic unfurling below.
If a team doesn't have a Slack app handler for a specific domain, unfurling will fall back to classic behavior: Slack crawls the URL, looks for common OpenGraph and Twitter Card metadata, and renders some micro-approximation of the content. For some domains, Slack even provides its own extra bells and whistles.

When deciding whether to unfurl a link we consider the type of content that has been linked to. We treat "media" -- that is images, tweets, videos, or audio -- differently to pages that are primarily text-content.
Here are some examples of media content:
While these are examples of text-based content:
By default we unfurl all links in any messages posted by users. For messages posted via incoming webhooks or the chat.postMessage API method, we will unfurl links to media, but not other links.
If you'd like to override these defaults on a per-message basis you can pass
unfurl_links or unfurl_media while posting that message. unfurl_links
applies to text based content, unfurl_media applies to media based content.
These flags are mutually exclusive, the unfurl_links flag has no effect on
media content.
There is one notable exception to these rules: we never unfurl links where
the label is a complete substring of your URL minus the protocol. This is so a
paragraph of text can contain domain names or abbreviated URLs that are
treated as a simple reference, and not a link to be unfurled. For example, if
a message contains a link to http://example.com with the label example.com
then that link will not be unfurled. There are more examples of this rule
below.
Note that our servers need to fetch every URL in a message in order to
determine what kind of content it references. If you'd like to stop this
from happening, set both unfurl_links and unfurl_media to false when posting the message.
Want to know more about unfurling? Find out everything you ever wanted to know about unfurling but were afraid to ask.
All of these examples are for incoming webhooks, but similar rules apply to our other APIs:
api.slack.com is text-based, so this link will not unfurl:
{
"text": "<https://api.slack.com>"
}
Passing "unfurl_links": true means the link will unfurl:
{
"text": "<https://api.slack.com>",
"unfurl_links": true
}
This xkcd link is an image, so the content will be unfurled by default:
{
"text": "<http://imgs.xkcd.com/comics/regex_golf.png>"
}
We can then disable that using the unfurl_media flag:
{
"text": "<http://imgs.xkcd.com/comics/regex_golf.png>",
"unfurl_media": false
}
Even though unfurl_links is true, this link has a label that matches the URL minus the protocol, so the link will not unfurl:
{
"text": "<https://api.slack.com|api.slack.com>",
"unfurl_links": true
}
The label for this link does not match the URL minus the protocol, so this link will unfurl:
{
"text": "<https://api.slack.com|Slack API>",
"unfurl_links": true
}
Featuring artwork by Peter Ryan.