Slack Slash Command
Category: Security & Compliance
This page is generated from the Air Pipe marketplace. Browse it live to install into your organization.
A production-ready Slack slash command endpoint that verifies Slack's request
signature before doing any work, logs each command to Postgres, and replies with an
ephemeral message. The signature check (is_valid_hmac) is the same pattern you need
for any signed webhook (Slack, GitHub, Stripe, Shopify) — swap the base string and
prefix for the provider.
Why signature verification matters
Your slash-command URL is public. Without verification, anyone who finds it can forge
requests. Slack signs every request with your app's Signing Secret over the base
string v0:{timestamp}:{raw_body} and sends it as X-Slack-Signature: v0=<hex>. This
pack recomputes that HMAC-SHA256 and rejects anything that doesn't match — before
touching your database.
Endpoints
| Method | Route | Purpose |
|---|---|---|
| POST | /slack/command | Verify the Slack signature, log the command, reply. |
| POST | /api/seed | Create the slack_commands table (from seed.yml). |
Setup
- Create a Slack app at api.slack.com/apps → your app.
- Copy Basic Information → App Credentials → Signing Secret.
- Add a Slash Command (e.g.
/status) and set its Request URL to your deployed/slack/commandroute. - Set the managed variables:
SLACK_SIGNING_SECRET— the Signing Secret from step 2.DATABASE_URL— your Postgres connection string.
- Seed the table:
POST /api/seed.
Try it
Slack sends application/x-www-form-urlencoded. A genuine request carries a valid
X-Slack-Signature; an unsigned or tampered one is rejected with 401:
# Forged / unsigned request → 401 Invalid Slack signature
curl -i -X POST https://<your-host>/slack/command \
-H 'content-type: application/x-www-form-urlencoded' \
-H 'x-slack-request-timestamp: 1700000000' \
-H 'x-slack-signature: v0=deadbeef' \
--data 'command=/status&text=hello&user_name=alice'
Slack itself supplies the correct signature, so a real slash command from your workspace returns:
{ "response_type": "ephemeral", "text": "✅ Received `/status` — logged as <uuid>" }
How it works
- ReadHeaders — exposes the request headers so the base string can read the Slack timestamp (interpolation only sees prior actions' output).
- VerifySignature —
is_valid_hmacrecomputesHMAC-SHA256(SLACK_SIGNING_SECRET, "v0:{timestamp}:{raw_body}")and compares it constant-time againstX-Slack-Signature(stripping thev0=prefix). Mismatch →401. - LogCommand — inserts the command into
slack_commands. - Respond — returns an ephemeral Slack message.
Adapting to other providers
is_valid_hmac works for any signed webhook — change three fields:
| Provider | body (base string) | prefix | signature header |
|---|---|---|---|
| Slack | v0:{timestamp}:{raw_body} | v0= | X-Slack-Signature |
| GitHub | {raw_body} | sha256= | X-Hub-Signature-256 |
| Stripe | {timestamp}.{raw_body} | v1= | Stripe-Signature |
| Shopify | {raw_body} | (none) | X-Shopify-Hmac-Sha256 |
Configuration
config.yml
name: SlackSlashCommand
docs: true
# A verified Slack slash-command handler.
#
# Slack signs every request with your app's Signing Secret over the base string
# v0:{X-Slack-Request-Timestamp}:{raw_body}
# and sends the result in the X-Slack-Signature header as "v0=<hex>". This pack
# recomputes that HMAC-SHA256 with is_valid_hmac and rejects forged/unsigned
# requests (401) before doing any work — then logs the command and replies with
# an ephemeral message Slack shows only to the invoking user.
#
# Required managed variables:
# DATABASE_URL — Postgres connection string (event log)
# SLACK_SIGNING_SECRET — Basic Information → App Credentials → Signing Secret
global:
databases:
main:
driver: postgres
conn_string: "a|ap_var::DATABASE_URL|"
interfaces:
# POST /slack/command
# Point your slash command's Request URL here (Slack → Slash Commands).
# Slack posts application/x-www-form-urlencoded with command/text/user_name/etc.
slack/command:
output: http
method: POST
summary: Verify a Slack slash command's signature, log it, and reply
description: >
Verifies the X-Slack-Signature HMAC over the v0 base string, records the
command in Postgres, and returns an ephemeral Slack message.
tags: [slack, webhook, security]
actions:
# 1. Expose the request headers so the base string below can read the
# Slack timestamp (interpolation only sees PRIOR actions' output).
- name: ReadHeaders
input: a|headers|
hide_data_on_success: true
# 2. Reject anything not signed by Slack. is_valid_hmac recomputes
# HMAC-SHA256(signing_secret, "v0:{timestamp}:{raw_body}") and compares
# it constant-time to X-Slack-Signature (after stripping the "v0=" prefix).
- name: VerifySignature
input: a|headers|
run_when_succeeded: [ReadHeaders]
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="
# 3. Log the command. Slack form fields land on the body; ->default('')
# keeps the insert valid even for fields a given command omits.
- name: LogCommand
run_when_succeeded: [VerifySignature]
database: main
query: |-
INSERT INTO slack_commands (id, command, text, user_name, channel_id, team_id)
VALUES (
a|uuid->single_quote|,
a|body::command->default('')->single_quote|,
a|body::text->default('')->single_quote|,
a|body::user_name->default('')->single_quote|,
a|body::channel_id->default('')->single_quote|,
a|body::team_id->default('')->single_quote|
)
RETURNING id
post_transforms:
- extract_value: "[0]"
# 3. Reply. response_type "ephemeral" shows the message only to the caller.
- name: Respond
run_when_succeeded: [LogCommand]
json_output: |-
{
"response_type": "ephemeral",
"text": "✅ Received `a|body::command->default('')|` — logged as a|LogCommand::id|"
}
seed.yml
name: SlackSlashCommandSeed
description: Creates the slack_commands 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
# Creates the slack_commands table and index.
api/seed:
output: http
method: POST
actions:
- name: CreateTable
database: main
hide_data_on_success: true
query: |
CREATE TABLE IF NOT EXISTS slack_commands (
id TEXT PRIMARY KEY,
command TEXT NOT NULL,
text TEXT NOT NULL DEFAULT '',
user_name TEXT NOT NULL DEFAULT '',
channel_id TEXT NOT NULL DEFAULT '',
team_id TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
- name: CreateIndex
database: main
run_when_succeeded: [CreateTable]
hide_data_on_success: true
query: |
CREATE INDEX IF NOT EXISTS idx_slack_commands_created_at
ON slack_commands (created_at DESC);
- name: Respond
run_when_succeeded: [CreateIndex]
json_output: |-
{ "status": "seeded" }
schema.sql
-- Slack slash-command event log.
CREATE TABLE IF NOT EXISTS slack_commands (
id TEXT PRIMARY KEY,
command TEXT NOT NULL,
text TEXT NOT NULL DEFAULT '',
user_name TEXT NOT NULL DEFAULT '',
channel_id TEXT NOT NULL DEFAULT '',
team_id TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_slack_commands_created_at ON slack_commands (created_at DESC);