Skip to main content

Slack Interactivity

Category: Productivity

Get this pack →

This page is generated from the Air Pipe marketplace. Browse it live to install into your organization.

Post an approval request to Slack with Approve and Reject buttons, then handle the click: verify it really came from Slack, record the decision, and replace the original message so the buttons disappear.

This is the second half of a Slack app. slack-slash-command covers the request side — someone types /deploy. This covers what happens when someone clicks something.

Why this needs its own pack

Slack's interactivity endpoint behaves differently from every other webhook you will wire up, in three ways that each break the obvious implementation:

1. The payload is JSON inside a form field. Slack does not post JSON here. It posts application/x-www-form-urlencoded with a single field named payload whose value is a JSON document. So a|body::payload| is a string, and a|body::payload.actions| resolves to nothing at all. It has to be parsed first:

- name: ParsePayload
input: a|body|
post_transforms:
- yaml_to_json:
key: interaction
json_path: $.payload

Everything downstream then reads a|ParsePayload::interaction.|<field>|.

2. The signature is over the raw body, not the parsed one. Slack signs v0:{timestamp}:{raw_body} — the urlencoded bytes, payload=%7B%22type%22... and all. a|raw_body| is the only thing that reproduces those bytes; verifying against the parsed form would never match.

3. You reply by POSTing to response_url, not by returning a body. Returning 200 only dismisses the spinner. To make the buttons disappear you POST the replacement message to the response_url carried in the payload, with "replace_original": true.

Endpoints

MethodRoutePurpose
POST/slack/approvalsRecord a pending approval and post it to Slack with two buttons
POST/slack/interactionsHandle a button click — point Slack's Request URL here
GET/slack/approvals/list?limit=25List approvals and their outcome

POST /slack/approvals

{ "title": "Deploy v2.1 to prod", "channel": "C0123456789", "requested_by": "alice" }

title and channel are required. Returns:

{ "approval_id": "019f99c2-0f72-7b01-9c81-810f9acba8ad", "posted": true }

The approval id is written into each button's value, and Slack echoes it back verbatim on click — that is the join key for the whole flow.

POST /slack/interactions

Slack posts this itself; you never call it by hand. It returns:

{ "approval_id": "019f…", "status": "approved", "changed": true, "updated": true }

status is the stored outcome, not the button just pressed, and changed says whether this click was the one that decided it — see Idempotence below.

Two failure modes this pack handles

Slack signals failure at HTTP 200. chat.postMessage returns 200 with {"ok": false, "error": "channel_not_found"} when it rejects your message — the same trap as GraphQL. An HTTP-status check alone reports a message that was never posted as posted. The CheckPosted assert tests body.ok explicitly and returns 502 instead.

A double-click must not error. response_url expires after 30 minutes and can fail, so the decision is recorded before Slack is contacted. The update filters on status = 'pending', which means a second click updates zero rows — and a bare UPDATE … RETURNING would then return an empty set, so extract_value: "[0]" would fail the action and the user would get an error for clicking twice. A CTE returns exactly one row either way:

WITH upd AS (
UPDATE slack_approvals SET status =, decided_by =, decided_at = NOW()
WHERE id =AND status = 'pending'
RETURNING id, title, status, decided_by
)
SELECT id, title, status, decided_by, true AS changed FROM upd
UNION ALL
SELECT id, title, status, decided_by, false AS changed
FROM slack_approvals WHERE id =AND NOT EXISTS (SELECT 1 FROM upd)

Required variables

VariableWhat to set it to
DATABASE_URLPostgres connection string
SLACK_SIGNING_SECRETSlack → Basic Information → App Credentials → Signing Secret
SLACK_BOT_TOKENSlack → OAuth & Permissions → Bot User OAuth Token (xoxb-…)

Slack app setup

  1. OAuth & Permissions → add the chat:write bot scope, install the app, copy the Bot User OAuth Token into SLACK_BOT_TOKEN.
  2. Interactivity & Shortcuts → turn Interactivity on, set the Request URL to your deployed /slack/interactions route.
  3. Basic Information → copy the Signing Secret into SLACK_SIGNING_SECRET.
  4. Invite the bot to the channel you will post to.

Database

schema.sql contains the DDL. One table:

  • slack_approvals — one row per request. status starts pending and moves to approved or rejected exactly once. message_ts is Slack's message timestamp, decided_by / decided_at record who resolved it.

POST /api/seed (in seed.yml) creates the table and its indexes; it is idempotent.

Usage example

# 1. Ask for approval
curl -X POST https://api.airpipe.io/<org>/production/slack/approvals \
-H 'Content-Type: application/json' \
-d '{"title":"Deploy v2.1 to prod","channel":"C0123456789","requested_by":"alice"}'

