I tried implementing Slack approval button waiting with Lambda Durable Functions without using Step Functions

I tried implementing Slack approval button waiting with Lambda Durable Functions without using Step Functions

I challenged myself to implement processing suspension until a Slack approval button is pressed, using the waitForCallback feature of Lambda Durable Functions. I'll try to see if this can be achieved with just two Lambdas and a Function URL, without Step Functions.
2026.07.30

This page has been translated by machine translation. View original

Hey there! I'm Yuji Nishimura from the Operations team!

The requirement of "pausing a workflow until a Slack approval button is pressed" has traditionally been handled using Step Functions' waitForTaskToken. Since Lambda durable functions, announced at re:Invent 2025, includes a waitForCallback that can wait for external callbacks, I decided to try whether the same thing could be achieved with Lambda alone, without using Step Functions.

As a result, I got "post a message with approval buttons to Slack → suspend until pressed → resume when pressed" working with just two Lambda functions and a Lambda Function URL. In addition to approval and rejection, timeout handling for when nobody presses the button can also be written in code. I'll summarize everything including the tricky parts I ran into.

What You Can Do with Lambda Durable Functions' waitForCallback

Lambda durable functions use a checkpoint-and-replay mechanism (saving the progress of processing and, upon resumption, re-executing the code from the beginning while reading back saved results to continue from where it left off), allowing you to write "long-running workflows that span multiple invocations" as the code of a single Lambda function. You enable durable execution when creating the function and wrap the handler with the Durable Execution SDK's withDurableExecution.

waitForCallback is used for waiting on external events. When called, it issues a callbackId, and execution is suspended until an external system calls the SendDurableExecutionCallbackSuccess API. No compute charges are incurred while suspended (for on-demand functions), and you can wait up to one year (the ExecutionTimeout upper limit of 31,622,400 seconds).

Mapping to the components used when doing the same thing with Step Functions:

Step Functions (waitForTaskToken) Lambda durable functions
State machine + ASL definition Lambda function code
TaskToken callbackId
SendTaskSuccess / SendTaskFailure SendDurableExecutionCallbackSuccess / Failure
Timeout configured with TimeoutSeconds / HeartbeatSeconds timeout option of waitForCallback

This Configuration

There are only four components. No state machine or API Gateway.

  • Approval flow main body (durable function): Posts a message with buttons to Slack and waits for a press using waitForCallback
  • Callback receiver Lambda + Function URL: Receives Slack button presses (interactivity) and calls SendDurableExecutionCallbackSuccess
  • Slack app: Sets the Function URL as the Request URL for Interactivity
  • Secrets Manager: Stores the Slack Bot Token

The processing flow is as follows:

  1. Launch the approval flow main body with an asynchronous invocation
  2. Inside the waitForCallback submitter (the function that receives the callbackId and sends notifications to external systems), post a Block Kit message to Slack with the callbackId embedded in the button's value
  3. The function suspends
  4. Button press → Slack POSTs an interaction payload to the Function URL
  5. The callback receiver Lambda extracts the callbackId from value and calls SendDurableExecutionCallbackSuccess
  6. The approval flow main body resumes via replay and executes subsequent processing based on the approval/rejection result

Let's Try It

Environment

  • aws-cdk-lib 2.262.1
  • @aws/durable-execution-sdk-js 2.2.0
  • @aws-sdk/client-lambda 3.1097.0
  • Node.js 22 / TypeScript 5.9
  • Region: ap-northeast-1 (Tokyo region supported as of 2025-12-18)

1. Define the Durable Function with CDK

Starting from aws-cdk-lib v2.232.0, the Function L2 construct natively supports durableConfig. You only need to specify executionTimeout (required) and retentionPeriod (execution history retention period after completion, default 14 days).

lib/stack.ts
import * as cdk from "aws-cdk-lib";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as nodejs from "aws-cdk-lib/aws-lambda-nodejs";
import * as iam from "aws-cdk-lib/aws-iam";
import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
import { Construct } from "constructs";
import * as path from "path";

const BOT_TOKEN_SECRET_NAME = "dev/blog-verify/slack-bot-token";

