Custom Integration with Slack

41 min read

Custom Slack Integration with Your Logic

How to connect a Slack workspace to Salted CX Your Logic without using any SDK — you receive the Your Logic event JSON on your own HTTP endpoint, you call the Slack Web API yourself, and you push results back into the conversation with plain HTTP.

Both use cases are started by a human agent pressing a button in the Salted CX agent desktop. That press arrives as an ACTION trigger — it is the cleanest entry point for a Slack integration, because the agent decides when the integration fires instead of the integration guessing from message content.

  1. Announcement — the agent presses Announce in Slack; you post a static message with a link back to the conversation. Fire-and-forget, no callback.
  1. Approve / Decline — the agent presses Request approval; you post an interactive card and resume the conversation when a second human presses a button. Requires a callback endpoint and a stateless round-trip.

Table of contents


1. Architecture

There are three parties and two independent directions of traffic. Understanding that they are independent is the single most important thing in this document.

   agent presses a
   desktop button
         │
         ▼  (A) ACTION event                 (B) Web API call
  Salted CX  ─────────────────▶  Your service  ─────────────────▶  Slack
   (Your Logic)                   (any runtime)                    (workspace)
      ▲                                 ▲                             │
      │  (D) actions (HTTPS POST)       │  (C) interaction callback   │
      └─────────────────────────────────┴─────────────────────────────┘
LegDirectionTransportAuth
ASalted CX → youPOST JSON to your Your Logic endpointAuthorization: Bearer <shared secret> you chose
Byou → SlackPOST https://slack.com/api/chat.postMessageAuthorization: Bearer xoxb-… (bot token)
CSlack → youPOST form-encoded to your interactivity endpointSlack request signature (HMAC-SHA256)
Dyou → Salted CXPOST JSON to the conversation actions endpointAuthorization: Bearer <shared secret>

Leg A and leg C are two different HTTP endpoints, or one endpoint that branches on Content-Type (Your Logic is application/json, Slack interactivity is application/x-www-form-urlencoded). Either is fine; two paths is clearer.

Note the symmetry: a Salted CX button press is leg A, a Slack button press is leg C. Both are “a human clicked something”; they simply arrive from different systems, in different formats, with different authentication.

The critical consequence for use case 2: you cannot block a Your Logic turn waiting for a button press in Slack. Leg A must answer within seconds; the approver may take hours. Use case 2 is therefore split into two completely separate request lifecycles — post the card in leg A/B, resume the conversation in leg C/D. Nothing is held open in between, so this design survives serverless cold starts and process restarts.


2. What the customer must set up in Slack

These steps are performed once by someone with workspace admin rights (or someone who can get an admin to approve the app install). Everything happens at api.slack.com/apps.

2.1 Create the app

  1. Open api.slack.com/appsCreate New AppFrom scratch.
  1. Name it (e.g. Demo Adventures Support) and pick the workspace.

Tip: if you want the app configuration to be reproducible, use From an app manifest and paste the manifest below.

2.2 Add bot token scopes

OAuth & Permissions → Scopes → Bot Token Scopes. Add exactly what you need:

