Discord Interactions
Category: Security & Compliance
This page is generated from the Air Pipe marketplace. Browse it live to install into your organization.
A production-ready Discord Interactions endpoint (slash commands, buttons, modals) that verifies Discord's Ed25519 request signature before doing any work, answers the PING handshake, logs each interaction to Postgres, and replies in-channel.
This is the counterpart to the HMAC signature packs: Discord is one of the few major
providers that signs with an asymmetric algorithm, so it needs verify_signature
rather than is_valid_hmac.
Ed25519 is not HMAC — this trips people up
Discord signs each request with your application's private Ed25519 key. You verify it with the Public Key from the developer portal.
- The Public Key is genuinely public. It can verify a signature; it can never create one. It is not a shared secret.
- Verifying with
is_valid_hmackeyed on the Public Key is wrong twice over: it rejects every genuine Discord request (the algorithms differ), and if it ever did match, anyone who read your public key could forge requests.
Air Pipe's verify_signature assert does the real Ed25519 check:
- custom_value: "a|ReadHeaders::x-signature-timestamp|a|raw_body|" # the signed message
verify_signature:
public_key: "a|ap_var::DISCORD_PUBLIC_KEY|" # hex
signature: "a|ReadHeaders::x-signature-ed25519|" # hex
custom_value builds the exact byte string Discord signed — {timestamp}{raw_body} —
because the signed message is the target of the assert, and the signature is supplied
alongside it.
Endpoints
| Method | Route | Purpose |
|---|---|---|
| POST | /discord/interactions | Verify the Ed25519 signature, answer PING, log + reply to commands. |
| POST | /api/seed | Create the discord_interactions table (from seed.yml). |
Setup
- Create an application at discord.com/developers/applications.
- Copy General Information → Public Key (a hex string).
- Set the managed variables:
DISCORD_PUBLIC_KEY— the Public Key from step 2.DATABASE_URL— your Postgres connection string.
- Seed the table:
POST /api/seed. - Set Interactions Endpoint URL to your deployed
/discord/interactionsroute. Discord immediately sends a signed PING and will only save the URL if it gets a valid PONG back — so saving the URL successfully is the proof your signature verification works. - Register a slash command (e.g.
/stats) for your application.
Try it
An unsigned or tampered request is rejected before any database work happens:
# Forged / unsigned request → 401 invalid request signature
curl -i -X POST https://<your-host>/discord/interactions \
-H 'content-type: application/json' \
-H 'x-signature-timestamp: 1700000000' \
-H 'x-signature-ed25519: deadbeef' \
--data '{"type":2,"data":{"name":"stats"}}'
A genuine, correctly signed /stats command returns a Discord type-4 reply:
{
"type": 4,
"data": { "content": "12 interactions recorded — latest /stats from kav" }
}
And the PING handshake returns the PONG Discord requires:
{ "type": 1 }
How it works
- ReadHeaders — exposes the request headers so the signed base string can read the timestamp (interpolation only sees prior actions' output).
- VerifySignature — Ed25519-verifies
{timestamp}{raw_body}againstX-Signature-Ed25519usingDISCORD_PUBLIC_KEY. Mismatch →401, and nothing downstream runs. - Pong — if
type == 1,response_on_successreturns{"type": 1}immediately and skips the remaining actions, so handshakes are never written to the log. - LogInteraction — inserts real interactions into
discord_interactions. - CountEvents / Respond — returns a type-4 (
CHANNEL_MESSAGE_WITH_SOURCE) reply containing the live row count.
Interaction and response types
| Value | Meaning |
|---|---|
1 | Request: PING · Response: PONG |
2 | Request: APPLICATION_COMMAND (a slash command) |
3 | Request: MESSAGE_COMPONENT (button / select menu) |
4 | Response: CHANNEL_MESSAGE_WITH_SOURCE (reply immediately) |
5 | Response: DEFERRED_CHANNEL_MESSAGE (reply within 15 minutes) |
Discord requires a response within 3 seconds; use type 5 if the work behind a
command takes longer.
Signature verification across providers
| Provider | Algorithm | Signed message | Assert |
|---|---|---|---|
| Discord | Ed25519 | {timestamp}{raw_body} | verify_signature |
| Slack | HMAC-256 | v0:{timestamp}:{raw_body} | is_valid_hmac |
| GitHub | HMAC-256 | {raw_body} | is_valid_hmac |
| Stripe | HMAC-256 | {timestamp}.{raw_body} | is_valid_hmac |
Configuration
config.yml
name: DiscordInteractions
docs: true
# A verified Discord Interactions (slash-command) endpoint.
#
# Discord signs every interaction with your application's Ed25519 key:
# X-Signature-Ed25519 — hex-encoded signature
# X-Signature-Timestamp — timestamp that is signed together with the body
# The signed message is the concatenation {timestamp}{raw_body}. This pack
# verifies it with the `verify_signature` assert (Ed25519, asymmetric) and
# rejects forged or unsigned requests with 401 before doing any work.
#
# NOTE: Discord uses Ed25519, NOT HMAC. The Public Key shown in the Discord
# developer portal is genuinely public — it can only *verify* a signature, it
# can never *create* one, so it is not a shared secret. An HMAC check keyed on
# it would both reject every real Discord request and be insecure if it didn't.
#
# Discord requires the PING (type 1) handshake to be answered with a PONG
# (type 1) before it will accept the Interactions Endpoint URL.
#
# Required managed variables:
# DATABASE_URL — Postgres connection string (interaction log + stats)
# DISCORD_PUBLIC_KEY — Application → General Information → Public Key (hex)
global:
databases:
main:
driver: postgres
conn_string: "a|ap_var::DATABASE_URL|"
interfaces:
# POST /discord/interactions
# Set this as your app's "Interactions Endpoint URL" in the Discord portal.
discord/interactions:
output: http
method: POST
summary: Verify a Discord interaction's Ed25519 signature and respond
description: >
Verifies the X-Signature-Ed25519 signature over {timestamp}{raw_body} with
the application's Ed25519 public key, answers the PING handshake with a
PONG, and answers a slash command with a live count from Postgres.
tags: [discord, webhook, security, bot]
request_example: |-
{ "type": 2, "data": { "name": "stats" }, "member": { "user": { "username": "kav" } } }
response_example: |-
{ "type": 4, "data": { "content": "12 interactions recorded" } }
actions:
# 1. Expose the request headers so the signed base string below can read
# the timestamp — interpolation only sees PRIOR actions' output.
- name: ReadHeaders
input: a|headers|
hide_data_on_success: true
# 2. Reject anything not signed by Discord.
# `custom_value` builds the exact message Discord signed
# ({timestamp}{raw_body}); `verify_signature` checks the Ed25519
# signature from the header against the application public key.
# Both public_key and signature are hex-encoded.
- name: VerifySignature
input: a|headers|
run_when_succeeded: [ReadHeaders]
hide_data_on_success: true
assert:
http_code_on_error: 401
error_message: "invalid request signature"
tests:
- custom_value: "a|ReadHeaders::x-signature-timestamp|a|raw_body|"
verify_signature:
public_key: "a|ap_var::DISCORD_PUBLIC_KEY|"
signature: "a|ReadHeaders::x-signature-ed25519|"
# 3. PING handshake. Discord sends type 1 when you save the endpoint URL
# and periodically after; it must be answered with type 1.
# `response_on_success` returns immediately — the remaining actions
# are skipped, so a PING is never written to the log.
- name: Pong
run_when_succeeded: [VerifySignature]
run_on_assertion:
tests:
- custom_value: "a|body::type|"
is_equal_to: "1"
response_on_success:
http_code: 200
body: |-
{ "type": 1 }
# 4. Log the interaction (a real command, not the PING handshake).
- name: LogInteraction
run_when_succeeded: [VerifySignature]
run_on_assertion:
tests:
- custom_value: "a|body::type|"
is_not_equal_to: "1"
database: main
query: |-
INSERT INTO discord_interactions (id, interaction_type, command_name, user_name, guild_id, channel_id)
VALUES (
a|uuid->single_quote|,
a|body::type->default(2)|,
a|body::data.name->default('')->single_quote|,
a|body::member.user.username->default('')->single_quote|,
a|body::guild_id->default('')->single_quote|,
a|body::channel_id->default('')->single_quote|
)
RETURNING id
post_transforms:
- extract_value: "[0]"
# 5. /stats — count what this pack has recorded.
- name: CountEvents
run_when_succeeded: [LogInteraction]
database: main
query: "SELECT COUNT(*) AS total FROM discord_interactions"
post_transforms:
- extract_value: "[0]"
# 6. Reply. Type 4 = CHANNEL_MESSAGE_WITH_SOURCE (an immediate reply).
# `response_on_success` pins the exact response body. Discord only
# accepts a bare interaction object, and the PING branch above leaves a
# skipped-assertion marker on `Pong` that would otherwise make the
# engine return its full per-action envelope instead.
- name: Respond
run_when_succeeded: [CountEvents]
response_on_success:
http_code: 200
body: |-
{
"type": 4,
"data": {
"content": "a|CountEvents::total| interactions recorded — latest /a|body::data.name->default('unknown')| from a|body::member.user.username->default('unknown')|"
}
}
seed.yml
name: DiscordInteractionsSeed
description: Creates the discord_interactions 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 discord_interactions 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 discord_interactions (
id TEXT PRIMARY KEY,
interaction_type INTEGER NOT NULL,
command_name TEXT NOT NULL DEFAULT '',
user_name TEXT NOT NULL DEFAULT '',
guild_id TEXT NOT NULL DEFAULT '',
channel_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_discord_interactions_created_at
ON discord_interactions (created_at DESC);
- name: Reset
database: main
run_when_succeeded: [CreateIndex]
hide_data_on_success: true
query: "TRUNCATE TABLE discord_interactions;"
- name: Respond
run_when_succeeded: [Reset]
json_output: |-
{ "status": "seeded" }
schema.sql
-- Discord interaction log.
CREATE TABLE IF NOT EXISTS discord_interactions (
id TEXT PRIMARY KEY,
interaction_type INTEGER NOT NULL,
command_name TEXT NOT NULL DEFAULT '',
user_name TEXT NOT NULL DEFAULT '',
guild_id TEXT NOT NULL DEFAULT '',
channel_id TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_discord_interactions_created_at
ON discord_interactions (created_at DESC);