export class DurableSlackApprovalStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const botTokenSecret = secretsmanager.Secret.fromSecretNameV2(
      this,
      "BotTokenSecret",
      BOT_TOKEN_SECRET_NAME,
    );

    // Approval flow main body (durable function)
    const approvalFlowFn = new nodejs.NodejsFunction(this, "ApprovalFlowFn", {
      entry: path.join(__dirname, "../lambda/approval-flow.ts"),
      runtime: lambda.Runtime.NODEJS_22_X,
      timeout: cdk.Duration.seconds(60),
      durableConfig: {
        executionTimeout: cdk.Duration.hours(1),
        retentionPeriod: cdk.Duration.days(7),
      },
      environment: {
        BOT_TOKEN_SECRET: BOT_TOKEN_SECRET_NAME,
      },
    });
    botTokenSecret.grantRead(approvalFlowFn);

    // Lambda that receives Slack button presses and completes the callback
    const slackCallbackFn = new nodejs.NodejsFunction(this, "SlackCallbackFn", {
      entry: path.join(__dirname, "../lambda/slack-callback.ts"),
      runtime: lambda.Runtime.NODEJS_22_X,
      timeout: cdk.Duration.seconds(10),
    });
    // Since durable execution ARNs are in the format "function ARN:version/durable-execution/..."
    // the Resource must include a wildcard pattern up to :*
    slackCallbackFn.addToRolePolicy(
      new iam.PolicyStatement({
        actions: [
          "lambda:SendDurableExecutionCallbackSuccess",
          "lambda:SendDurableExecutionCallbackFailure",
        ],
        resources: [`${approvalFlowFn.functionArn}:*`],
      }),
    );

    const fnUrl = slackCallbackFn.addFunctionUrl({
      authType: lambda.FunctionUrlAuthType.NONE,
    });

    new cdk.CfnOutput(this, "CallbackUrl", { value: fnUrl.url });
    new cdk.CfnOutput(this, "ApprovalFlowFunctionName", {
      value: approvalFlowFn.functionName,
    });
  }
}

There are two key points.

  • When durableConfig is specified, CDK automatically grants AWSLambdaBasicDurableExecutionRolePolicy instead of the usual AWSLambdaBasicExecutionRole to the generated execution role (it includes permissions for checkpoint storage). This could be verified in the synthesized template.
  • For the IAM on the callback completion side, always specify the Resource with a wildcard including the version part, like function ARN:*. Since durable execution ARNs are in the format function ARN:version/durable-execution/execution name/ID, writing just the function ARN won't match and will result in AccessDenied. (This is also explicitly stated in the official documentation.)

2. Implement the Approval Flow Main Body

Wrap the handler with withDurableExecution and call waitForCallback from the DurableContext passed as the second argument to the handler. The callbackId is passed to the submitter function, which is the second argument of waitForCallback, and Slack posting is performed inside it.

