Workflows

Send a Slack message from a workflow

Post a Slack message when a workflow action fires, from a Sanity Function generated from the workflow, without posting twice when the effect runs again.

Early access

This guide builds an approval workflow that posts one Slack message when someone approves. An effect is a step the workflow hands off to code outside the engine. Here, the approve action queues a slack-notify effect. A Sanity Function generated from the workflow runs the effect’s handler. The handler posts the message and records the time it posted as the effect’s sentAt output.

You need:

Get a Slack incoming webhook

An incoming webhook is a URL that posts messages to one Slack channel. Slack issues it to a Slack app, as Sending messages using incoming webhooks describes:

  • Go to api.slack.com/apps, click Create New App, and choose From scratch. Name the app and pick your workspace. To create the app From a manifest instead, use the manifest below: the app needs a bot user and the incoming-webhook bot scope.
  • Open Incoming Webhooks in the app settings and switch Activate Incoming Webhooks on.
  • Click Add New Webhook to Workspace, pick the channel the messages go to, and click Allow.
  • Copy the webhook URL. It starts with https://hooks.slack.com/services/. You put it in .env as SLACK_WEBHOOK_URL in a later step.
display_information:
  name: Workflows notifications
features:
  bot_user:
    display_name: Workflows
oauth_config:
  scopes:
    bot:
      - incoming-webhook

The webhook URL is the only Slack credential this guide uses. You don’t need the bot token, any app-level token, or any other scope. Anyone with the URL can post to the channel, so keep it out of version control.

Create the project

Create a directory with the six files below. The generator and setup add the rest.

package.json installs the Workflows packages at one version and runs setup.ts with the values from .env:

{
  "name": "example-slack-effect",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "setup": "node --env-file=.env setup.ts",
    "workflows": "node --env-file=.env node_modules/@sanity/workflow-cli/bin/run.js",
    "sanity": "node --env-file=.env node_modules/sanity/bin/sanity"
  },
  "dependencies": {
    "@sanity/blueprints": "^0.32.0",
    "@sanity/client": "^8.11.0",
    "@sanity/functions": "^1.7.3",
    "@sanity/workflow-blueprint": "^0.38.0",
    "@sanity/workflow-engine": "^0.38.0"
  },
  "devDependencies": {
    "@sanity/workflow-cli": "^0.38.0",
    "@types/node": "^26.6.5",
    "sanity": "^6.21.0",
    "typescript": "^6.0.3"
  }
}

Copy .env.example to .env. You fill it in when you deploy:

# An API token with the Developer role (or higher) on the project in sanity.workflow.ts
SANITY_AUTH_TOKEN=
# A Slack incoming webhook URL: https://api.slack.com/messaging/webhooks
SLACK_WEBHOOK_URL=

workflow.ts defines the workflow. Its review stage has one approve action, which declares the slack-notify effect. The effect binds the workflow’s title field as its title param and declares one output, sentAt:

import {defineWorkflow} from '@sanity/workflow-engine/define'

export const approval = defineWorkflow({
  name: 'approval',
  title: 'Approval',
  initialStage: 'review',
  fields: [{type: 'string', name: 'title', title: 'Title', initialValue: {type: 'input'}}],
  stages: [
    {
      name: 'review',
      title: 'Review',
      activities: [
        {
          name: 'review',
          title: 'Review',
          actions: [
            {
              name: 'approve',
              title: 'Approve',
              status: 'done',
              effects: [
                {
                  name: 'slack-notify',
                  bindings: {title: '$fields.title'},
                  outputs: [{type: 'datetime', name: 'sentAt', title: 'Sent at'}],
                },
              ],
            },
          ],
        },
      ],
      transitions: [{name: 'approved', to: 'approved'}],
    },
    {name: 'approved', title: 'Approved'},
  ],
})

effect-handlers/slack-notify.ts is the effect handler. The generator looks in effect-handlers/ for one handler per effect and connects it to the generated Function. If a handler file already exists, the generator leaves it unchanged:

import type {EffectHandler} from '@sanity/workflow-engine'

export const slackNotify: EffectHandler = async (params) => {
  const webhookUrl = process.env.SLACK_WEBHOOK_URL
  if (!webhookUrl) throw new Error('SLACK_WEBHOOK_URL is not set')

  // You might want to handle failure here, for example a message that was already sent.
  const response = await fetch(webhookUrl, {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({text: `Approved: ${String(params.title)}`}),
  })
  if (!response.ok) {
    throw new Error(`Slack responded ${response.status}: ${await response.text()}`)
  }

  return {outputs: {sentAt: new Date().toISOString()}}
}

sanity.workflow.ts binds the definition to a deployment named slack. Set workflowResource.id to your project ID and dataset, joined by a dot, such as abc123de.production:

import type {WorkflowDeploymentInput} from '@sanity/workflow-engine'
import {defineWorkflowConfig} from '@sanity/workflow-engine/define'

import {approval} from './workflow.ts'

