Work Objects embeds
Work Objects embeds allow a Slack app to render interactive content inside a sandboxed iframe within the Work Object flexpane. Instead of showing a static image or PDF preview, your app can serve a fully interactive web experience, such as a document editor, a dashboard, or a signing flow, directly inside Slack.
To take advantage of the features outlined below, your app needs to have basic support for one or more supported entity types.
First-party apps, meaning apps that are not distributed, can use embeds on their own workspace without any additional setup.
To use embeds in a distributed Slack Marketplace app, apply to our pilot program by filling out the partner interest form. We grant access by invitation.
How embeds work
Embeds build on top of the existing Work Objects unfurl and flexpane features. The flow is:
- A user pastes a link, and Slack sends a
link_sharedevent to your app. - Your app unfurls a Work Object by calling the
chat.unfurlAPI method with entity metadata. Includefull_size_previewinattributes(without apreview_urlyet) to declare embed support. Slack renders the Work Object card in the channel. - The user clicks the Work Object, and Slack sends an
entity_details_requestedevent to your app. - Your app calls the
entity.presentDetailsAPI method with the full entity payload, this time including apreview_urlin thefull_size_previewobject. Slack loads this URL in a sandboxed iframe inside the flexpane.
App-initiated notifications
Embeds also work when your app creates a Work Object proactively through the chat.postMessage API method, with no link paste required. This is useful for notifications, alerts, or bot-initiated workflows.
- Your app posts a message by calling the
chat.postMessageAPI method with entity metadata, includingfull_size_previewinattributesto declare embed support. - The message appears in the channel as a Work Object card.
- The user clicks the Work Object. As above, Slack sends the
entity_details_requestedevent, and your app responds with theentity.presentDetailsAPI method including thepreview_urlvalue.
Implementation
Prerequisites
- A Slack app with Work Objects unfurling already implemented using a supported entity type.
- A publicly accessible HTTPS endpoint that serves your embedded content.
- The
entity_details_requestedevent subscription enabled in your app settings.
Entry points
There are two ways to create a Work Object with embed support.
| Method | Trigger | API call | Use case |
|---|---|---|---|
| Link unfurl | A user pastes a URL matching your app's unfurl domain. | chat.unfurl | Contextual, where a user shares a link and the Work Object appears automatically. |
| App notification | Your app decides to post proactively. | chat.postMessage | Alerts, status updates, or bot workflows where no user action is needed. |
Both methods use the same entity_payload structure, and both support embeds. The only difference is the initial API call. Handling the entity_details_requested event and serving the embedded content apply to both.
Declare embed support in the unfurl
When handling the link_shared event, include full_size_preview inside the attributes object of your entity payload. At unfurl time, you only need to declare support, so no preview_url is required yet.
{
"metadata": {
"entities": [
{
"app_unfurl_url": "https://example.com/document/123",
"url": "https://example.com/document/123",
"external_ref": {
"id": "doc-123",
"type": "document"
},
"entity_type": "slack#/entities/file",
"entity_payload": {
"attributes": {
"title": { "text": "Quarterly Report" },
"product_name": "My App",
"full_size_preview": {
"is_supported": true,
"mime_type": "application/vnd.slack-embed"
}
},
"fields": {}
}
}
]
}
}
The full_size_preview property must be nested inside attributes, not at the root of the entity_payload object.
Create a Work Object through a notification
If your app creates Work Objects proactively (without a link paste), use the chat.postMessage API method with entity metadata. The entity_payload structure is the same as in the unfurl.
{
"channel": "C0123456789",
"text": "New document ready for review: Quarterly Report",
"metadata": {
"entities": [
{
"url": "https://example.com/document/123",
"external_ref": {
"id": "doc-123",
"type": "document"
},
"entity_type": "slack#/entities/file",
"entity_payload": {
"attributes": {
"title": { "text": "Quarterly Report" },
"product_name": "My App",
"full_size_preview": {
"is_supported": true,
"mime_type": "application/vnd.slack-embed"
}
},
"fields": {}
}
}
]
}
}
Unlike the chat.unfurl API method, the chat.postMessage payload does not include the app_unfurl_url field. The url field serves as the entity identifier, and the text field is required as the fallback message.
Provide the embed URL in the flexpane
When handling the entity_details_requested event, call the entity.presentDetails API method with the full entity payload. This time, include the preview_url in the full_size_preview object. This is the URL Slack loads in the iframe.
{
"trigger_id": "1234567890.1234567890.abcdef",
"metadata": {
"entity_type": "slack#/entities/file",
"url": "https://example.com/document/123",
"external_ref": {
"id": "doc-123",
"type": "document"
},
"entity_payload": {
"attributes": {
"title": { "text": "Quarterly Report" },
"product_name": "My App",
"metadata_last_modified": 1741164235,
"full_size_preview": {
"is_supported": true,
"mime_type": "application/vnd.slack-embed",
"preview_url": "https://your-app.example.com/embed/document/123?token=abc123"
}
},
"fields": {}
}
}
}
Serve the embedded content
Your preview_url endpoint must:
-
Use HTTPS. The URL must be served over HTTPS, and HTTP URLs will not be loaded.
-
Be cross-origin from Slack. The URL's domain must not be a Slack domain (for example,
*.slack.com). -
Set the
Content-Security-Policyheader to allow Slack to iframe your content.Content-Security-Policy: frame-ancestors https://*.slack.com https://*.slack-gov.com https://*.slack-mcps.com -
Authenticate the request. Because the iframe URL is visible to the client, use signed URLs or public content to control access. Refer to authentication strategies below.
The following Express route sets the required header, verifies the signed URL, and serves the content.
app.get('/embed/document/:docId', (req, res) => {
// Allow Slack to iframe this page
res.setHeader(
'Content-Security-Policy',
'frame-ancestors https://*.slack.com https://*.slack-gov.com https://*.slack-mcps.com'
);
// Verify the signed URL
if (!verifySignedUrl(req)) {
return res.status(403).send('Access denied: invalid or expired token.');
}
// Serve your embedded content
const doc = getDocument(req.params.docId);
res.send(renderEmbedPage(doc));
});
The full_size_preview property
The full_size_preview property accepts the following fields.
| Property | Description | Required? |
|---|---|---|
is_supported | Must be true to enable embed support. | Required |
mime_type | Must be application/vnd.slack-embed for interactive embeds. | Required |
preview_url | The HTTPS URL Slack loads in the iframe. Not required in the initial unfurl, only in the entity.presentDetails call. | Required |
The full_size_preview property is a member of the attributes object inside entity_payload:
entity_payload
└── attributes
├── title
├── product_name
└── full_size_preview
├── is_supported
├── mime_type
└── preview_url
Authentication strategies
Because your application loads inside an iframe within Slack, you must account for how your users are authenticated. There are two primary options.
Public content
Ensure that all content and resources displayed within the embed are publicly available. This approach has the lowest implementation cost, but it is only suitable when the embedded content does not contain sensitive or user-specific data.
Signed URLs
We recommend signing the URL by passing a short-lived authentication token as a query parameter. The embedded page must then exchange this short-lived token for authentication cookies before loading other resources, or explicitly sign requests for additional resources (for example, adding a token parameter to the src attribute of <img> tags).
import crypto from 'node:crypto';
const SECRET = process.env.EMBED_SIGNING_SECRET;
function generateSignedUrl(baseUrl, params) {
const url = new URL(baseUrl);
const expiresAt = Math.floor(Date.now() / 1000) + (params.expiresIn || 600);
url.searchParams.set('docId', params.docId);
url.searchParams.set('exp', expiresAt.toString());
const dataToSign = `${url.pathname}?${url.searchParams.toString()}`;
const signature = crypto
.createHmac('sha256', SECRET)
.update(dataToSign)
.digest('hex');
url.searchParams.set('sig', signature);
return url.toString();
}
Security and sandboxing
To protect both Slack users and your application, the iframe is sandboxed. The sandbox allows the embed to execute scripts (allow-scripts), and you can choose between two origin policies depending on your existing technology and risk profile.
There is no communication between the Slack client and the embedded iframe, which is the most secure configuration.
Sandbox policy comparison
| Feature | allow-same-origin | Origin: null |
|---|---|---|
| Origin value | The embedded page has an Origin equal to the URL domain. | The embedded page has Origin: null. |
| Cookie behavior | HTTP requests made from the embed include any cookies on the Origin, and Set-Cookie headers in responses set cookies in the browser or app cookie store. | HTTP requests made from the embed do not include any cookies, and Set-Cookie headers in responses are ignored. |
| Network requests | The browser allows XMLHttpRequest and fetch requests to the Origin. | The browser blocks XMLHttpRequest and fetch requests to all other origins unless cross-origin resource sharing (CORS) headers are configured. |
| Development requirements | Lower implementation complexity if you rely on existing authentication cookies. | All retrieved resources must be public, pre-signed, or include an Authorization header. |
By default, the embed runs with Origin: null, which is the most secure configuration. If your embed relies on existing authentication cookies, you can enable allow-same-origin for your app on the app settings page for Work Objects and rich previews.
Required configurations
Set your Content-Security-Policy frame-ancestors header to allow Slack to iframe your content:
Content-Security-Policy: frame-ancestors https://*.slack.com https://*.slack-gov.com https://*.slack-mcps.com
This ensures your page is only embeddable within the environments Slack permits, including mobile clients.
The https://*.slack-mcps.com origin is required for mobile support. Without it, Work Objects embeds fail to load on the Slack mobile clients. Ensure all three origins are included in your frame-ancestors directive.
Domain allow list
You must declare the domains you intend to embed within your full size previews. Configure this allow list on the app settings page for Work Objects and rich previews.
Slack does not allow embeds until a domain allow list is configured for your app. If the URL domain presented in the full size preview does not exist in the allow list, Slack scrubs the preview from the data presented to the end user.
Entries support one level of wildcard matching. A wildcard replaces exactly one subdomain level, so the entry *.app.com matches a single label in that position and nothing deeper or shallower. Multi-level wildcards such as *.*.app.com are not supported.
The following table shows what the entry *.app.com matches.
| URL domain | Matches *.app.com? | Reason |
|---|---|---|
sub1.app.com | Yes | One subdomain level fills the wildcard. |
sub1.sub2.app.com | No | Two subdomain levels, and the wildcard covers only one. |
app.com | No | The root domain has no subdomain to fill the wildcard. |
To allow domains a single wildcard does not cover, such as deeper subdomains or the root domain, add each one to the allow list as its own entry (for example, sub1.sub2.app.com and app.com).
Security requirements and partner responsibilities
Slack applies sandboxing and iframe isolation to protect users within the Slack client, as described in security and sandboxing above. However, because your application serves the embedded content and controls the signed URL, partners bear direct responsibility for the security of their embed endpoint and the content it serves.
Partners are responsible for the following.
- Preventing injection attacks. Your embed endpoint must sanitize all inputs and protect against cross-site scripting (XSS), HTML injection, and other content injection vulnerabilities. Because you generate and sign the
preview_urlproperty, any parameters or tokens included in that URL are under your control and must be validated server-side. - Keeping token sessions short-lived. Signed URLs and any associated session tokens should have the shortest practical expiration window. We recommend a maximum time-to-live (TTL) of 10 minutes. Long-lived tokens increase the risk of replay attacks if a URL is intercepted or leaked.
- Securing your embed endpoint. Your server must authenticate every request, reject expired or tampered signatures, and enforce appropriate access controls. Do not rely solely on the iframe sandbox for security.
If an embed is found to be serving malicious content, distributing malware, or otherwise compromising user safety, Slack reserves the right to revoke embed access immediately and without notice.
Complete example
The following example handles the link_shared event and unfurls a Work Object with embed support.
import { generateSignedUrl } from '../utils/token.js';
app.event('link_shared', async ({ event, client }) => {
for (const link of event.links) {
const doc = getDocumentByUrl(link.url);
if (!doc) continue;
await client.chat.unfurl({
channel: event.channel,
ts: event.message_ts,
unfurls: {},
metadata: {
entities: [{
app_unfurl_url: link.url,
url: link.url,
external_ref: { id: doc.id, type: 'document' },
entity_type: 'slack#/entities/file',
entity_payload: {
attributes: {
title: { text: doc.title },
product_name: 'My App',
full_size_preview: {
is_supported: true,
mime_type: 'application/vnd.slack-embed',
},
},
fields: {
status: {
value: doc.status,
tag_color: 'green',
},
},
},
}],
},
});
}
});
Alternatively, post a Work Object notification with embed support. This approach suits slash commands, incoming webhooks, scheduled jobs, or any flow where your app initiates the Work Object rather than responding to a user-pasted link.
// Example: a slash command, webhook, or scheduled job triggers this
async function postDocumentNotification(client, channelId, doc) {
await client.chat.postMessage({
channel: channelId,
text: `New document ready for review: ${doc.title}`,
metadata: {
entities: [{
url: `https://your-app.example.com/document/${doc.id}`,
external_ref: { id: doc.id, type: 'document' },
entity_type: 'slack#/entities/file',
entity_payload: {
attributes: {
title: { text: doc.title },
product_name: 'My App',
full_size_preview: {
is_supported: true,
mime_type: 'application/vnd.slack-embed',
},
},
fields: {
status: {
value: doc.status,
tag_color: 'green',
},
},
},
}],
},
});
}
Handle the entity_details_requested event and provide the embed URL.
app.event('entity_details_requested', async ({ event, body, client }) => {
const doc = getDocument(event.external_ref.id);
const signedUrl = generateSignedUrl(
`https://your-app.example.com/embed/document/${doc.id}`,
{ docId: doc.id, expiresIn: 600 }
);
await client.apiCall('entity.presentDetails', {
trigger_id: body.trigger_id,
metadata: {
entity_type: 'slack#/entities/file',
url: event.entity_url,
external_ref: {
id: doc.id,
type: 'document',
},
entity_payload: {
attributes: {
title: { text: doc.title },
product_name: 'My App',
metadata_last_modified: Math.floor(Date.now() / 1000),
full_size_preview: {
is_supported: true,
mime_type: 'application/vnd.slack-embed',
preview_url: signedUrl,
},
},
fields: {
status: {
value: doc.status,
tag_color: 'green',
},
},
},
},
});
});
Serve the embedded content.
app.get('/embed/document/:docId', (req, res) => {
res.setHeader(
'Content-Security-Policy',
'frame-ancestors https://*.slack.com https://*.slack-gov.com https://*.slack-mcps.com'
);
if (!verifySignedUrl(req)) {
return res.status(403).send('Access denied.');
}
const doc = getDocument(req.params.docId);
res.send(renderEmbedPage(doc));
});
Checklist
- The
full_size_previewproperty is insideattributes, not at the root of theentity_payloadobject. - The unfurl includes
full_size_previewwithis_supportedset totrueandmime_typeset toapplication/vnd.slack-embed. - The
entity.presentDetailscall includespreview_urlin thefull_size_previewobject. - The
preview_urlvalue uses HTTPS. - The
preview_urlvalue is cross-origin from Slack, so it is not on a*.slack.comdomain. - The embed endpoint sets
Content-Security-Policy: frame-ancestors https://*.slack.com https://*.slack-gov.com https://*.slack-mcps.com. - The embed endpoint authenticates requests through signed URLs or public content.
- Signed URL tokens use a short-lived TTL, ideally 10 minutes or less.
- The embed endpoint validates and sanitizes all input parameters to prevent injection attacks.
- The entity type is enabled in your app's Work Object Previews settings.
- The embed domain is added to the domain allow list in your app's Work Objects and rich previews settings.
- When using the
chat.postMessageAPI method, thetextfield is provided as a fallback.