Slack Interactivity
Category: Productivity
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
| Method | Route | Purpose |
|---|---|---|
POST | /slack/approvals | Record a pending approval and post it to Slack with two buttons |
POST | /slack/interactions | Handle a button click — point Slack's Request URL here |
GET | /slack/approvals/list?limit=25 | List 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
| Variable | What to set it to |
|---|---|
DATABASE_URL | Postgres connection string |
SLACK_SIGNING_SECRET | Slack → Basic Information → App Credentials → Signing Secret |
SLACK_BOT_TOKEN | Slack → OAuth & Permissions → Bot User OAuth Token (xoxb-…) |
Slack app setup
- OAuth & Permissions → add the
chat:writebot scope, install the app, copy the Bot User OAuth Token intoSLACK_BOT_TOKEN. - Interactivity & Shortcuts → turn Interactivity on, set the Request URL to your
deployed
/slack/interactionsroute. - Basic Information → copy the Signing Secret into
SLACK_SIGNING_SECRET. - Invite the bot to the channel you will post to.
Database
schema.sql contains the DDL. One table:
slack_approvals— one row per request.statusstartspendingand moves toapprovedorrejectedexactly once.message_tsis Slack's message timestamp,decided_by/decided_atrecord 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);