lambda/approval-flow.ts
import {
  withDurableExecution,
  DurableContext,
  CallbackTimeoutError,
} from "@aws/durable-execution-sdk-js";
import {
  SecretsManagerClient,
  GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";

const secretsManager = new SecretsManagerClient({});

interface ApprovalEvent {
  channel: string;
  summary: string;
  timeoutMinutes?: number;
}

interface Decision {
  approved: boolean;
  user: string;
}

// Retrieve token from Secrets Manager each time; do not persist in checkpoints
async function getBotToken(): Promise<string> {
  const res = await secretsManager.send(
    new GetSecretValueCommand({ SecretId: process.env.BOT_TOKEN_SECRET! }),
  );
  return res.SecretString!;
}

async function postSlackMessage(body: unknown): Promise<void> {
  const token = await getBotToken();
  const res = await fetch("https://slack.com/api/chat.postMessage", {
    method: "POST",
    headers: {
      "Content-Type": "application/json; charset=utf-8",
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify(body),
  });
  const data = (await res.json()) as { ok: boolean; error?: string };
  if (!data.ok) {
    throw new Error(`Slack API error: ${data.error}`);
  }
}

export const handler = withDurableExecution(
  async (event: ApprovalEvent, context: DurableContext) => {
    let decision: Decision;
    try {
      // Post a message with approval buttons to Slack and wait until a button is pressed
      const result = await context.waitForCallback<string>(
        "wait-for-slack-approval",
        async (callbackId) => {
          await postSlackMessage({
            channel: event.channel,
            text: `Approval request: ${event.summary}`,
            blocks: [
              {
                type: "section",
                text: {
                  type: "mrkdwn",
                  text: `*Approval Request*\n${event.summary}`,
                },
              },
              {
                type: "actions",
                elements: [
                  {
                    type: "button",
                    text: { type: "plain_text", text: "Approve" },
                    style: "primary",
                    action_id: "approve",
                    value: callbackId,
                  },
                  {
                    type: "button",
                    text: { type: "plain_text", text: "Reject" },
                    style: "danger",
                    action_id: "reject",
                    value: callbackId,
                  },
                ],
              },
            ],
          });
        },
        { timeout: { minutes: event.timeoutMinutes ?? 5 } },
      );
      decision = JSON.parse(result) as Decision;
    } catch (err) {
      if (err instanceof CallbackTimeoutError) {
        // If nobody pressed before the deadline, treat it as automatic rejection
        await context.step("notify-timeout", async () => {
          await postSlackMessage({
            channel: event.channel,
            text: `The approval request has expired (automatically rejected): ${event.summary}`,
          });
        });
        return { status: "timeout" };
      }
      throw err;
    }

    await context.step("notify-result", async () => {
      await postSlackMessage({
        channel: event.channel,
        text: decision.approved
          ? `${decision.user} approved. Proceeding with subsequent processing: ${event.summary}`
          : `${decision.user} rejected: ${event.summary}`,
      });
    });

    return {
      status: decision.approved ? "approved" : "rejected",
      by: decision.user,
    };
  },
);

The callbackId is embedded directly into the button's value. The callbackId is a base64-like string of up to 1024 characters (347 characters in actual measurements for this case), so it fits within the Slack button value limit (2000 characters).

Timeout handling is written by catching CallbackTimeoutError. If timeout is not specified, it will keep waiting until ExecutionTimeout, so official best practices also recommend always setting it.

3. Implement the Endpoint for Slack Buttons

Receive the Slack interaction payload via Function URL and call SendDurableExecutionCallbackSuccess.

lambda/slack-callback.ts
import {
  LambdaClient,
  SendDurableExecutionCallbackSuccessCommand,
} from "@aws-sdk/client-lambda";
import type {
  LambdaFunctionURLEvent,
  LambdaFunctionURLResult,
} from "aws-lambda";

const lambda = new LambdaClient({});

interface SlackInteractionPayload {
  user: { id: string; username?: string };
  actions: { action_id: string; value: string }[];
  response_url: string;
}

export const handler = async (
  event: LambdaFunctionURLEvent,
): Promise<LambdaFunctionURLResult> => {
  const rawBody = event.isBase64Encoded
    ? Buffer.from(event.body ?? "", "base64").toString()
    : (event.body ?? "");
  const params = new URLSearchParams(rawBody);

  // Return 200 for ssl_check verification POSTs sent by Slack when saving the Request URL
  // (If you don't return 200 for POSTs without a payload field, saving the URL will fail)
  const payloadStr = params.get("payload");
  if (!payloadStr) {
    return { statusCode: 200, body: "" };
  }
  const payload = JSON.parse(payloadStr) as SlackInteractionPayload;

  const action = payload.actions[0];
  const decision = {
    approved: action.action_id === "approve",
    user: payload.user.username ?? payload.user.id,
  };

  try {
    await lambda.send(
      new SendDurableExecutionCallbackSuccessCommand({
        CallbackId: action.value,
        // Result type is Uint8Array, so pass it using Buffer
        Result: Buffer.from(JSON.stringify(decision)),
      }),
    );
  } catch (err) {
    // Notify the user via Slack for button presses on expired or already-processed callbacks
    await fetch(payload.response_url, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        replace_original: true,
        text: `This approval request has already been processed or has expired (${(err as Error).name})`,
      }),
    });
    return { statusCode: 200, body: "" };
  }

  // Replace the original message with the button press result (prevents double-pressing)
  await fetch(payload.response_url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      replace_original: true,
      text: decision.approved
        ? `${decision.user} approved`
        : `${decision.user} rejected`,
    }),
  });

  return { statusCode: 200, body: "" };
};

There are three implementation notes.

  • A branch that returns 200 for POSTs without payload is required. Slack sends a verification POST with ssl_check=1&token=... when saving the Request URL. At first, this branch was missing, causing the handler to crash and return 502. As a result, I got stuck in a situation where Slack kept failing to save the Request URL.
  • The type of Result is Uint8Array. Unlike boto3, you can't pass a string directly, so use Buffer.from(JSON.stringify(...)).
  • Calling SendDurableExecutionCallbackSuccess after expiration or on a double-press will throw an exception (sending to a closed callback), so catch it and notify the user via response_url.

4. Prepare the Slack App and Token