ScopeNeeded forRequired by
chat:writePosting messages and cardsBoth use cases
chat:write.publicPosting into public channels the bot has not been invited toOptional
channels:readResolving a channel name (#support) to an IDOptional
users:readResolving the approver’s display name / emailOptional
chat:write.customizeOverriding the posting username / icon per messageOptional

Do not add scopes “just in case” — every extra scope is shown to the admin at install time and lengthens the approval conversation.

2.3 Install the app and copy the bot token

OAuth & Permissions → Install to Workspace → approve. Copy the Bot User OAuth Token — it starts with xoxb-. This is a secret; store it in your secret manager, never in source control.

If the workspace requires admin approval for apps, the install request goes to the workspace admin and the token appears only after they approve.

2.4 Copy the signing secret

Basic Information → App Credentials → Signing SecretShow → copy. This is what you use to verify that inbound requests (leg C) genuinely came from Slack. Also a secret.

2.5 Enable interactivity (only needed for use case 2)

Interactivity & Shortcuts → Interactivity: On → Request URL = https://your-service.example.com/slack/interactive.

Slack immediately sends a test POST to this URL — your endpoint must already be deployed and return 200 within 3 seconds, or Slack refuses to save the URL.

2.6 Invite the bot to the channel

In Slack, in the target channel: /invite @Demo Adventures Support.

Skip this only if you granted chat:write.public and the channel is public. Private channels always require an explicit invite, no scope substitutes for it.

Then copy the channel ID: click the channel name → About → bottom of the dialog → C0123ABCDEF. Prefer the ID over the #name — names get renamed, IDs never change.

2.7 App manifest (copy-paste)

Create New App → From an app manifest and paste:

display_information:
name: Demo Adventures Support
description: Announcements and approvals from Demo Adventures customer conversations
background_color:"#1f2d3d"
features:
bot_user:
display_name: Demo Adventures Support
always_online:true
oauth_config:
scopes:
bot:
- chat:write
- chat:write.public
- users:read
settings:
interactivity:
is_enabled:true
request_url: https://your-service.example.com/slack/interactive
org_deploy_enabled:false
socket_mode_enabled:false
token_rotation_enabled:false

2.8 Setup summary — what to hand to the developer

ValueWhere the customer finds itExample
Bot tokenOAuth & Permissionsxoxb-1111-2222-aBcDeF…
Signing secretBasic Information → App Credentials8f14e45fceea167a5a36…
Channel IDChannel → AboutC0123ABCDEF

3. What must be set up in Salted CX

Requested from your Salted CX contact or configured in the admin console:

  1. Your Logic endpoint URL — the HTTPS URL Salted CX will POST events to (https://your-service.example.com/yourlogic). It must be publicly reachable and serve a valid TLS certificate.
  1. Shared secret — a random string you generate. Salted CX sends it as Authorization: Bearer <secret> on every event; you send the same value back on leg D. Generate with openssl rand -hex 32.
  1. Account ID (accountId) and region (region) — these also arrive inside every event payload, so you can read them rather than configure them.
  1. The custom agent actions — the desktop buttons that start these flows. This is the piece specific to this document; see below.
  1. The ACTION trigger enabled for the Your Logic endpoint, so button presses actually reach you.

3.1 Custom agent actions (the desktop buttons)

Go to SettingsLive ConversationsToolbar to set up buttons.

A custom agent action is a per-account button that appears in the agent’s conversation toolbar. Each one has a stable id and a display label. The id is what travels on the wire; the label is what the agent reads.

Ask your Salted CX contact to configure two:

IdLabel the agent seesUse case
slack.announceAnnounce in Slack1
slack.approvalRequest approval in Slack2

Once configured, the buttons appear in the toolbar and each press produces an ACTION event on your Your Logic endpoint. Nothing else is needed on the Salted CX side.

The API base is derived from the region:

https://api.{region}.salted.cx

4. The Your Logic wire contract

4.1 The inbound event (leg A)

Every event has the same envelope. Only trigger varies. This is what an agent button press looks like:

{
  "requestId": "8b1f1d16-3f2a-4d8a-9a1e-2e0a1c4a77bd",
  "accountId": "a1b2c3d4-0000-1111-2222-333344445555",
  "time": "2026-07-24T10:15:30Z",
  "expires": "2026-07-24T10:16:00Z",
  "domain": "demo-adventures.salted.cx",
  "region": "eu",
  "trigger": {
    "type": "ACTION",
    "participantType": "AGENT",
    "time": "2026-07-24T10:15:29Z",
    "action": "slack.approval",
    "agent": "agt_3c9f10b2"
  },
  "customer": {
    "pid": "cus_7f3a2b19",
    "displayName": "Jana Novak",
    "contacts": [
      { "pid": "con_4e1a", "contact": "[email protected]", "type": "Email" }
    ],
    "custom": {}
  },
  "conversation": {
    "pid": "cnv_5d9c8b7a",
    "startConversationTime": "2026-07-24T10:14:02Z",
    "brandId": "demo-adventures",
    "channelType": "Chat",
    "needsHelp": false,
    "status": "IN_PROGRESS",
    "defaultChannel": "web-chat",
    "direction": "INBOUND",
    "languageCustomer": "en",
    "agentDesktop": {
      "toolbar": [
        { "type": "action", "id": "slack.announce" },
        { "type": "action", "id": "slack.approval" }
      ]
    },
    "custom": { "orderId": "10042", "refundAmount": 129 }
  },
  "engagements": [
    {
      "pid": "eng_88a1",
      "time": "2026-07-24T10:14:40Z",
      "type": "AGENT",
      "status": "IN_PROGRESS",
      "agent": { "pid": "agt_3c9f10b2", "type": "HUMAN",
                 "name": "Petr Dvorak", "email": "[email protected]" }
    }
  ],
  "turns": []
}

Fields you will actually use:

FieldWhy it matters
trigger.actionWhich button was pressed — the configured custom-action id
trigger.agentPid of the agent who pressed it
trigger.participantTypeAGENT for an in-house agent, EXTERNAL_AGENT for a partner
requestIdEcho it back when responding in band to this event
accountId, regionBuild the callback URL for leg D
domainTenant domain — used to build the conversation deep link
conversation.pidIdentifies the conversation for leg D
conversation.customFree-form per-conversation state you control — carries the order/amount
engagements[]Where to look up the pressing agent’s name and email from their pid
conversation.agentDesktop.toolbarCurrent state of the toolbar buttons
expiresAfter this instant Salted CX stops waiting for an in-band response

Forward compatibility. Treat trigger.type as an open set, and trigger.action too. Unknown values must be acknowledged with 200/202 and ignored, never rejected — both sets grow over time, and a button you do not handle is not an error.

4.2 The response (legs A-reply and D)

The response body is always the same shape:

{
  "requestId": "8b1f1d16-3f2a-4d8a-9a1e-2e0a1c4a77bd",
  "actions": [
    { "type": "MESSAGE", "content": "Thanks — let me check that for you.", "attachments": [] },
    { "type": "NOTE", "content": "Approval card posted to #approvals." }
  ]
}

Two ways to deliver it — this is the part most integrations get wrong:

ModeWhenBodyEndpoint
In bandAnswering the event you are currently handlingincludes requestIdPOST {api}/api/v1/live/your-logic/accounts/{accountId}/conversations/{conversationPid}
Out of bandAny later moment (a Slack button click, a cron, a webhook)omits requestIdsame URL

Both are HTTPS POST with Content-Type: application/json and the shared secret as a bearer token. Presence of requestId is the only difference: with it, the actions are attached to that turn; without it, they are applied to the conversation immediately.

Your HTTP response to leg A itself is just an acknowledgement — return 202 Accepted with an empty body. The actions travel on the separate POST above. (Returning them inline in the HTTP response body is not the contract.)


5. The agent desktop button

5.1 Dispatching on the press

One endpoint, one switch. Everything in this document hangs off it:

if trigger.type != "ACTION"           → ignore, 202
if trigger.participantType not AGENT
   and not EXTERNAL_AGENT             → ignore, 202
switch trigger.action:
  "slack.announce" → use case 1
  "slack.approval" → use case 2
  default          → ignore, 202

Checking participantType matters: ACTION triggers can also be synthetic, injected by other integrations to route an external event back into a conversation. Only act on presses that came from a real agent, and only on action ids you own.

JavaScript

const ACTION_ANNOUNCE = process.env.SALTED_ACTION_ANNOUNCE ?? "slack.announce";
const ACTION_APPROVAL = process.env.SALTED_ACTION_APPROVAL ?? "slack.approval";

async function handleEvent(event) {
  const trigger = event.trigger;
  if (trigger.type !== "ACTION") return;
  if (trigger.participantType !== "AGENT" && trigger.participantType !== "EXTERNAL_AGENT") return;

  switch (trigger.action) {
    case ACTION_ANNOUNCE:
      return announce(event);
    case ACTION_APPROVAL:
      return requestApproval(event);
    default:
      console.info("action.ignored", { action: trigger.action });
  }
}

TypeScript

interface ActionTrigger {
  readonly type: "ACTION";
  readonly participantType: "AGENT" | "EXTERNAL_AGENT" | "CUSTOMER" | "BOT" | "UNKNOWN";
  readonly time: string;
  readonly action: string;
  readonly agent?: string | null;
}

const isAgentPress = (trigger: { type: string; participantType?: string }): boolean =>
  trigger.type === "ACTION" &&
  (trigger.participantType === "AGENT" || trigger.participantType === "EXTERNAL_AGENT");

export async function handleEvent(event: YourLogicEvent, env: Env): Promise<void> {
  if (!isAgentPress(event.trigger)) return;

  switch ((event.trigger as ActionTrigger).action) {
    case env.SALTED_ACTION_ANNOUNCE:
      return announce(event, env);
    case env.SALTED_ACTION_APPROVAL:
      return requestApproval(event, env);
    default:
      return; // an action id we do not own
  }
}

Python

import os

ACTION_ANNOUNCE = os.environ.get("SALTED_ACTION_ANNOUNCE", "slack.announce")
ACTION_APPROVAL = os.environ.get("SALTED_ACTION_APPROVAL", "slack.approval")
AGENT_PARTICIPANTS = {"AGENT", "EXTERNAL_AGENT"}


async def handle_event(event: dict) -> None:
    trigger = event["trigger"]
    if trigger.get("type") != "ACTION":
        return
    if trigger.get("participantType") not in AGENT_PARTICIPANTS:
        return

    action = trigger.get("action")
    if action == ACTION_ANNOUNCE:
        await announce(event)
    elif action == ACTION_APPROVAL:
        await request_approval(event)

5.2 Naming the agent who pressed

trigger.agent is a pid, not a name. Resolve it against engagements[], which carries the full agent record — and fall back gracefully, because the engagement may not be present on every event.

function pressedBy(event) {
  const pid = event.trigger.agent;
  const match = (event.engagements ?? []).find((e) => e.agent?.pid === pid);
  return match?.agent?.name ?? match?.agent?.email ?? pid ?? "an agent";
}
export function pressedBy(event: YourLogicEvent): string {
  const pid = (event.trigger as ActionTrigger).agent;
  const match = event.engagements?.find((e) => e.agent?.pid === pid);
  return match?.agent?.name ?? match?.agent?.email ?? pid ?? "an agent";
}
def pressed_by(event: dict) -> str:
    pid = event["trigger"].get("agent")
    for engagement in event.get("engagements") or []:
        agent = engagement.get("agent") or {}
        if agent.get("pid") == pid:
            return agent.get("name") or agent.get("email") or pid
    return pid or "an agent"

5.3 Controlling the button back

The bot can change the toolbar with a CONVERSATION_UPDATE action carrying desktop.customActions. Each entry addresses one button by id:

{
  "actions": [
    {
      "type": "CONVERSATION_UPDATE",
      "desktop": {
        "customActions": [
          { "id": "slack.approval", "status": "Disabled", "title": "Approval pending…" }
        ]
      }
    }
  ]
}
statusEffect
DisabledButton greyed out — visible but not clickable
HiddenButton removed from the toolbar
(omit the entry)Clears any override, restoring the account default

title overrides the configured label, which is how you turn the button itself into a status indicator.

This is the best de-duplication mechanism available to you — far better than guarding in code. Disable the button in the same response that posts to Slack, and an impatient agent physically cannot fire the flow twice. Re-enable it (by omitting the override) once the Slack round-trip resolves.

The inbound conversation.agentDesktop.toolbar reports the current state so you can read what the agent is looking at:

{ "toolbar": [
  { "type": "action", "id": "slack.announce" },
  { "type": "action", "id": "slack.approval", "status": "Disabled" }
] }

An item with no status is shown and active.


6. Receiving and authenticating a Your Logic event

Three things, in order: reject non-POST, compare the bearer token, parse the body. Anything that fails is a 401 or 400 — never a 500, and never a silent 200.

JavaScript (Node + Express)

import express from "express";

const app = express();
app.use(express.json({ limit: "1mb" }));

app.post("/yourlogic", async (req, res) => {
  const auth = req.get("authorization");
  if (auth !== `Bearer${process.env.YOURLOGIC_SHARED_SECRET}`) {
    return res.status(401).send("Unauthorized");
  }

  const event = req.body;

  // Acknowledge first, work afterwards — Salted CX must not wait on Slack.
  res.status(202).end();

  try {
    await handleEvent(event);
  } catch (err) {
    console.error("yourlogic.failed", { requestId: event.requestId, err });
  }
});

app.listen(8787);

TypeScript (fetch-style runtime — Cloudflare Workers, Deno, Bun)

interface YourLogicEvent {
  readonly requestId: string;
  readonly accountId: string;
  readonly region: string;
  readonly domain: string;
  readonly trigger: { readonly type: string; readonly participantType?: string };
  readonly conversation: {
    readonly pid: string;
    readonly channelType?: string | null;
    readonly languageCustomer?: string | null;
    readonly custom?: Record<string, unknown> | null;
  };
  readonly customer: { readonly pid: string; readonly displayName?: string | null };
  readonly engagements?: ReadonlyArray<{
    readonly agent?: { readonly pid: string; readonly name?: string | null; readonly email?: string | null } | null;
  }>;
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405 });
    if (request.headers.get("Authorization") !== `Bearer${env.YOURLOGIC_SHARED_SECRET}`) {
      return new Response("Unauthorized", { status: 401 });
    }

    let event: YourLogicEvent;
    try {
      event = (await request.json()) as YourLogicEvent;
    } catch {
      return new Response("Bad Request", { status: 400 });
    }

    // Keep working after the response is returned.
    ctx.waitUntil(handleEvent(event, env));
    return new Response(null, { status: 202 });
  },
};

Python (FastAPI)

import hmac
import os

from fastapi import BackgroundTasks, FastAPI, Header, HTTPException, Request

app = FastAPI()
SHARED_SECRET = os.environ["YOURLOGIC_SHARED_SECRET"]