// Every generated function bundles this file, so it must not read the environment.
export const slack = {
  name: 'slack',
  partition: 'slack-example',
  expectedMinReaderModel: 11,
  workflowResource: {type: 'dataset', id: '<projectId>.<dataset>'},
  definitions: [approval],
} satisfies WorkflowDeploymentInput

export default defineWorkflowConfig({deployments: [slack]})

setup.ts runs the deploy steps in order:

import {spawnSync} from 'node:child_process'
import {existsSync, readFileSync} from 'node:fs'

import {datasetResourceParts} from '@sanity/workflow-engine'

import {slack} from './sanity.workflow.ts'

const webhookUrl = process.env.SLACK_WEBHOOK_URL
if (!webhookUrl) throw new Error('Set SLACK_WEBHOOK_URL in .env')
const {projectId} = datasetResourceParts(slack.workflowResource.id)

// Reports a failure without the arguments, because one of them is the webhook URL.
function npx(...args: string[]): void {
  const {status, signal, error} = spawnSync('npx', args, {stdio: 'inherit'})
  if (status !== 0) {
    const reason = {status, signal, code: error && 'code' in error ? error.code : undefined}
    throw new Error(`npx ${args.slice(0, 3).join(' ')} failed: ${JSON.stringify(reason)}`)
  }
}

// `blueprints destroy` keeps the config file and removes only its stackId.
function hasStack(configPath: string): boolean {
  if (!existsSync(configPath)) return false
  const config: unknown = JSON.parse(readFileSync(configPath, 'utf8'))
  return isRecord(config) && typeof config.stackId === 'string' && config.stackId !== ''
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null
}

npx('sanity-workflows', 'blueprints', 'generate')
npx('sanity-workflows', 'deploy')
if (!hasStack('.sanity/blueprint.config.json')) {
  npx(
    'sanity',
    'blueprints',
    'init',
    '.',
    '--project-id',
    projectId,
    '--stack-name',
    'slack-example',
  )
}
npx('sanity', 'blueprints', 'deploy')
npx('sanity', 'functions', 'env', 'add', 'wf-slack-drain-effects', 'SLACK_WEBHOOK_URL', webhookUrl)

Deploy and run it

Fill in .env. SANITY_AUTH_TOKEN is an API token with the Developer role or higher on the project in sanity.workflow.ts. The token needs that role because setup deploys a Functions stack. SLACK_WEBHOOK_URL is the webhook URL you copied from Slack. Then install and run setup:

npm install
npm run setup

setup runs these steps in order:

  • Generates the Functions runtime with sanity-workflows blueprints generate.
  • Deploys the workflow definition.
  • Runs sanity blueprints init if the directory has no stack yet, then deploys the project stack.
  • Sets SLACK_WEBHOOK_URL on the generated drain function, wf-slack-drain-effects. The drain function runs queued effects.

The generator also writes a heartbeat stack in workflows-heartbeat/. The heartbeat re-queues an effect that no drain picked up. setup doesn’t deploy it, because deploying it needs an organization deploy token. To decide whether you need it, see Plan for heartbeat recovery.

Wait for setup to finish. The stack deploy takes a minute or two and may print You can safely exit. If you exit then, setup doesn’t set SLACK_WEBHOOK_URL, and the handler fails.

setup writes generated files next to yours, including .sanity/, functions/, and workflows-heartbeat/. If you keep the project in Git, leave them and .env out of it:

node_modules
.env

# Written by `npm run setup`
/workflows.blueprint.ts
/sanity.blueprint.ts
/workflows-heartbeat/
/functions/
/effect-handlers/all.ts
/.sanity/
/.build/

Start an instance, approve it, and show it:

npm run workflows -- start approval --field title="Launch post"
npm run workflows -- fire-action INSTANCE_ID --activity review --action approve
npm run workflows -- show INSTANCE_ID

Replace INSTANCE_ID with the ID that start prints. The workflows and sanity scripts run the sanity-workflows and sanity CLIs with the token from .env. If you’re logged in with npx sanity@latest login, you can run npx sanity-workflows directly instead.

Approving resolves the review activity, queues slack-notify, and moves the instance to approved. The queued effect starts the generated drain function, which posts one message to your channel: Approved: Launch post.

Wait a few seconds for the drain, then run show again. The Pending effects section is gone.

To see the result, run npm run workflows -- show INSTANCE_ID --json. The instance document’s effectHistory has a slack-notify entry with status set to done and the post time in outputs.sentAt.

If no message arrives, check the drain function’s logs with npm run sanity -- functions logs wf-slack-drain-effects.

Remove it

To remove the stack, run npm run sanity -- blueprints destroy. It removes the function and the robot token the stack created for it. The workflow definition stays deployed, because it’s a document in your dataset, not part of the stack. Instance documents stay in the dataset until you delete them.

Next steps

Visiting agent?

Was this page helpful?

Sanity logosanity.ioAll Systems Operational© Sanity 2026
Change site theme