# 2. Someone clicks Approve in Slack — Slack calls /slack/interactions for you.

# 3. Check the outcome
curl 'https://api.airpipe.io/<org>/production/slack/approvals/list?limit=10'

Verified

Runtime-tested against a mock Slack upstream, 18/18: signature verification over the raw urlencoded body (and rejection of a forged one), JSON parsed out of the payload form field, ok:false-at-200 caught as a 502, replace_original delivered to response_url, double-click idempotence, and a title containing " quotes surviving ->json_escape through both the Block Kit post and the message update.

Configuration

config.yml

name: SlackInteractivity

docs: true

# Slack interactive components — Approve / Reject buttons that actually resolve.
#
# This is the SECOND half of a Slack app. `slack-slash-command` covers the
# request side; this covers what happens when someone CLICKS something.
#
# Three Slack-specific things this handles, none of which are obvious:
#
# 1. THE PAYLOAD IS JSON INSIDE A FORM FIELD. Slack does NOT post JSON to an
# interactivity endpoint. It posts application/x-www-form-urlencoded with a
# single field named `payload` whose VALUE is a JSON document. So
# `a|body::payload|` is a *string*, and `a|body::payload.actions|` resolves
# to nothing. It has to be parsed first — see ParsePayload.
#
# 2. SIGNATURE IS OVER THE RAW BODY, NOT THE PARSED ONE. Slack signs
# `v0:{timestamp}:{raw_body}` — the urlencoded bytes, `payload=%7B%22...`
# and all. `a|raw_body|` is the only thing that reproduces it.
#
# 3. YOU REPLY BY POSTING TO response_url, NOT BY RETURNING A BODY. Returning
# 200 just dismisses the spinner. To make the buttons disappear you POST
# the replacement message to the `response_url` inside the payload, with
# `replace_original: true`. That URL is single-use-ish and expires after
# 30 minutes, which is why the decision is recorded BEFORE the update is
# attempted — a failed Slack update must not lose the user's decision.
#
# Required managed variables:
# DATABASE_URL — Postgres connection string
# SLACK_SIGNING_SECRET — Basic Information → App Credentials → Signing Secret
# SLACK_BOT_TOKEN — Bot User OAuth Token (xoxb-…), for posting the prompt

global:
databases:
main:
driver: postgres
conn_string: "a|ap_var::DATABASE_URL|"

interfaces:

# POST /slack/approvals
# Body: { "title": "Deploy v2.1 to prod", "channel": "C0123456789", "requested_by": "alice" }
#
# Posts a message carrying two buttons. `value` on each button is the approval
# id, which is what comes back when the button is clicked — Slack echoes it
# verbatim in actions[0].value, so it is the join key for the whole flow.
slack/approvals:
output: http
method: POST
summary: Post an approval request to Slack with Approve/Reject buttons
description: >
Records a pending approval, then posts a Block Kit message with two
buttons whose value carries the approval id.
tags: [slack, approvals, interactivity, postgres]
request_example: |-
{ "title": "Deploy v2.1 to prod", "channel": "C0123456789", "requested_by": "alice" }
response_example: |-
{ "approval_id": "8d7f…", "posted": true }

actions:
- name: ValidateBody
input: a|body|
hide_data_on_success: true
assert:
http_code_on_error: 400
error_message: "title and channel are required"
tests:
- value: title
is_not_null: true
is_not_empty: true
- value: channel
is_not_null: true
is_not_empty: true

# Insert BEFORE posting: if Slack is down we still have a durable record,
# and the button value has to exist before the message can carry it.
- name: CreateApproval
run_when_succeeded: [ValidateBody]
database: main
query: |-
INSERT INTO slack_approvals (id, title, requested_by, channel, status)
VALUES (
a|uuid->single_quote|,
a|ValidateBody::title->single_quote|,
a|ValidateBody::requested_by->default('')->single_quote|,
a|ValidateBody::channel->single_quote|,
'pending'
)
RETURNING id
post_transforms:
- extract_value: "[0]"