@app.post("/yourlogic", status_code=202)
async def your_logic(request: Request, background: BackgroundTasks, authorization: str = Header(None)):
    if not authorization or not hmac.compare_digest(authorization, f"Bearer{SHARED_SECRET}"):
        raise HTTPException(status_code=401, detail="Unauthorized")

    event = await request.json()
    background.add_task(handle_event, event)
    return {}

Use a constant-time comparison (hmac.compare_digest, crypto.timingSafeEqual) for the shared secret. A naive == leaks the secret byte-by-byte through timing over enough requests.


7. Calling the Slack Web API

Every Slack Web API call in this document is the same shape:

POST https://slack.com/api/<method>
Authorization: Bearer xoxb-…
Content-Type: application/json; charset=utf-8

Slack returns HTTP 200 even on failure. The real result is {"ok": false, "error": "…"} in the body. Always check ok.

A minimal client you will reuse in both use cases:

// JavaScript
async function slack(method, payload, botToken) {
  const response = await fetch(`https://slack.com/api/${method}`, {
    method: "POST",
    headers: {
      Authorization: `Bearer${botToken}`,
      "Content-Type": "application/json; charset=utf-8",
    },
    body: JSON.stringify(payload),
  });
  const body = await response.json();
  if (!body.ok) throw new Error(`Slack${method} failed:${body.error}`);
  return body;
}
// TypeScript
interface SlackResult { ok: boolean; error?: string; ts?: string; channel?: string }

export async function slack<T extends SlackResult = SlackResult>(
  method: string,
  payload: Record<string, unknown>,
  botToken: string,
): Promise<T> {
  const response = await fetch(`https://slack.com/api/${method}`, {
    method: "POST",
    headers: {
      Authorization: `Bearer${botToken}`,
      "Content-Type": "application/json; charset=utf-8",
    },
    body: JSON.stringify(payload),
  });
  const body = (await response.json()) as T;
  if (!body.ok) throw new Error(`Slack${method} failed:${body.error ?? "unknown"}`);
  return body;
}
# Python
import httpx

SLACK_API = "https://slack.com/api"


async def slack(method: str, payload: dict, bot_token: str) -> dict:
    async with httpx.AsyncClient(timeout=10) as client:
        response = await client.post(
            f"{SLACK_API}/{method}",
            json=payload,
            headers={"Authorization": f"Bearer{bot_token}"},
        )
    body = response.json()
    if not body.get("ok"):
        raise RuntimeError(f"Slack{method} failed:{body.get('error')}")
    return body

Common error values: not_in_channel (invite the bot), channel_not_found (wrong ID, or a private channel the bot cannot see), invalid_auth (bad/revoked token), missing_scope (reinstall with the scope added), ratelimited (honour the Retry-After header).


Use case 1 — Announcement

Goal: the agent presses Announce in Slack on a conversation; a static, non-interactive message lands in a Slack channel with a link back to that conversation. No reply is expected.

Typical use: a supervisor-visibility channel, a “this one needs eyes” ping, an escalation notice.

1.1 Flow

agent presses "Announce in Slack"
         │
         ▼  ACTION trigger, action = "slack.announce"
  Salted CX ──▶ your service ──chat.postMessage──▶ #support
                      │
                      └── NOTE + button Disabled ──▶ Salted CX (in band)

Three things happen in one turn:

  1. Post the message to Slack.
  1. Write a NOTE on the conversation timeline so the other agents can see it was announced.
  1. Disable the button so a second press cannot double-post.

All three are worth doing. The NOTE and the disabled button together make the integration visible in the agent desktop instead of being an invisible side effect.

1.2 The conversation deep link

Build it from the event’s own domain and conversation.pid:

https://{domain}/conversations/{conversationPid}

Confirm the exact agent-desktop path with your Salted CX contact — the tenant domain is authoritative but the path segment can differ per deployment. If the event carries conversation.info[].url, prefer that value: it is the link Salted CX itself considers canonical. Keep the construction in one function so a change is a one-line fix.

1.3 Message layout

Plain text is enough, but a two-block layout reads far better in a busy channel — a headline section plus a context line with the metadata, including who pressed the button:

{
  "channel": "C0123ABCDEF",
  "text": "Petr Dvorak flagged a conversation: Jana Novak",
  "blocks": [
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "*Conversation flagged by Petr Dvorak*\nCustomer: Jana Novak — _Demo Adventures_\n<https://demo-adventures.salted.cx/conversations/cnv_5d9c8b7a|Open conversation>"
      }
    },
    {
      "type": "context",
      "elements": [
        { "type": "mrkdwn", "text": "Channel: Chat · Language: en · Order 10042" }
      ]
    }
  ]
}

Two rules that are easy to miss:

  • Always send text as well as blocks. text is the notification preview on mobile and in the sidebar. Blocks-only messages show up as “This content can’t be displayed”.
  • Slack link syntax is <url|label>, not Markdown [label](url).

1.4 Implementation

JavaScript

const SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN;
const SLACK_CHANNEL_ID = process.env.SLACK_CHANNEL_ID;
const SHARED_SECRET = process.env.YOURLOGIC_SHARED_SECRET;

function conversationLink(event) {
  return `https://${event.domain}/conversations/${event.conversation.pid}`;
}

function escapeSlack(text) {
  return String(text).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}

async function announce(event) {
  const agentName = escapeSlack(pressedBy(event));
  const who = escapeSlack(event.customer.displayName ?? "Unknown customer");
  const link = conversationLink(event);

  await slack("chat.postMessage", {
    channel: SLACK_CHANNEL_ID,
    text: `${agentName} flagged a conversation:${who}`,
    blocks: [
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: `*Conversation flagged by${agentName}*\nCustomer:${who}\n<${link}|Open conversation>`,
        },
      },
      {
        type: "context",
        elements: [
          {
            type: "mrkdwn",
            text: `Channel:${event.conversation.channelType ?? "Chat"} · Language:${event.conversation.languageCustomer ?? "n/a"}`,
          },
        ],
      },
    ],
  }, SLACK_BOT_TOKEN);

  // In band (echo the requestId): note it on the timeline and stop the button
  // being pressed a second time.
  await postActions(event, [
    { type: "NOTE", content: `Announced in Slack #support by${pressedBy(event)}.` },
    {
      type: "CONVERSATION_UPDATE",
      desktop: {
        customActions: [
          { id: ACTION_ANNOUNCE, status: "Disabled", title: "Announced in Slack" },
        ],
      },
    },
  ], event.requestId);
}

