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.
- Announcement — the agent presses Announce in Slack; you post a static message with a link back to the conversation. Fire-and-forget, no callback.
- 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 │
└─────────────────────────────────┴─────────────────────────────┘| Leg | Direction | Transport | Auth |
|---|---|---|---|
| A | Salted CX → you | POST JSON to your Your Logic endpoint | Authorization: Bearer <shared secret> you chose |
| B | you → Slack | POST https://slack.com/api/chat.postMessage | Authorization: Bearer xoxb-… (bot token) |
| C | Slack → you | POST form-encoded to your interactivity endpoint | Slack request signature (HMAC-SHA256) |
| D | you → Salted CX | POST JSON to the conversation actions endpoint | Authorization: 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
- Open api.slack.com/apps → Create New App → From scratch.
- 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:
| Scope | Needed for | Required by |
|---|---|---|
chat:write | Posting messages and cards | Both use cases |
chat:write.public | Posting into public channels the bot has not been invited to | Optional |
channels:read | Resolving a channel name (#support) to an ID | Optional |
users:read | Resolving the approver’s display name / email | Optional |
chat:write.customize | Overriding the posting username / icon per message | Optional |
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 Secret → Show → 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:false2.8 Setup summary — what to hand to the developer
| Value | Where the customer finds it | Example |
|---|---|---|
| Bot token | OAuth & Permissions | xoxb-1111-2222-aBcDeF… |
| Signing secret | Basic Information → App Credentials | 8f14e45fceea167a5a36… |
| Channel ID | Channel → About | C0123ABCDEF |
3. What must be set up in Salted CX
Requested from your Salted CX contact or configured in the admin console:
- Your Logic endpoint URL — the HTTPS URL Salted CX will
POSTevents to (https://your-service.example.com/yourlogic). It must be publicly reachable and serve a valid TLS certificate.
- 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 withopenssl rand -hex 32.
- Account ID (
accountId) and region (region) — these also arrive inside every event payload, so you can read them rather than configure them.
- The custom agent actions — the desktop buttons that start these flows. This is the piece specific to this document; see below.
- The
ACTIONtrigger enabled for the Your Logic endpoint, so button presses actually reach you.
3.1 Custom agent actions (the desktop buttons)
Go to Settings → Live Conversations → Toolbar 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:
| Id | Label the agent sees | Use case |
|---|---|---|
slack.announce | Announce in Slack | 1 |
slack.approval | Request approval in Slack | 2 |
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.cx4. 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:
| Field | Why it matters |
|---|---|
trigger.action | Which button was pressed — the configured custom-action id |
trigger.agent | Pid of the agent who pressed it |
trigger.participantType | AGENT for an in-house agent, EXTERNAL_AGENT for a partner |
requestId | Echo it back when responding in band to this event |
accountId, region | Build the callback URL for leg D |
domain | Tenant domain — used to build the conversation deep link |
conversation.pid | Identifies the conversation for leg D |
conversation.custom | Free-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.toolbar | Current state of the toolbar buttons |
expires | After this instant Salted CX stops waiting for an in-band response |
Forward compatibility. Treat
trigger.typeas an open set, andtrigger.actiontoo. Unknown values must be acknowledged with200/202and 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:
| Mode | When | Body | Endpoint |
|---|---|---|---|
| In band | Answering the event you are currently handling | includes requestId | POST {api}/api/v1/live/your-logic/accounts/{accountId}/conversations/{conversationPid} |
| Out of band | Any later moment (a Slack button click, a cron, a webhook) | omits requestId | same 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, 202Checking 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…" }
]
}
}
]
}status | Effect |
|---|---|
Disabled | Button greyed out — visible but not clickable |
Hidden | Button 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-8Slack 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 bodyCommon 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:
- Post the message to Slack.
- Write a
NOTEon the conversation timeline so the other agents can see it was announced.
- 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
textas well asblocks.textis 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, "&").replace(/</g, "<").replace(/>/g, ">");
}
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, "&").replace(/</g, "<").replace(/>/g, ">");
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("&", "&").replace("<", "<").replace(">", ">")
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.announcein the same response is simpler and more honest than a flag, because the agent can see that it already happened. Keep acustomflag 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 inmrkdwn. A customer name containing<otherwise breaks the whole block.
- Rate limits.
chat.postMessageis roughly 1 message per second per channel. Agent presses are human-paced so this rarely bites, but honourRetry-Afteron 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 CX2.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:
| Source | Typical content |
|---|---|
conversation.custom | The order id, the amount, the case reference — set earlier by the bot or by a prior integration |
customer.displayName | Who is asking |
turns[] | Recent conversation history, if you want to quote the customer’s request |
trigger.agent → engagements[] | 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:
| Limit | Value |
|---|---|
Button value | 2000 characters max — the hard constraint on how much context you can carry |
action_id | Must be unique within the message |
Buttons per actions block | 25 |
text in a button | plain_text only, no mrkdwn |
| Card must be re-postable | Slack 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:
- Read the raw body as bytes/string — before any parsing. The signature is over raw bytes.
- Verify the Slack signature (see §8). Reject with
401.
- Parse
payload, ignore anything that is nottype: "block_actions".
- Decode the button
valueinto the conversation context; reject if it does not decode.
- Return
200immediately.
- In the background:
chat.updatethe card into a resolved state, then post the actions to Salted CX out of band (norequestId) — 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.updatebefore applying the conversation actions and treat a Slackmessage_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
approvalDecidedflag 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:
chat.updatethe card to “expired — no longer actionable”,
- apply
NEEDS_HELP: trueso a human agent picks the conversation up,
- clear the
customActionsoverride 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:
- 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.
- 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
customis 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.idagainst 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 8787Paste 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
| Symptom | Cause | Fix |
|---|---|---|
| Agent presses the button, nothing happens anywhere | ACTION trigger not enabled for the endpoint, or the id does not match | Check Salted CX config; log every inbound trigger.action |
| Button press reaches you but is ignored | trigger.action differs from your constant (renamed in admin) | Read the id from config, not source; log the mismatch |
| Button not visible in the desktop | Custom action not configured, or a stale Hidden override | Clear the override with an empty customActions list |
| Button stuck greyed out | A Disabled override was never cleared | Send desktop.customActions: []; add a timeout path |
| Slack “Request URL didn’t respond in time” when saving | Endpoint not deployed, or slower than 3 s | Deploy 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 channel | Use the channel ID; invite the bot |
{"ok": false, "error": "missing_scope"} | Scope not granted | Add scope, reinstall the app |
{"ok": false, "error": "invalid_blocks"} | Malformed Block Kit | Validate in Block Kit Builder |
| Signature verification always fails | Body was parsed/re-serialised before hashing | Hash the raw bytes |
| Signature fails only sometimes | Server clock drift | Sync NTP; the tolerance is 5 min |
| Slack button click shows a red error | Handler exceeded 3 s | Ack first, background the work |
| Card posted twice | Agent pressed twice before the button was disabled | Disable the button in the same turn as the post |
| Salted CX returns 401 on leg D | Wrong or missing bearer token | Same shared secret as leg A |
| Salted CX returns 404 on leg D | Wrong accountId / conversationPid / region in the URL | Read all three from the event |
| Actions accepted but nothing visible | requestId sent out of band, or turn already expired | Omit 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.
| Type | Fires when |
|---|---|
ACTION | A named action fired — including an agent pressing a custom desktop button. The entry point for both use cases here. |
MESSAGE | A chat message was sent (participantType says by whom) |
EMAIL | An email arrived on the conversation (carries subject) |
QUESTION / QUESTION_DYNAMIC | A question was posed |
ANSWER | A question was answered (questionId / answerId) |
TEMPLATE | A WhatsApp template message was sent |
NOTE | An internal note was added |
FILE / IMAGE / AUDIO / VIDEO | Media was uploaded |
REVIEW | A CSAT / review event |
CONVERSATION_OFFERED | The conversation was offered to agents |
CONVERSATION_COMPLETE | The conversation finished |
CONVERSATION_UPDATE | Conversation attributes or custom changed |
CONVERSATION_INACTIVE | No activity for the configured period |
ENGAGEMENT_COMPLETE | One agent/bot engagement ended |
12.2 The ACTION trigger
| Field | Type | Meaning |
|---|---|---|
type | "ACTION" | Discriminant |
participantType | AGENT / EXTERNAL_AGENT / BOT / CUSTOMER / UNKNOWN | Who caused it — filter on the agent kinds |
time | ISO-8601 | When it fired |
action | string | The configured custom-action id — your switch key |
agent | string | null | Pid of the agent; resolve a name via engagements[] |
12.3 Action types
Outbound actions[].type values, with the fields these use cases need.
| Type | Key fields | Effect |
|---|---|---|
MESSAGE | content, attachments, channel?, contactPid? | Sends a message to the customer |
NOTE | content, responseTo? | Internal note on the timeline — invisible to the customer |
NEEDS_HELP | needsHelp, targetAgentPid?, timeout?, onTimeout? | Escalates to a human agent |
CONVERSATION_UPDATE | custom, desktop, urgency?, info?, attributes? | Writes conversation state and controls the toolbar buttons |
QUESTION | questionPid, allowCustomReply? | Poses a pre-configured question |
QUESTION_DYNAMIC | question: { externalId, content, answers[] } | Poses an ad-hoc question with answer options |
CUSTOMER_UPDATE | countryExternalId?, segmentExternalId?, … | Updates customer classification |
EMAIL | contactPid, subject, body, attachments? | Sends an email to an email contact |
SEND_FILE | path, name, mimeType | Sends a file to the customer |
INVITE_EXTERNAL_AGENT | email, name, subject, expires | Invites a partner into the conversation |
ENGAGEMENT_COMPLETE | engagementPid, outcome*?, reason*? | Closes one engagement with an outcome |
CONVERSATION_COMPLETE | — | Closes the conversation |
ENGAGE_YOUR_LOGIC | engageYourLogic | Turns the bot on/off for this conversation |
NO_ACTION | — | Explicit no-op — acknowledges the turn without doing anything |
12.4 Desktop button control (CONVERSATION_UPDATE.desktop)
| Field | Shape | Purpose |
|---|---|---|
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
| Purpose | Method + 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 card | POST https://slack.com/api/chat.postMessage |
| Edit an existing Slack message | POST https://slack.com/api/chat.update |
| Post an ephemeral (single-viewer) Slack message | POST https://slack.com/api/chat.postEphemeral |
12.6 Environment variables
| Name | Value | Source |
|---|---|---|
YOURLOGIC_SHARED_SECRET | Bearer token for legs A and D | You generate; give to Salted CX |
SLACK_BOT_TOKEN | xoxb-… | Slack → OAuth & Permissions |
SLACK_SIGNING_SECRET | Hex string | Slack → Basic Information |
SLACK_CHANNEL_ID | C0123ABCDEF — announcements | Slack → channel → About |
SLACK_APPROVAL_CHANNEL_ID | C0456DEFGHI — approvals | Slack → channel → About |
SALTED_ACTION_ANNOUNCE | slack.announce | Salted CX admin — custom action id |
SALTED_ACTION_APPROVAL | slack.approval | Salted CX admin — custom action id |
Further reading
- Slack Block Kit Builder — live preview
- Slack
chat.postMessage·chat.update