There are two things to prepare on the Slack app side. The Interactivity Request URL setting is done after deploying and obtaining the Function URL (step 7).

  1. Add chat:write to Bot Token Scopes and invite the Bot to the target channel (also add channels:history if you plan to do the standalone curl testing described later)
  2. Register the Bot User OAuth Token (the string starting with xoxb-) in Secrets Manager

Since the code reads SecretString as a plain string, save it directly rather than in JSON format.

aws secretsmanager create-secret \
  --name dev/blog-verify/slack-bot-token \
  --secret-string "xoxb-(Bot User OAuth Token)"

5. Deploy and Launch the Approval Flow

Install dependencies and run cdk deploy. Since NodejsFunction uses esbuild for bundling, include it in devDependencies (assuming cdk bootstrap has already been done).

pnpm add aws-cdk-lib constructs @aws/durable-execution-sdk-js \
  @aws-sdk/client-lambda @aws-sdk/client-secrets-manager
pnpm add -D aws-cdk tsx typescript esbuild @types/node @types/aws-lambda
pnpm exec cdk deploy

Note down the CallbackUrl (Function URL) and ApprovalFlowFunctionName from the deployment output, and set them as environment variables FUNCTION_URL / FUNCTION_NAME for use in subsequent commands.

Invoke the approval flow asynchronously (--invocation-type Event) with a version or alias appended to the function name. Durable functions can only be called with a qualified ARN (an ARN with a version, alias, or $LATEST appended). Also, synchronous invocations are subject to the 15-minute limit for the entire execution, so long-running workflows like approval waiting are premised on asynchronous invocation.

aws lambda invoke \
  --function-name "$FUNCTION_NAME:\$LATEST" \
  --invocation-type Event \
  --cli-binary-format raw-in-base64-out \
  --payload '{"channel":"C0XXXXXXXXX","summary":"Production environment deployment (release v1.2.3)","timeoutMinutes":30}' \
  /dev/null

A message with buttons is posted to the channel.

CleanShot 2026-07-30 at 23.14.19@2x

Waiting executions can be confirmed as RUNNING status. Since my local AWS CLI (2.31.39) didn't yet have durable execution subcommands, I confirmed via the AWS SDK for JavaScript (ListDurableExecutionsByFunction / GetDurableExecution APIs).

{"summary":"Production environment deployment...","Status":"RUNNING","Started":"2026-07-30T09:02:36.386Z"}

6. Unit Test the Endpoint with curl

Before configuring Interactivity in the Slack app, you can verify the entire callback completion flow with curl. Simply POST the same format that Slack sends (a payload field in application/x-www-form-urlencoded) to the Function URL.

Since the callbackId is in the button value of the message posted in step 5, extract it from the conversations.history API.

# Extract callbackId from the most recent message (requires channels:history scope)
curl -s -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
  "https://slack.com/api/conversations.history?channel=$CHANNEL_ID&limit=5" \
  -o hist.json
CALLBACK_ID=$(jq -r '.messages[0].blocks[] | select(.type=="actions") | .elements[] | select(.action_id=="approve") | .value' hist.json)

# POST a payload equivalent to pressing a button
jq -nc --arg v "$CALLBACK_ID" \
  '{type:"block_actions",user:{id:"UTEST",username:"curl-test"},actions:[{action_id:"approve",value:$v}],response_url:"https://example.com/dummy"}' \
  > payload.json
curl -s -o /dev/null -w "HTTP %{http_code}\n" -X POST "$FUNCTION_URL" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "payload@payload.json"

Running this returns HTTP 200, and a few seconds later the durable function resumes and "curl-test approved. Proceeding with subsequent processing" is posted to Slack. The rejection side (action_id: "reject") worked the same way.

Note that handling JSON through variables in the shell can cause zsh's echo to expand \n into actual newlines, breaking jq parsing, so passing via a file as shown above is safer.

7. Configure Interactivity and Test with Buttons

Enable Interactivity & Shortcuts in the Slack app and set the CallbackUrl (Function URL) as the Request URL. Since the aforementioned ssl_check verification runs when saving, you can't save it until the receiver Lambda is deployed.

Launch the approval flow again and press "Approve": the original message is replaced with the button press result, and the durable function resumes and posts a completion notification.

CleanShot 2026-07-30 at 23.15.17@2x

Pressing "Reject" also resumes as a rejection in the same way.

A completed execution becomes Status: SUCCEEDED, and the handler's return value {"status":"approved","by":"..."} is recorded in the Result field.

8. Verify Timeout Behavior