async function postActions(event, actions, requestId) {
  const url =
    `https://api.${event.region}.salted.cx/api/v1/live/your-logic` +
    `/accounts/${event.accountId}/conversations/${event.conversation.pid}`;

  const response = await fetch(url, {
    method: "POST",
    headers: {
      Authorization: `Bearer${SHARED_SECRET}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(requestId ? { requestId, actions } : { actions }),
  });
  if (!response.ok) {
    throw new Error(`Salted CX rejected actions:${response.status}${await response.text()}`);
  }
}

TypeScript

const escapeSlack = (text: string): string =>
  text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");

function conversationLink(event: YourLogicEvent): string {
  return `https://${event.domain}/conversations/${event.conversation.pid}`;
}

export async function announce(event: YourLogicEvent, env: Env): Promise<void> {
  const agentName = escapeSlack(pressedBy(event));
  const who = escapeSlack(event.customer.displayName ?? "Unknown customer");

  await slack("chat.postMessage", {
    channel: env.SLACK_CHANNEL_ID,
    text: `${agentName} flagged a conversation:${who}`,
    blocks: [
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: `*Conversation flagged by${agentName}*\nCustomer:${who}\n<${conversationLink(event)}|Open conversation>`,
        },
      },
      {
        type: "context",
        elements: [{
          type: "mrkdwn",
          text: `Channel:${event.conversation.channelType ?? "Chat"} · Language:${event.conversation.languageCustomer ?? "n/a"}`,
        }],
      },
    ],
  }, env.SLACK_BOT_TOKEN);

  await postActions(event, [
    { type: "NOTE", content: `Announced in Slack by${pressedBy(event)}.` },
    {
      type: "CONVERSATION_UPDATE",
      desktop: {
        customActions: [{ id: env.SALTED_ACTION_ANNOUNCE, status: "Disabled", title: "Announced in Slack" }],
      },
    },
  ], { requestId: event.requestId, sharedSecret: env.YOURLOGIC_SHARED_SECRET });
}

export async function postActions(
  event: YourLogicEvent,
  actions: ReadonlyArray<Record<string, unknown>>,
  options: { readonly requestId?: string; readonly sharedSecret: string },
): Promise<void> {
  const url =
    `https://api.${event.region}.salted.cx/api/v1/live/your-logic` +
    `/accounts/${event.accountId}/conversations/${event.conversation.pid}`;

  const body = options.requestId ? { requestId: options.requestId, actions } : { actions };
  const response = await fetch(url, {
    method: "POST",
    headers: {
      Authorization: `Bearer${options.sharedSecret}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(body),
  });
  if (!response.ok) {
    throw new Error(`Salted CX rejected actions:${response.status}${await response.text()}`);
  }
}

Python

import os

import httpx

SLACK_BOT_TOKEN = os.environ["SLACK_BOT_TOKEN"]
SLACK_CHANNEL_ID = os.environ["SLACK_CHANNEL_ID"]
SHARED_SECRET = os.environ["YOURLOGIC_SHARED_SECRET"]


def conversation_link(event: dict) -> str:
    return f"https://{event['domain']}/conversations/{event['conversation']['pid']}"


def escape_slack(text: str) -> str:
    return str(text).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")


async def announce(event: dict) -> None:
    agent_name = escape_slack(pressed_by(event))
    who = escape_slack(event["customer"].get("displayName") or "Unknown customer")
    link = conversation_link(event)
    conversation = event["conversation"]

    await slack(
        "chat.postMessage",
        {
            "channel": SLACK_CHANNEL_ID,
            "text": f"{agent_name} flagged a conversation:{who}",
            "blocks": [
                {
                    "type": "section",
                    "text": {
                        "type": "mrkdwn",
                        "text": f"*Conversation flagged by{agent_name}*\n"
                                f"Customer:{who}\n<{link}|Open conversation>",
                    },
                },
                {
                    "type": "context",
                    "elements": [{
                        "type": "mrkdwn",
                        "text": f"Channel:{conversation.get('channelType') or 'Chat'} · "
                                f"Language:{conversation.get('languageCustomer') or 'n/a'}",
                    }],
                },
            ],
        },
        SLACK_BOT_TOKEN,
    )

    await post_actions(event, [
        {"type": "NOTE", "content": f"Announced in Slack #support by{pressed_by(event)}."},
        {"type": "CONVERSATION_UPDATE",
         "desktop": {"customActions": [
             {"id": ACTION_ANNOUNCE, "status": "Disabled", "title": "Announced in Slack"},
         ]}},
    ], request_id=event["requestId"])


async def post_actions(event: dict, actions: list[dict], request_id: str | None = None) -> None:
    url = (
        f"https://api.{event['region']}.salted.cx/api/v1/live/your-logic"
        f"/accounts/{event['accountId']}/conversations/{event['conversation']['pid']}"
    )
    body: dict = {"actions": actions}
    if request_id:
        body["requestId"] = request_id

    async with httpx.AsyncClient(timeout=10) as client:
        response = await client.post(
            url, json=body, headers={"Authorization": f"Bearer{SHARED_SECRET}"}
        )
    response.raise_for_status()

1.5 Things to get right

  • De-duplicate with the button, not with code. Disabling slack.announce in the same response is simpler and more honest than a flag, because the agent can see that it already happened. Keep a custom flag too if you re-enable the button later.
  • Never let a Slack failure fail the conversation. Catch, log, move on. If the post failed, say so on the timeline (NOTE: "Slack announcement failed — not posted.") and leave the button enabled so the agent can retry. Silently disabling a button after a failed post is the worst of both worlds.
  • Escape user-controlled text (&, <, >) before putting it in mrkdwn. A customer name containing < otherwise breaks the whole block.
  • Rate limits. chat.postMessage is roughly 1 message per second per channel. Agent presses are human-paced so this rarely bites, but honour Retry-After on HTTP 429 anyway.

Use case 2 — Approve / Decline

Goal: the agent presses Request approval in Slack; you post a card with Approve and Decline buttons, and continue the conversation when a second human presses one — minutes or hours later.

This is the agent-in-the-loop pattern: the agent decides that approval is needed, a supervisor decides whether it is granted, and the customer is answered automatically either way.

2.1 Flow

  ── Lifecycle 1 (leg A + B) ──────────────────────────────
  agent presses "Request approval in Slack"
         │  ACTION trigger, action = "slack.approval"
  Salted CX ──▶ your service ──chat.postMessage──▶ #approvals
                      │                              (card with 2 buttons,
                      ├── MESSAGE "checking…"           each carrying the
                      ├── NOTE                          conversation context)
                      └── button Disabled ──▶ Salted CX (in band, requestId)

  ── nothing is held open in between ──────────────────────

  ── Lifecycle 2 (leg C + D) ──────────────────────────────
  #approvals ──button click──▶ your service ──200 within 3s──▶ Slack
                                    │
                                    ├── chat.update ──▶ #approvals ("Approved by @petr")
                                    └── actions (out of band, NO requestId) ──▶ Salted CX

2.2 What the agent press gives you

The ACTION event carries no free-text payload — a button press is a bare signal. Everything the approver needs to make a decision must therefore come from the conversation itself:

SourceTypical content
conversation.customThe order id, the amount, the case reference — set earlier by the bot or by a prior integration
customer.displayNameWho is asking
turns[]Recent conversation history, if you want to quote the customer’s request
trigger.agentengagements[]Which agent is asking for the approval

If conversation.custom does not yet hold what you need, that is a flow design problem, not an integration problem — the bot (or the agent, via a form) must record it before the button is pressed. Fail loudly:

const orderId = event.conversation.custom?.orderId;
const amount = event.conversation.custom?.refundAmount;
if (!orderId || typeof amount !== "number") {
  await postActions(event, [
    { type: "NOTE", content: "Cannot request approval: order id / refund amount missing on the conversation." },
  ], event.requestId);
  return;
}

A NOTE telling the agent why nothing happened is worth far more than a silent return — from the agent’s seat, a button that does nothing is indistinguishable from a broken integration.

2.3 Posting the card

The two buttons differ only in action_id, label and style. Both carry the same value: an encoded snapshot of everything you need to find the conversation again.

{
  "channel": "C0123ABCDEF",
  "text": "Approval needed: refund 129 EUR for order 10042",
  "blocks": [
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "*Approval needed*\nRequested by *Petr Dvorak*\nRefund *129 EUR* for order *10042*\nCustomer: Jana Novak\n<https://demo-adventures.salted.cx/conversations/cnv_5d9c8b7a|Open conversation>"
      }
    },
    {
      "type": "actions",
      "block_id": "approval",
      "elements": [
        {
          "type": "button",
          "action_id": "approve",
          "style": "primary",
          "text": { "type": "plain_text", "text": "Approve" },
          "value": "eyJhY2NvdW50SWQiOiJhMWIyYzNkNCIsInJlZ2lvbiI6ImV1In0"
        },
        {
          "type": "button",
          "action_id": "decline",
          "style": "danger",
          "text": { "type": "plain_text", "text": "Decline" },
          "value": "eyJhY2NvdW50SWQiOiJhMWIyYzNkNCIsInJlZ2lvbiI6ImV1In0"
        }
      ]
    }
  ]
}

Constraints worth knowing before you design the payload:

LimitValue
Button value2000 characters max — the hard constraint on how much context you can carry
action_idMust be unique within the message
Buttons per actions block25
text in a buttonplain_text only, no mrkdwn
Card must be re-postableSlack does not de-duplicate; you must

If your context does not fit in 2000 characters, put an opaque key in value and store the payload in your own database — but see §9 first: the self-contained approach is almost always better.

JavaScript

function encodeContext(context) {
  const json = JSON.stringify(context);
  return Buffer.from(json, "utf8").toString("base64url");
}

async function requestApproval(event) {
  const custom = event.conversation.custom ?? {};
  const orderId = custom.orderId;
  const amount = custom.refundAmount;
  if (!orderId || typeof amount !== "number") {
    await postActions(event, [
      { type: "NOTE", content: "Cannot request approval: order id / refund amount missing." },
    ], event.requestId);
    return;
  }

  const requestedBy = pressedBy(event);
  const question = `Refund *${amount} EUR* for order *${orderId}*`;

  const value = encodeContext({
    accountId: event.accountId,
    region: event.region,
    domain: event.domain,
    conversationPid: event.conversation.pid,
    customerPid: event.customer.pid,
    requestedBy,
    custom,
  });
  if (value.length > 2000) throw new Error("Approval context exceeds Slack's 2000-char button value limit");

  await slack("chat.postMessage", {
    channel: process.env.SLACK_APPROVAL_CHANNEL_ID,
    text: `Approval needed: refund${amount} EUR for order${orderId}`,
    blocks: [
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: `*Approval needed*\nRequested by *${escapeSlack(requestedBy)}*\n${question}\n` +
                `Customer:${escapeSlack(event.customer.displayName ?? "Unknown")}\n` +
                `<${conversationLink(event)}|Open conversation>`,
        },
      },
      {
        type: "actions",
        block_id: "approval",
        elements: [
          { type: "button", action_id: "approve", style: "primary",
            text: { type: "plain_text", text: "Approve" }, value },
          { type: "button", action_id: "decline", style: "danger",
            text: { type: "plain_text", text: "Decline" }, value },
        ],
      },
    ],
  }, SLACK_BOT_TOKEN);

  // In band: tell the customer, note it, and lock the button while it is pending.
  await postActions(event, [
    { type: "MESSAGE", content: "Let me check this with a colleague — one moment.", attachments: [] },
    { type: "NOTE", content: `Approval requested in Slack by${requestedBy}: refund${amount} EUR for order${orderId}.` },
    {
      type: "CONVERSATION_UPDATE",
      custom: { approvalPending: true },
      desktop: {
        customActions: [{ id: ACTION_APPROVAL, status: "Disabled", title: "Approval pending…" }],
      },
    },
  ], event.requestId);
}

TypeScript

export interface ApprovalContext {
  readonly accountId: string;
  readonly region: string;
  readonly domain: string;
  readonly conversationPid: string;
  readonly customerPid: string;
  readonly requestedBy?: string;
  readonly custom?: Record<string, unknown>;
}

const encodeContext = (context: ApprovalContext): string =>
  base64UrlEncode(new TextEncoder().encode(JSON.stringify(context)));

const decodeContext = (value: string): ApprovalContext | null => {
  try {
    const parsed = JSON.parse(new TextDecoder().decode(base64UrlDecode(value))) as Partial<ApprovalContext>;
    if (typeof parsed.accountId !== "string" || typeof parsed.region !== "string") return null;
    if (typeof parsed.conversationPid !== "string" || typeof parsed.customerPid !== "string") return null;
    return parsed as ApprovalContext;
  } catch {
    return null;
  }
};

function base64UrlEncode(bytes: Uint8Array): string {
  let binary = "";
  for (const byte of bytes) binary += String.fromCharCode(byte);
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

function base64UrlDecode(value: string): Uint8Array {
  const normalised = value.replace(/-/g, "+").replace(/_/g, "/");
  const padded = normalised + "===".slice((normalised.length + 3) % 4);
  const binary = atob(padded);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
  return bytes;
}

export async function requestApproval(event: YourLogicEvent, env: Env): Promise<void> {
  const custom = event.conversation.custom ?? {};
  const orderId = custom["orderId"];
  const amount = custom["refundAmount"];
  if (typeof orderId !== "string" || typeof amount !== "number") {
    await postActions(event, [
      { type: "NOTE", content: "Cannot request approval: order id / refund amount missing." },
    ], { requestId: event.requestId, sharedSecret: env.YOURLOGIC_SHARED_SECRET });
    return;
  }

  const requestedBy = pressedBy(event);
  const value = encodeContext({
    accountId: event.accountId,
    region: event.region,
    domain: event.domain,
    conversationPid: event.conversation.pid,
    customerPid: event.customer.pid,
    requestedBy,
    custom,
  });
  if (value.length > 2000) throw new Error("Approval context exceeds Slack's 2000-char button value limit");

  const button = (actionId: string, label: string, style: "primary" | "danger") => ({
    type: "button",
    action_id: actionId,
    style,
    text: { type: "plain_text", text: label },
    value,
  });

  await slack("chat.postMessage", {
    channel: env.SLACK_APPROVAL_CHANNEL_ID,
    text: `Approval needed: refund${amount} EUR for order${orderId}`,
    blocks: [
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: `*Approval needed*\nRequested by *${escapeSlack(requestedBy)}*\n` +
                `Refund *${amount} EUR* for order *${escapeSlack(orderId)}*\n` +
                `<${conversationLink(event)}|Open conversation>`,
        },
      },
      { type: "actions", block_id: "approval",
        elements: [button("approve", "Approve", "primary"), button("decline", "Decline", "danger")] },
    ],
  }, env.SLACK_BOT_TOKEN);

  await postActions(event, [
    { type: "MESSAGE", content: "Let me check this with a colleague — one moment.", attachments: [] },
    { type: "NOTE", content: `Approval requested in Slack by${requestedBy}.` },
    {
      type: "CONVERSATION_UPDATE",
      custom: { approvalPending: true },
      desktop: {
        customActions: [{ id: env.SALTED_ACTION_APPROVAL, status: "Disabled", title: "Approval pending…" }],
      },
    },
  ], { requestId: event.requestId, sharedSecret: env.YOURLOGIC_SHARED_SECRET });
}

Python

import base64
import json
import os


def encode_context(context: dict) -> str:
    raw = json.dumps(context, separators=(",", ":")).encode("utf-8")
    return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")


def decode_context(value: str) -> dict | None:
    try:
        padded = value + "=" * (-len(value) % 4)
        context = json.loads(base64.urlsafe_b64decode(padded))
    except Exception:
        return None
    required = ("accountId", "region", "conversationPid", "customerPid")
    if not all(isinstance(context.get(key), str) for key in required):
        return None
    return context


async def request_approval(event: dict) -> None:
    custom = event["conversation"].get("custom") or {}
    order_id = custom.get("orderId")
    amount = custom.get("refundAmount")
    if not order_id or not isinstance(amount, (int, float)):
        await post_actions(event, [
            {"type": "NOTE", "content": "Cannot request approval: order id / refund amount missing."},
        ], request_id=event["requestId"])
        return

    requested_by = pressed_by(event)
    value = encode_context({
        "accountId": event["accountId"],
        "region": event["region"],
        "domain": event["domain"],
        "conversationPid": event["conversation"]["pid"],
        "customerPid": event["customer"]["pid"],
        "requestedBy": requested_by,
        "custom": custom,
    })
    if len(value) > 2000:
        raise ValueError("Approval context exceeds Slack's 2000-char button value limit")

    def button(action_id: str, label: str, style: str) -> dict:
        return {
            "type": "button",
            "action_id": action_id,
            "style": style,
            "text": {"type": "plain_text", "text": label},
            "value": value,
        }

    await slack(
        "chat.postMessage",
        {
            "channel": os.environ["SLACK_APPROVAL_CHANNEL_ID"],
            "text": f"Approval needed: refund{amount} EUR for order{order_id}",
            "blocks": [
                {"type": "section",
                 "text": {"type": "mrkdwn",
                          "text": f"*Approval needed*\nRequested by *{escape_slack(requested_by)}*\n"
                                  f"Refund *{amount} EUR* for order *{escape_slack(order_id)}*\n"
                                  f"<{conversation_link(event)}|Open conversation>"}},
                {"type": "actions", "block_id": "approval",
                 "elements": [button("approve", "Approve", "primary"),
                              button("decline", "Decline", "danger")]},
            ],
        },
        SLACK_BOT_TOKEN,
    )

    await post_actions(event, [
        {"type": "MESSAGE", "content": "Let me check this with a colleague — one moment.",
         "attachments": []},
        {"type": "NOTE", "content": f"Approval requested in Slack by{requested_by}."},
        {"type": "CONVERSATION_UPDATE",
         "custom": {"approvalPending": True},
         "desktop": {"customActions": [
             {"id": ACTION_APPROVAL, "status": "Disabled", "title": "Approval pending…"},
         ]}},
    ], request_id=event["requestId"])

2.4 Receiving the Slack button click

Slack POSTs to your Interactivity Request URL as application/x-www-form-urlencoded with a single field, payload, holding JSON:

{
  "type": "block_actions",
  "user": { "id": "U024BE7LH", "username": "petr", "name": "petr" },
  "channel": { "id": "C0123ABCDEF", "name": "approvals" },
  "message": { "ts": "1753351234.123456", "text": "Approval needed: refund 129 EUR" },
  "response_url": "https://hooks.slack.com/actions/T0001/12345/abcdef",
  "trigger_id": "13345224609.738474920.8088930838d88f008e0",
  "actions": [
    {
      "type": "button",
      "action_id": "approve",
      "block_id": "approval",
      "value": "eyJhY2NvdW50SWQiOiJhMWIyYzNkNCIsInJlZ2lvbiI6ImV1In0",
      "action_ts": "1753351299.000100"
    }
  ]
}

The 3-second rule. Slack shows the user a red error if you have not returned 200 within three seconds. Salted CX and Slack API calls can easily exceed that. So: acknowledge first, work afterwards. Every example below does exactly that.

2.5 The full callback handler

Order of operations, in every language:

  1. Read the raw body as bytes/string — before any parsing. The signature is over raw bytes.
  1. Verify the Slack signature (see §8). Reject with 401.
  1. Parse payload, ignore anything that is not type: "block_actions".
  1. Decode the button value into the conversation context; reject if it does not decode.
  1. Return 200 immediately.
  1. In the background: chat.update the card into a resolved state, then post the actions to Salted CX out of band (no requestId) — including re-enabling the agent’s desktop button by clearing its override.

JavaScript (Node + Express)

import crypto from "node:crypto";
import express from "express";

const app = express();
// Raw body — the Slack signature is computed over the exact bytes.
app.use("/slack/interactive", express.raw({ type: "*/*" }));

app.post("/slack/interactive", (req, res) => {
  const rawBody = req.body.toString("utf8");

  if (!verifySlackSignature({
    signingSecret: process.env.SLACK_SIGNING_SECRET,
    timestamp: req.get("x-slack-request-timestamp"),
    signature: req.get("x-slack-signature"),
    body: rawBody,
  })) {
    return res.status(401).send("Invalid Slack signature");
  }

  const payload = JSON.parse(new URLSearchParams(rawBody).get("payload") ?? "{}");

  // Acknowledge inside the 3-second window; everything else is background work.
  res.status(200).end();

  if (payload.type !== "block_actions") return;
  handleBlockAction(payload).catch((err) => console.error("slack.action.failed", err));
});

async function handleBlockAction(payload) {
  const action = payload.actions?.[0];
  if (!action) return;
  if (action.action_id !== "approve" && action.action_id !== "decline") return;

  const context = decodeContext(action.value);
  if (!context) return console.error("slack.action.bad_context", { actionId: action.action_id });

  const approved = action.action_id === "approve";
  const decidedBy = payload.user?.username ?? payload.user?.id ?? "someone";

  // 1. Freeze the card so the decision cannot be taken twice.
  await slack("chat.update", {
    channel: payload.channel.id,
    ts: payload.message.ts,
    text: payload.message.text,
    blocks: [
      { type: "section", text: { type: "mrkdwn", text: payload.message.text } },
      { type: "context", elements: [{
        type: "mrkdwn",
        text: `${approved ? ":white_check_mark: *Approved*" : ":x: *Declined*"} by <@${payload.user.id}>` +
              (context.requestedBy ? ` · requested by${context.requestedBy}` : ""),
      }] },
    ],
  }, SLACK_BOT_TOKEN);

  // 2. Resume the conversation — out of band, so NO requestId.
  const outcome = approved
    ? [
        { type: "MESSAGE", content: "Good news — your refund has been approved and is on its way.", attachments: [] },
        { type: "NOTE", content: `Refund approved in Slack by${decidedBy}.` },
        { type: "CONVERSATION_COMPLETE" },
      ]
    : [
        { type: "MESSAGE", content: "I could not approve this automatically. A colleague will take over shortly.", attachments: [] },
        { type: "NOTE", content: `Refund declined in Slack by${decidedBy}.` },
        { type: "NEEDS_HELP", needsHelp: true },
      ];

  // 3. Clear the desktop button override so the agent can request again.
  outcome.push({
    type: "CONVERSATION_UPDATE",
    custom: { approvalPending: false, approvalDecided: true, approvalApproved: approved },
    desktop: { customActions: [] },
  });

  await applyConversationActions(context, outcome);
}

async function applyConversationActions(context, actions) {
  const url =
    `https://api.${context.region}.salted.cx/api/v1/live/your-logic` +
    `/accounts/${context.accountId}/conversations/${context.conversationPid}`;

  const response = await fetch(url, {
    method: "POST",
    headers: {
      Authorization: `Bearer${process.env.YOURLOGIC_SHARED_SECRET}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ actions }),  // no requestId — out of band
  });
  if (!response.ok) {
    throw new Error(`Salted CX rejected actions:${response.status}${await response.text()}`);
  }
}

function decodeContext(value) {
  try {
    const context = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
    const required = ["accountId", "region", "conversationPid", "customerPid"];
    return required.every((key) => typeof context[key] === "string") ? context : null;
  } catch {
    return null;
  }
}

TypeScript (fetch-style runtime)

interface BlockActionsPayload {
  readonly type: string;
  readonly user: { readonly id: string; readonly username?: string };
  readonly channel: { readonly id: string };
  readonly message: { readonly ts: string; readonly text: string };
  readonly actions: ReadonlyArray<{ readonly action_id: string; readonly value: string }>;
}

export async function handleSlackInteractive(
  request: Request,
  env: Env,
  ctx: ExecutionContext,
): Promise<Response> {
  const rawBody = await request.text();

  const verified = await verifySlackSignature({
    signingSecret: env.SLACK_SIGNING_SECRET,
    timestamp: request.headers.get("X-Slack-Request-Timestamp"),
    signature: request.headers.get("X-Slack-Signature"),
    body: rawBody,
  });
  if (!verified) return new Response("Invalid Slack signature", { status: 401 });

  const payloadJson = new URLSearchParams(rawBody).get("payload");
  if (!payloadJson) return new Response("", { status: 200 });

  const payload = JSON.parse(payloadJson) as BlockActionsPayload;
  if (payload.type === "block_actions") {
    // Ack now, work later — Slack's window is 3 seconds.
    ctx.waitUntil(handleBlockAction(payload, env));
  }
  return new Response("", { status: 200 });
}

type Decision = "approve" | "decline";

async function handleBlockAction(payload: BlockActionsPayload, env: Env): Promise<void> {
  const action = payload.actions[0];
  if (!action || (action.action_id !== "approve" && action.action_id !== "decline")) return;

  const context = decodeContext(action.value);
  if (!context) return;

  const decision = action.action_id as Decision;
  const decidedBy = payload.user.username ?? payload.user.id;

  await freezeCard(payload, decision, context, env);
  await applyConversationActions(context, actionsFor(decision, decidedBy, env), env);
}

function actionsFor(
  decision: Decision,
  decidedBy: string,
  env: Env,
): ReadonlyArray<Record<string, unknown>> {
  const approved = decision === "approve";
  const outcome = approved
    ? [
        { type: "MESSAGE", content: "Good news — your refund has been approved.", attachments: [] },
        { type: "NOTE", content: `Refund approved in Slack by${decidedBy}.` },
        { type: "CONVERSATION_COMPLETE" },
      ]
    : [
        { type: "MESSAGE", content: "A colleague will take over shortly.", attachments: [] },
        { type: "NOTE", content: `Refund declined in Slack by${decidedBy}.` },
        { type: "NEEDS_HELP", needsHelp: true },
      ];

  return [
    ...outcome,
    {
      type: "CONVERSATION_UPDATE",
      custom: { approvalPending: false, approvalDecided: true, approvalApproved: approved },
      // Empty list clears the override, restoring the button's account default.
      desktop: { customActions: [] },
    },
  ];
}

async function freezeCard(
  payload: BlockActionsPayload,
  decision: Decision,
  context: ApprovalContext,
  env: Env,
): Promise<void> {
  const verdict = decision === "approve"
    ? `:white_check_mark: *Approved* by <@${payload.user.id}>`
    : `:x: *Declined* by <@${payload.user.id}>`;

  await slack("chat.update", {
    channel: payload.channel.id,
    ts: payload.message.ts,
    text: payload.message.text,
    blocks: [
      { type: "section", text: { type: "mrkdwn", text: payload.message.text } },
      { type: "context", elements: [{
        type: "mrkdwn",
        text: context.requestedBy ? `${verdict} · requested by${context.requestedBy}` : verdict,
      }] },
    ],
  }, env.SLACK_BOT_TOKEN);
}

async function applyConversationActions(
  context: ApprovalContext,
  actions: ReadonlyArray<Record<string, unknown>>,
  env: Env,
): Promise<void> {
  const url =
    `https://api.${context.region}.salted.cx/api/v1/live/your-logic` +
    `/accounts/${context.accountId}/conversations/${context.conversationPid}`;

  const response = await fetch(url, {
    method: "POST",
    headers: {
      Authorization: `Bearer${env.YOURLOGIC_SHARED_SECRET}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ actions }),  // no requestId — out of band
  });
  if (!response.ok) {
    throw new Error(`Salted CX rejected actions:${response.status}${await response.text()}`);
  }
}

Python (FastAPI)

import json
import os
from urllib.parse import parse_qs

import httpx
from fastapi import BackgroundTasks, Request, Response

SLACK_SIGNING_SECRET = os.environ["SLACK_SIGNING_SECRET"]


@app.post("/slack/interactive")
async def slack_interactive(request: Request, background: BackgroundTasks) -> Response:
    raw_body = await request.body()

    if not verify_slack_signature(
        signing_secret=SLACK_SIGNING_SECRET,
        timestamp=request.headers.get("x-slack-request-timestamp"),
        signature=request.headers.get("x-slack-signature"),
        body=raw_body,
    ):
        return Response(status_code=401, content="Invalid Slack signature")

    fields = parse_qs(raw_body.decode("utf-8"))
    payload_json = fields.get("payload", [None])[0]
    if not payload_json:
        return Response(status_code=200)

    payload = json.loads(payload_json)
    if payload.get("type") == "block_actions":
        # Ack now, work later — Slack's window is 3 seconds.
        background.add_task(handle_block_action, payload)

    return Response(status_code=200)


async def handle_block_action(payload: dict) -> None:
    actions = payload.get("actions") or []
    if not actions:
        return
    action = actions[0]
    if action.get("action_id") not in ("approve", "decline"):
        return

    context = decode_context(action.get("value", ""))
    if context is None:
        return

    approved = action["action_id"] == "approve"
    user = payload.get("user") or {}
    decided_by = user.get("username") or user.get("id") or "someone"
    verdict = (f":white_check_mark: *Approved* by <@{user.get('id')}>"
               if approved else f":x: *Declined* by <@{user.get('id')}>")
    requested_by = context.get("requestedBy")

    await slack("chat.update", {
        "channel": payload["channel"]["id"],
        "ts": payload["message"]["ts"],
        "text": payload["message"]["text"],
        "blocks": [
            {"type": "section",
             "text": {"type": "mrkdwn", "text": payload["message"]["text"]}},
            {"type": "context", "elements": [{
                "type": "mrkdwn",
                "text": f"{verdict} · requested by{requested_by}" if requested_by else verdict,
            }]},
        ],
    }, SLACK_BOT_TOKEN)

    if approved:
        outcome = [
            {"type": "MESSAGE", "content": "Good news — your refund has been approved.",
             "attachments": []},
            {"type": "NOTE", "content": f"Refund approved in Slack by{decided_by}."},
            {"type": "CONVERSATION_COMPLETE"},
        ]
    else:
        outcome = [
            {"type": "MESSAGE", "content": "A colleague will take over shortly.",
             "attachments": []},
            {"type": "NOTE", "content": f"Refund declined in Slack by{decided_by}."},
            {"type": "NEEDS_HELP", "needsHelp": True},
        ]

    # Clear the desktop button override so the agent can request again.
    outcome.append({
        "type": "CONVERSATION_UPDATE",
        "custom": {"approvalPending": False, "approvalDecided": True, "approvalApproved": approved},
        "desktop": {"customActions": []},
    })

    await apply_conversation_actions(context, outcome)


async def apply_conversation_actions(context: dict, actions: list[dict]) -> None:
    url = (
        f"https://api.{context['region']}.salted.cx/api/v1/live/your-logic"
        f"/accounts/{context['accountId']}/conversations/{context['conversationPid']}"
    )
    async with httpx.AsyncClient(timeout=10) as client:
        response = await client.post(
            url,
            json={"actions": actions},  # no requestId — out of band
            headers={"Authorization": f"Bearer{SHARED_SECRET}"},
        )
    response.raise_for_status()

2.6 Double-click and race protection

There are two double-click risks in this flow, at opposite ends:

The agent’s desktop button. Solved by the Disabled override you send in the same turn (§5.3) — the button is greyed out before the agent can press it again, and the approvalPending flag in custom lets you reject a stray press that was already in flight.

The Slack card. Two people can press Approve and Decline within the same second, and Slack does not serialise this for you. The cheapest defence is the chat.update in step 1 — replacing the actions block removes the buttons, so a second click has nothing to hit. But it is not atomic: a click already in flight still arrives. For anything that moves money, add a real guard:

  • Conditional update: call chat.update before applying the conversation actions and treat a Slack message_not_found / mismatch as “already decided”.
  • Idempotency key: key on payload.message.ts (unique per card) in a small store — KV, Redis, a database row with a unique constraint. Store the decision on first write; on a duplicate, do nothing.
  • Conversation-side flag: the approvalDecided flag written in the callback above — ignore clicks whose context snapshot already carries it.

2.7 What if nobody presses anything?

Slack messages never expire, so an unanswered card sits there forever while the customer waits — and the agent’s desktop button stays disabled, so they cannot even retry. Always pair the card with a timeout:

  • Schedule a job (cron, delayed queue, durable timer) for e.g. 15 minutes after posting.
  • On fire, check whether a decision was recorded. If not:
    1. chat.update the card to “expired — no longer actionable”,
    1. apply NEEDS_HELP: true so a human agent picks the conversation up,
    1. clear the customActions override so the requesting agent’s button works again.

Do not rely on Salted CX to time this out for you — Your Logic’s own expires governs the in-band turn, not your out-of-band approval.


8. Verifying inbound Slack requests

Slack signs every request. The recipe:

basestring = "v0:" + X-Slack-Request-Timestamp + ":" + <raw request body>
expected   = "v0=" + hex( HMAC_SHA256( signing_secret, basestring ) )
compare expected against X-Slack-Signature  (constant time)

Two non-negotiable details:

  1. The body must be the raw bytes, before any framework has parsed or re-serialised it. If your framework consumed the body into a dict, you cannot recompute the signature.
  1. Reject timestamps older than 5 minutes. Without this the signature alone permits unlimited replay of a captured request.

JavaScript (Node)

import crypto from "node:crypto";

function verifySlackSignature({ signingSecret, timestamp, signature, body, toleranceSeconds = 300 }) {
  if (!timestamp || !signature) return false;

  const ts = Number.parseInt(timestamp, 10);
  if (!Number.isFinite(ts)) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - ts) > toleranceSeconds) return false;

  const expected = "v0=" + crypto
    .createHmac("sha256", signingSecret)
    .update(`v0:${timestamp}:${body}`)
    .digest("hex");

  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(signature, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

TypeScript (Web Crypto — works on Workers, Deno, Bun and modern Node)

export async function verifySlackSignature(options: {
  readonly signingSecret: string;
  readonly timestamp: string | null;
  readonly signature: string | null;
  readonly body: string;
  readonly toleranceSeconds?: number;
}): Promise<boolean> {
  const { signingSecret, timestamp, signature, body } = options;
  if (!timestamp || !signature) return false;

  const tolerance = options.toleranceSeconds ?? 300;
  const ts = Number.parseInt(timestamp, 10);
  if (!Number.isFinite(ts) || Math.abs(Math.floor(Date.now() / 1000) - ts) > tolerance) return false;

  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(signingSecret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`v0:${timestamp}:${body}`));
  const expected = "v0=" + [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");

  return timingSafeEqual(expected, signature);
}

/** Length-independent, value-constant-time string comparison. */
function timingSafeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return diff === 0;
}

Python

import hashlib
import hmac
import time


def verify_slack_signature(
    *, signing_secret: str, timestamp: str | None, signature: str | None,
    body: bytes, tolerance_seconds: int = 300,
) -> bool:
    if not timestamp or not signature:
        return False
    try:
        ts = int(timestamp)
    except ValueError:
        return False
    if abs(int(time.time()) - ts) > tolerance_seconds:
        return False

    basestring = f"v0:{timestamp}:".encode("utf-8") + body
    expected = "v0=" + hmac.new(
        signing_secret.encode("utf-8"), basestring, hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected, signature)

9. Carrying conversation state through Slack

The round-trip is stateless by design: the Slack button click arrives at a process that may have no memory of posting the card — a new container, a recycled serverless isolate, a different region. There are two ways to bridge the gap.

Option A — self-contained (recommended)

Encode everything needed into the button value: accountId, region, domain, conversationPid, customerPid, who requested it, and a snapshot of conversation.custom.

{
  "accountId": "a1b2c3d4-0000-1111-2222-333344445555",
  "region": "eu",
  "domain": "demo-adventures.salted.cx",
  "conversationPid": "cnv_5d9c8b7a",
  "customerPid": "cus_7f3a2b19",
  "requestedBy": "Petr Dvorak",
  "custom": { "orderId": "10042", "refundAmount": 129 }
}

Base64url-encode the JSON (the codecs are in §2.3) — it stays URL/JSON-safe and Slack will not mangle it.

  • Pro: no database, no TTL, no cleanup, survives every kind of restart.
  • Con: the 2000-character ceiling, and custom is a snapshot — if the conversation moves on between posting the card and the click, blindly restoring it can overwrite newer values. Fine for short-lived approvals; carry only what the decision actually needs.
  • Security: the value is base64, not encryption — it is readable by anyone who can inspect the message, and Slack echoes back whatever it was given. Never put secrets or money amounts you will act on unverified in it. Re-read authoritative values (price, balance, entitlement) from your own system after the click, and treat the context purely as an identifier of what to re-check.

Option B — server-side store

Put an opaque random key in value, store the full context in KV/Redis/SQL under that key with a TTL a little longer than your approval timeout.

Use this when the context genuinely exceeds 2000 characters, or when it contains data that must not leave your perimeter. Cost: infrastructure, expiry handling, and a new failure mode (“the key was evicted before the human clicked”).

Persisting flags on the conversation itself

conversation.custom is a free-form object you own. Write to it with a CONVERSATION_UPDATE action; read it back off the next event — including the next agent button press. Use it for state that must survive independently of Slack, and pair it with the toolbar override so the agent sees the same state you are enforcing:

{
  "actions": [
    {
      "type": "CONVERSATION_UPDATE",
      "custom": {
        "slackApprovalMessageTs": "1753351234.123456",
        "approvalPending": true
      },
      "desktop": {
        "customActions": [{ "id": "slack.approval", "status": "Disabled", "title": "Approval pending…" }]
      }
    }
  ]
}

10. Security checklist

Item
Bot token and signing secret live in a secret manager, not in source or a .env committed to git
Inbound Your Logic requests: bearer token compared in constant time
Inbound Slack requests: signature verified over the raw body, timestamp within 5 minutes
Both endpoints are HTTPS with a valid certificate, and reject non-POST
ACTION triggers checked for participantType and a known action id before acting
Button value contains no secrets — it is base64, not encrypted, and echoes back from Slack
Amounts / entitlements re-read from your own system after the click, never trusted from the button
Approver identity checked if it matters: payload.user.id against an allowlist, not the channel
User-controlled text escaped (&, <, >) before entering mrkdwn
Slack token rotated on staff turnover; a leaked xoxb- token can post as your company
Slack failures logged and swallowed — never propagated into the customer conversation
Approvals de-duplicated by message.ts so a double-click cannot double-refund
Desktop button disabled while a request is in flight, and re-enabled when it resolves

Two notes on who is allowed to do what:

  • Pressing the desktop button is governed by Salted CX’s own per-account action configuration and agent permissions — that is the customer’s admin decision, not something you enforce in code. But do record who pressed (trigger.agent) on the timeline and in the Slack card; an approval request with no attributable requester is not auditable.
  • Pressing Approve in Slack is access control by convention, not by enforcement — anyone in the channel can press, and anyone added later inherits that power. If approval authority is a real business control, check payload.user.id against an explicit allowlist in your own code and reject the click otherwise (update the card with “not authorised” rather than silently ignoring it).

11. Testing and troubleshooting

11.1 Local development

Slack must reach your machine over HTTPS. Use a tunnel:

# Cloudflare Tunnel
cloudflared tunnel --url http://localhost:8787

# or ngrok
ngrok http 8787

Paste the resulting https://… URL into Interactivity & Shortcuts → Request URL. The URL changes on every restart unless you use a named tunnel — configure one to avoid re-pasting.

11.2 Simulating an agent button press

You do not need the agent desktop to test leg A — replay the ACTION event yourself:

curl -X POST https://your-service.example.com/yourlogic \
  -H "Authorization: Bearer$YOURLOGIC_SHARED_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "requestId": "test-1",
    "accountId": "a1b2c3d4-0000-1111-2222-333344445555",
    "time": "2026-07-24T10:15:30Z",
    "expires": "2026-07-24T10:16:00Z",
    "domain": "demo-adventures.salted.cx",
    "region": "eu",
    "trigger": { "type": "ACTION", "participantType": "AGENT",
                 "time": "2026-07-24T10:15:29Z",
                 "action": "slack.approval", "agent": "agt_test" },
    "customer": { "pid": "cus_test", "displayName": "Jana Novak", "contacts": [] },
    "conversation": { "pid": "cnv_test", "startConversationTime": "2026-07-24T10:14:02Z",
                      "needsHelp": false, "status": "IN_PROGRESS",
                      "defaultChannel": "web-chat", "direction": "INBOUND",
                      "custom": { "orderId": "10042", "refundAmount": 129 } },
    "engagements": [
      { "pid": "eng_test", "time": "2026-07-24T10:14:40Z", "type": "AGENT",
        "status": "IN_PROGRESS",
        "agent": { "pid": "agt_test", "type": "HUMAN", "name": "Petr Dvorak" } }
    ],
    "turns": []
  }'

Change trigger.action to slack.announce to exercise use case 1. Note that conversation.custom must carry the order/amount — that is exactly the precondition §2.2 warns about, and testing without it verifies your “missing data” NOTE path.

11.3 Previewing a card before you ship it

Paste your blocks array into Block Kit Builder — it renders live and reports schema errors precisely. Far faster than iterating through invalid_blocks.

11.4 Symptom table

SymptomCauseFix
Agent presses the button, nothing happens anywhereACTION trigger not enabled for the endpoint, or the id does not matchCheck Salted CX config; log every inbound trigger.action
Button press reaches you but is ignoredtrigger.action differs from your constant (renamed in admin)Read the id from config, not source; log the mismatch
Button not visible in the desktopCustom action not configured, or a stale Hidden overrideClear the override with an empty customActions list
Button stuck greyed outA Disabled override was never clearedSend desktop.customActions: []; add a timeout path
Slack “Request URL didn’t respond in time” when savingEndpoint not deployed, or slower than 3 sDeploy first, ack before doing work
{"ok": false, "error": "not_in_channel"}Bot not a member/invite @app, or add chat:write.public
{"ok": false, "error": "channel_not_found"}Wrong ID, or private channelUse the channel ID; invite the bot
{"ok": false, "error": "missing_scope"}Scope not grantedAdd scope, reinstall the app
{"ok": false, "error": "invalid_blocks"}Malformed Block KitValidate in Block Kit Builder
Signature verification always failsBody was parsed/re-serialised before hashingHash the raw bytes
Signature fails only sometimesServer clock driftSync NTP; the tolerance is 5 min
Slack button click shows a red errorHandler exceeded 3 sAck first, background the work
Card posted twiceAgent pressed twice before the button was disabledDisable the button in the same turn as the post
Salted CX returns 401 on leg DWrong or missing bearer tokenSame shared secret as leg A
Salted CX returns 404 on leg DWrong accountId / conversationPid / region in the URLRead all three from the event
Actions accepted but nothing visiblerequestId sent out of band, or turn already expiredOmit requestId for out-of-band calls

12. Reference tables

12.1 Trigger types

Inbound trigger.type values. Treat the set as open — acknowledge unknown types with 200.

TypeFires when
ACTIONA named action fired — including an agent pressing a custom desktop button. The entry point for both use cases here.
MESSAGEA chat message was sent (participantType says by whom)
EMAILAn email arrived on the conversation (carries subject)
QUESTION / QUESTION_DYNAMICA question was posed
ANSWERA question was answered (questionId / answerId)
TEMPLATEA WhatsApp template message was sent
NOTEAn internal note was added
FILE / IMAGE / AUDIO / VIDEOMedia was uploaded
REVIEWA CSAT / review event
CONVERSATION_OFFEREDThe conversation was offered to agents
CONVERSATION_COMPLETEThe conversation finished
CONVERSATION_UPDATEConversation attributes or custom changed
CONVERSATION_INACTIVENo activity for the configured period
ENGAGEMENT_COMPLETEOne agent/bot engagement ended

12.2 The ACTION trigger

FieldTypeMeaning
type"ACTION"Discriminant
participantTypeAGENT / EXTERNAL_AGENT / BOT / CUSTOMER / UNKNOWNWho caused it — filter on the agent kinds
timeISO-8601When it fired
actionstringThe configured custom-action id — your switch key
agentstring | nullPid of the agent; resolve a name via engagements[]

12.3 Action types

Outbound actions[].type values, with the fields these use cases need.

TypeKey fieldsEffect
MESSAGEcontent, attachments, channel?, contactPid?Sends a message to the customer
NOTEcontent, responseTo?Internal note on the timeline — invisible to the customer
NEEDS_HELPneedsHelp, targetAgentPid?, timeout?, onTimeout?Escalates to a human agent
CONVERSATION_UPDATEcustom, desktop, urgency?, info?, attributes?Writes conversation state and controls the toolbar buttons
QUESTIONquestionPid, allowCustomReply?Poses a pre-configured question
QUESTION_DYNAMICquestion: { externalId, content, answers[] }Poses an ad-hoc question with answer options
CUSTOMER_UPDATEcountryExternalId?, segmentExternalId?, …Updates customer classification
EMAILcontactPid, subject, body, attachments?Sends an email to an email contact
SEND_FILEpath, name, mimeTypeSends a file to the customer
INVITE_EXTERNAL_AGENTemail, name, subject, expiresInvites a partner into the conversation
ENGAGEMENT_COMPLETEengagementPid, outcome*?, reason*?Closes one engagement with an outcome
CONVERSATION_COMPLETECloses the conversation
ENGAGE_YOUR_LOGICengageYourLogicTurns the bot on/off for this conversation
NO_ACTIONExplicit no-op — acknowledges the turn without doing anything

12.4 Desktop button control (CONVERSATION_UPDATE.desktop)

FieldShapePurpose
customActions[{ id, status, title? }]Per-account buttons — status is Disabled or Hidden
attributes[{ id, status }]Per-account display fields
actions{ leave?, resolve?, waitForCustomer?, askForHelp?, inviteExternal? }Standard buttons; each { status } is Hidden / Disabled / Enabled
defaultReply{ title?, subject?, message }Pre-fills the agent’s composer

Custom actions and attributes carry only Disabled / Hidden. To restore a button’s default, omit its entry (or send an empty customActions list) rather than sending an Enabled status.

12.5 Endpoints

PurposeMethod + URL
Apply actions to a conversation (in and out of band)POST https://api.{region}.salted.cx/api/v1/live/your-logic/accounts/{accountId}/conversations/{conversationPid}
Post a Slack message or cardPOST https://slack.com/api/chat.postMessage
Edit an existing Slack messagePOST https://slack.com/api/chat.update
Post an ephemeral (single-viewer) Slack messagePOST https://slack.com/api/chat.postEphemeral

12.6 Environment variables

NameValueSource
YOURLOGIC_SHARED_SECRETBearer token for legs A and DYou generate; give to Salted CX
SLACK_BOT_TOKENxoxb-…Slack → OAuth & Permissions
SLACK_SIGNING_SECRETHex stringSlack → Basic Information
SLACK_CHANNEL_IDC0123ABCDEF — announcementsSlack → channel → About
SLACK_APPROVAL_CHANNEL_IDC0456DEFGHI — approvalsSlack → channel → About
SALTED_ACTION_ANNOUNCEslack.announceSalted CX admin — custom action id
SALTED_ACTION_APPROVALslack.approvalSalted CX admin — custom action id

Further reading

  • Slack chat.postMessage · chat.update
Was this page helpful?