Skip to main content

Slack Slash Command

Category: Security & Compliance

Get this pack →

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

MethodRoutePurpose
POST/slack/commandVerify the Slack signature, log the command, reply.
POST/api/seedCreate the slack_commands table (from seed.yml).

Setup

  1. Create a Slack app at api.slack.com/apps → your app.
  2. Copy Basic Information → App Credentials → Signing Secret.
  3. Add a Slash Command (e.g. /status) and set its Request URL to your deployed /slack/command route.
  4. Set the managed variables:
    • SLACK_SIGNING_SECRET — the Signing Secret from step 2.
    • DATABASE_URL — your Postgres connection string.
  5. 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

  1. ReadHeaders — exposes the request headers so the base string can read the Slack timestamp (interpolation only sees prior actions' output).
  2. VerifySignatureis_valid_hmac recomputes HMAC-SHA256(SLACK_SIGNING_SECRET, "v0:{timestamp}:{raw_body}") and compares it constant-time against X-Slack-Signature (stripping the v0= prefix). Mismatch → 401.
  3. LogCommand — inserts the command into slack_commands.
  4. Respond — returns an ephemeral Slack message.

Adapting to other providers

is_valid_hmac works for any signed webhook — change three fields:

Providerbody (base string)prefixsignature header
Slackv0:{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);