# Block Kit. `action_id` identifies WHICH button; `value` carries our id.
# ->json_escape on the title so a quote or newline in it can't break the
# JSON body.
- name: PostMessage
run_when_succeeded: [CreateApproval]
http:
url: "https://slack.com/api/chat.postMessage"
method: POST
bearer_auth: a|ap_var::SLACK_BOT_TOKEN|
headers:
content-type: "application/json; charset=utf-8"
body: |-
{
"channel": "a|ValidateBody::channel->json_escape|",
"text": "Approval requested: a|ValidateBody::title->json_escape|",
"blocks": [
{
"type": "section",
"text": { "type": "mrkdwn", "text": "*Approval requested*\na|ValidateBody::title->json_escape|" }
},
{
"type": "actions",
"block_id": "approval_actions",
"elements": [
{
"type": "button",
"action_id": "approve",
"style": "primary",
"text": { "type": "plain_text", "text": "Approve" },
"value": "a|CreateApproval::id|"
},
{
"type": "button",
"action_id": "reject",
"style": "danger",
"text": { "type": "plain_text", "text": "Reject" },
"value": "a|CreateApproval::id|"
}
]
}
]
}

# Slack's Web API returns HTTP 200 with {"ok": false, "error": "..."} on
# failure — the same trap as GraphQL. An HTTP-status check alone would
# report a message that was never posted as posted.
- name: CheckPosted
run_when_succeeded: [PostMessage]
input: a|PostMessage|
hide_data_on_success: true
assert:
http_code_on_error: 502
error_message: "Slack rejected the message"
tests:
- value: body.ok
is_equal_to: true

- name: RecordMessage
run_when_succeeded: [CheckPosted]
database: main
query: |-
UPDATE slack_approvals
SET message_ts = a|PostMessage::body.ts->default('')->single_quote|
WHERE id = a|CreateApproval::id->single_quote|
RETURNING id
post_transforms:
- extract_value: "[0]"

- name: Respond
run_when_succeeded: [RecordMessage]
json_output: |-
{ "approval_id": "a|CreateApproval::id|", "posted": true }

# POST /slack/interactions
# Point Slack → Interactivity & Shortcuts → Request URL here.
#
# Slack posts application/x-www-form-urlencoded with ONE field, `payload`,
# containing JSON. Everything below hangs off parsing that field.
slack/interactions:
output: http
method: POST
summary: Handle an Approve/Reject button click and update the original message
description: >
Verifies the Slack signature over the raw body, parses the JSON payload out
of the urlencoded `payload` field, records the decision, and replaces the
original message via response_url so the buttons disappear.
tags: [slack, interactivity, webhook, security, postgres]

actions:
# Headers must be read as their own action: interpolation only sees the
# output of PRIOR actions, and the base string below needs the timestamp.
- name: ReadHeaders
input: a|headers|
hide_data_on_success: true

# Signature is over the RAW urlencoded body. Parsing it first would change
# the bytes and the HMAC would never match.
- name: VerifySignature
run_when_succeeded: [ReadHeaders]
input: a|headers|
hide_data_on_success: true
assert:
http_code_on_error: 401
error_message: "Invalid Slack signature"
tests:
- value: x-slack-signature
is_not_null: true
is_valid_hmac:
secret: a|ap_var::SLACK_SIGNING_SECRET|
body: "v0:a|ReadHeaders::x-slack-request-timestamp|:a|raw_body|"
algorithm: sha256
prefix: "v0="

# THE STEP THAT MAKES THIS PACK DIFFERENT.
# `a|body::payload|` is a JSON *string*. yaml_to_json parses it (YAML is a
# superset of JSON) and writes the result to a new `interaction` key, so
# everything downstream reads a|ParsePayload::interaction.|<field>|.
- name: ParsePayload
run_when_succeeded: [VerifySignature]
input: a|body|
post_transforms:
- yaml_to_json:
key: interaction
json_path: $.payload

# actions[0] is the button that was clicked: action_id says which one,
# value carries the approval id we set when posting.
- name: ReadAction
run_when_succeeded: [ParsePayload]
database: main
query: |-
SELECT $1::text AS approval_id,
$2::text AS action_id,
$3::text AS response_url,
$4::text AS slack_user,
CASE WHEN $2::text = 'approve' THEN 'approved' ELSE 'rejected' END AS decision
params:
- a|ParsePayload::interaction.actions|[0].value->default(null)|
- a|ParsePayload::interaction.actions|[0].action_id->default(null)|
- a|ParsePayload::interaction.response_url->default(null)|
- a|ParsePayload::interaction.user.username->default('')|
post_transforms:
- extract_value: "[0]"

- name: ValidateAction
run_when_succeeded: [ReadAction]
input: a|ReadAction|
hide_data_on_success: true
assert:
http_code_on_error: 400
error_message: "payload did not carry a button action"
tests:
- value: approval_id
is_not_null: true
is_not_empty: true
- value: response_url
is_not_null: true
is_not_empty: true

