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. VerifySignature — is_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. CheckTimestamp — refuses a replay: a correctly signed request whose X-Slack-Request-Timestamp is more than 5 minutes from now (either way) → 401, as Slack recommends. The signature covers the timestamp, so it cannot be moved.
  4. LogCommand — inserts the command into slack_commands.
  5. Respond — returns an ephemeral Slack message.

Adapting to other providers​

is_valid_hmac works for any signed webhook — change the base string, prefix and encoding:

Providerbody (base string)prefixencodingsignature header
Slackv0:{timestamp}:{raw_body}v0=hexX-Slack-Signature
GitHub{raw_body}sha256=hexX-Hub-Signature-256
Stripe{timestamp}.{raw_body}v1=hexStripe-Signature
Shopify{raw_body}(none)base64X-Shopify-Hmac-Sha256

encoding defaults to hex. Shopify base64-encodes its digest, so a Shopify check must set encoding: base64 (engine 1.33.0+) — see the Shopify Webhooks pack.

Discord does not use HMAC at all: it signs with Ed25519, which needs the verify_signature assert instead. See the Discord Interactions pack.

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, refuses replays (a timestamp more than
# five minutes from now) — 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="

# Refuse replays. The signature covers the timestamp, so it cannot be moved, but a
# captured request stays validly signed forever: Slack's rule is to reject one whose
# X-Slack-Request-Timestamp is more than five minutes from now, either way.
- name: CheckTimestamp
run_when_succeeded: [VerifySignature]
input: a|headers|
hide_data_on_success: true
assert:
http_code_on_error: 401
error_message: "Stale Slack request: its timestamp is more than 5 minutes from now"
tests:
- value: x-slack-request-timestamp
is_after: "a|timestamp:s->date_sub('5m')|"
is_before: "a|timestamp:s->date_add('5m')|"

# 3. Log the command. Slack form fields land on the body; ->default('')
# keeps the insert valid even for fields a given command omits.
# Values are bound parameters, never pasted into the SQL text: text like
# "it's done" is stored as written, and nothing a user types can change
# the statement.
- name: LogCommand
run_when_succeeded: [CheckTimestamp]
database: main
query: |-
INSERT INTO slack_commands (id, command, text, user_name, channel_id, team_id)
VALUES ($1::text, $2::text, $3::text, $4::text, $5::text, $6::text)
RETURNING id
params:
- a|uuid|
- a|body::command->default('')|
- a|body::text->default('')|
- a|body::user_name->default('')|
- a|body::channel_id->default('')|
- a|body::team_id->default('')|
post_transforms:
- extract_value: "[0]"

# 3. Reply. response_type "ephemeral" shows the message only to the caller.
# Slack shows the response body as the reply, so it must be exactly the message:
# response_on_success sends this body on its own instead of the run's step-by-step
# result (which Slack would post as raw JSON).
- name: Respond
run_when_succeeded: [LogCommand]
response_on_success:
http_code: 200
body: |-
{
"response_type": "ephemeral",
"text": "✅ Received `a|body::command->default('')->json_escape|` — 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);