Skip to main content

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.

Embeds for distributed apps are invitation-only

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:

  1. A user pastes a link, and Slack sends a link_shared event to your app.
  2. Your app unfurls a Work Object by calling the chat.unfurl API method with entity metadata. Include full_size_preview in attributes (without a preview_url yet) to declare embed support. Slack renders the Work Object card in the channel.
  3. The user clicks the Work Object, and Slack sends an entity_details_requested event to your app.
  4. Your app calls the entity.presentDetails API method with the full entity payload, this time including a preview_url in the full_size_preview object. 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.

  1. Your app posts a message by calling the chat.postMessage API method with entity metadata, including full_size_preview in attributes to declare embed support.
  2. The message appears in the channel as a Work Object card.
  3. The user clicks the Work Object. As above, Slack sends the entity_details_requested event, and your app responds with the entity.presentDetails API method including the preview_url value.

Implementation

Prerequisites

Entry points

There are two ways to create a Work Object with embed support.

MethodTriggerAPI callUse case
Link unfurlA user pastes a URL matching your app's unfurl domain.chat.unfurlContextual, where a user shares a link and the Work Object appears automatically.
App notificationYour app decides to post proactively.chat.postMessageAlerts, 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:

  1. Use HTTPS. The URL must be served over HTTPS, and HTTP URLs will not be loaded.

  2. Be cross-origin from Slack. The URL's domain must not be a Slack domain (for example, *.slack.com).

  3. Set the Content-Security-Policy header to allow Slack to iframe your content.

    Content-Security-Policy: frame-ancestors https://*.slack.com https://*.slack-gov.com https://*.slack-mcps.com
  4. 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.

PropertyDescriptionRequired?
is_supportedMust be true to enable embed support.Required
mime_typeMust be application/vnd.slack-embed for interactive embeds.Required
preview_urlThe 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

Featureallow-same-originOrigin: null
Origin valueThe embedded page has an Origin equal to the URL domain.The embedded page has Origin: null.
Cookie behaviorHTTP 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 requestsThe 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 requirementsLower 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 slack-mcps.com origin is required for mobile

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.

A domain allow list is required before embeds will load

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 domainMatches *.app.com?Reason
sub1.app.comYesOne subdomain level fills the wildcard.
sub1.sub2.app.comNoTwo subdomain levels, and the wildcard covers only one.
app.comNoThe 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_url property, 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_preview property is inside attributes, not at the root of the entity_payload object.
  • The unfurl includes full_size_preview with is_supported set to true and mime_type set to application/vnd.slack-embed.
  • The entity.presentDetails call includes preview_url in the full_size_preview object.
  • The preview_url value uses HTTPS.
  • The preview_url value is cross-origin from Slack, so it is not on a *.slack.com domain.
  • 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.postMessage API method, the text field is provided as a fallback.