# Record BEFORE talking to Slack. response_url expires (30 min) and can
# fail; the decision must survive that.
#
# IDEMPOTENCE. `AND status = 'pending'` means a second click updates zero
# rows — and a bare UPDATE…RETURNING would then return an empty set, so
# `extract_value: "[0]"` would fail the action and a double-click would
# error instead of being a no-op. The CTE makes the query return exactly
# one row either way: the freshly-decided row, or the already-decided row
# when this click changed nothing. `changed` says which happened.
- name: RecordDecision
run_when_succeeded: [ValidateAction]
database: main
query: |-
WITH upd AS (
UPDATE slack_approvals
SET status = a|ReadAction::decision->single_quote|,
decided_by = a|ReadAction::slack_user->single_quote|,
decided_at = NOW()
WHERE id = a|ReadAction::approval_id->single_quote|
AND status = 'pending'
RETURNING id, title, status, decided_by
)
SELECT id, title, status, decided_by, true AS changed FROM upd
UNION ALL
SELECT id, title, status, decided_by, false AS changed
FROM slack_approvals
WHERE id = a|ReadAction::approval_id->single_quote|
AND NOT EXISTS (SELECT 1 FROM upd)
post_transforms:
- extract_value: "[0]"

# Replace the original message so the buttons are gone. response_url takes
# a plain JSON POST and needs no auth — the URL itself is the credential,
# which is also why it must never be logged.
- name: UpdateMessage
run_when_succeeded: [RecordDecision]
http:
url: a|ReadAction::response_url|
method: POST
headers:
content-type: application/json
body: |-
{
"replace_original": true,
"text": "a|RecordDecision::title->default('Request')->json_escape| — a|RecordDecision::status| by a|RecordDecision::decided_by->json_escape|",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*a|RecordDecision::title->default('Request')->json_escape|*\na|RecordDecision::status| by a|RecordDecision::decided_by->json_escape|"
}
}
]
}

# `status` is the STORED outcome, not the button just pressed, so a
# double-click reports the original decision rather than overwriting it.
# `changed: false` tells the caller this click was a no-op.
- name: Respond
run_when_succeeded: [UpdateMessage]
json_output: |-
{
"approval_id": "a|ReadAction::approval_id|",
"status": "a|RecordDecision::status|",
"changed": a|RecordDecision::changed|,
"updated": true
}

# GET /slack/approvals?limit=25
slack/approvals/list:
output: http
method: GET
summary: List approvals and their outcome
tags: [slack, postgres]

actions:
- name: ReadParams
input: a|params|
hide_data_on_success: true

- name: ListApprovals
run_when_succeeded: [ReadParams]
database: main
query: |-
SELECT id, title, requested_by, status, decided_by, decided_at, created_at
FROM slack_approvals
ORDER BY created_at DESC
LIMIT a|ReadParams::limit->default(25)|

seed.yml

name: SlackInteractivitySeed
description: Creates the slack_approvals table (idempotent). Safe to re-run — starts empty.

docs: true

# Required managed variables:
# DATABASE_URL — Postgres connection string

global:
databases:
main:
driver: postgres
conn_string: "a|ap_var::DATABASE_URL|"

interfaces:

# POST /api/seed
api/seed:
output: http
method: POST

actions:
- name: CreateTable
database: main
hide_data_on_success: true
query: |
CREATE TABLE IF NOT EXISTS slack_approvals (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
requested_by TEXT NOT NULL DEFAULT '',
channel TEXT NOT NULL,
message_ts TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
decided_by TEXT NOT NULL DEFAULT '',
decided_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

- name: CreateIndexCreated
database: main
run_when_succeeded: [CreateTable]
hide_data_on_success: true
query: |
CREATE INDEX IF NOT EXISTS idx_slack_approvals_created_at
ON slack_approvals (created_at DESC);

- name: CreateIndexStatus
database: main
run_when_succeeded: [CreateIndexCreated]
hide_data_on_success: true
query: |
CREATE INDEX IF NOT EXISTS idx_slack_approvals_status
ON slack_approvals (status);

- name: Respond
run_when_succeeded: [CreateIndexStatus]
json_output: |-
{ "status": "seeded" }

schema.sql

-- Slack interactive approvals: one row per request, updated in place when a
-- button is clicked. `status` starts 'pending' and moves to 'approved' or
-- 'rejected' exactly once (the UPDATE filters on status = 'pending', which is
-- what makes a double-click idempotent).
CREATE TABLE IF NOT EXISTS slack_approvals (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
requested_by TEXT NOT NULL DEFAULT '',
channel TEXT NOT NULL,
message_ts TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
decided_by TEXT NOT NULL DEFAULT '',
decided_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_slack_approvals_created_at ON slack_approvals (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_slack_approvals_status ON slack_approvals (status);