When left idle with a 5-minute timeout setting, the CallbackTimeoutError catch clause triggered exactly 5 minutes later, and an automatic rejection message was posted.

CleanShot 2026-07-30 at 23.16.49@2x

Looking at the event history via the GetDurableExecutionHistory API clearly shows the flow of suspension and resumption.

ExecutionStarted     09:02:37
CallbackStarted      09:02:38   ← waitForCallback started
StepStarted          09:02:38   ← submitter (Slack posting)
StepSucceeded        09:02:40
InvocationCompleted  09:02:40   ← first invocation ends here (suspended)
CallbackTimedOut     09:07:38   ← callback expires 5 minutes later
StepStarted          09:07:39   ← notify-timeout step (resumes via replay)
StepSucceeded        09:07:40
ExecutionSucceeded   09:07:40   ← caught, so the execution itself succeeds

The key point is that if the timeout is caught, the execution status becomes SUCCEEDED.

I hit one trap here. When specifying 60 minutes for the callback's timeout, the deadlines of durableConfig's executionTimeout (1 hour) coincided, causing the entire execution to forcibly terminate with a TIMED_OUT status. In this case, the CallbackTimeoutError catch is not executed and the automatic rejection notification is not sent. The callback's timeout must always be set shorter than executionTimeout.

9. Verify Charges During Waiting

Durable function Lambda functions output structured JSON platform logs. Looking at platform.report for an execution that waited approximately 72 seconds before approval, the billable items were just two short invocations.

10:35:43  billed: 3642ms  ← 1st invocation (until posting to Slack and suspending)
10:36:57  billed: 1478ms  ← 2nd invocation (replay after receiving callback, completion processing)

The approximately 72 seconds of waiting is not subject to duration charges. Even if approval waiting takes hours or days, compute charges remain the same.

Note that durable functions-specific charges apply: durable operations (per-operation units such as execution start, step completion, wait creation) at $8.00/million operations, checkpoint data writes at $0.25/GB, and retention at $0.15/GB-month (us-east-1, as of 2026-07-30 from the pricing page. Unit prices for the Tokyo region can be confirmed on the pricing page). This approval flow has approximately 12 events per execution based on the event history, and a simple calculation of operation unit prices shows less than $0.0001, so the impact on charges is minimal.

Analysis of Verification Results

Compared to the standard Step Functions waitForTaskToken configuration (state machine + API Gateway + two Lambdas), the following became unnecessary with this configuration:

  • State machine and ASL definition (the entire flow became TypeScript code)
  • API Gateway (replaced with Function URL)
  • TaskToken passing design (just embed callbackId in the button's value)

On the other hand, the visual flow representation and 220+ service integrations available in Step Functions are not available. The official decision guide positions durable functions as optimized for "application development within Lambda," while Step Functions is intended for "workflow orchestration across AWS services." Decision criteria include not only the number of AWS services involved, but also multiple perspectives such as whether you want to write in standard programming languages or define in visual designer/ASL, and whether non-technical people need to review the workflow. An approval flow like this one is application logic within Lambda itself, making it a use case well-suited for durable functions.

The following constraints need to be kept in mind:

  • Durable execution can only be enabled when creating a new function (cannot be added later)
  • Invocation requires an ARN with a version or alias; long-running waits are premised on asynchronous invocation
  • Code must be written deterministically with replay in mind (place external I/O inside steps or submitters)

My Impressions After Trying It

The API design where writing Slack posting inside the waitForCallback submitter packages "posting and waiting" as a set is intuitive. Compared to the old days of threading TaskTokens through environment variables or payloads in Step Functions, the flow is much clearer. I also liked that timeout handling for approvals can be written with try/catch, allowing branching like "auto-reject and notify Slack if expired" to be expressed as ordinary code.

On the other hand, things like responding to ssl_check and the relationship between two types of timeouts are things you'd be unlikely to notice without actually running it. It seems best to design the timeout values from the start.

Summary

  • With Lambda durable functions' waitForCallback, you can implement Slack approval button waiting and resuming without using Step Functions
  • The configuration consists of only 2 Lambda functions + Function URL + Slack app. There are no compute charges while waiting
  • The 3 paths of approval, rejection, and timeout, as well as error handling for double-clicks, are all handled in code
  • The 4 gotchas are: "200 response to ssl_check", "IAM Resource must be function ARN:*", "Result's Uint8Array", and "the relationship between callback timeout and executionTimeout"

I hope this is helpful to someone.


Reference Links:

Share this article