Skip to main content

Discord Interactions

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 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_hmac keyed 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

MethodRoutePurpose
POST/discord/interactionsVerify the Ed25519 signature, answer PING, log + reply to commands.
POST/api/seedCreate the discord_interactions table (from seed.yml).

Setup

  1. Create an application at discord.com/developers/applications.
  2. Copy General Information → Public Key (a hex string).
  3. Set the managed variables:
    • DISCORD_PUBLIC_KEY — the Public Key from step 2.
    • DATABASE_URL — your Postgres connection string.
  4. Seed the table: POST /api/seed.
  5. Set Interactions Endpoint URL to your deployed /discord/interactions route. 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.
  6. 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

  1. ReadHeaders — exposes the request headers so the signed base string can read the timestamp (interpolation only sees prior actions' output).
  2. VerifySignature — Ed25519-verifies {timestamp}{raw_body} against X-Signature-Ed25519 using DISCORD_PUBLIC_KEY. Mismatch → 401, and nothing downstream runs.
  3. Pong — if type == 1, response_on_success returns {"type": 1} immediately and skips the remaining actions, so handshakes are never written to the log.
  4. LogInteraction — inserts real interactions into discord_interactions.
  5. CountEvents / Respond — returns a type-4 (CHANNEL_MESSAGE_WITH_SOURCE) reply containing the live row count.

Interaction and response types

ValueMeaning
1Request: PING · Response: PONG
2Request: APPLICATION_COMMAND (a slash command)
3Request: MESSAGE_COMPONENT (button / select menu)
4Response: CHANNEL_MESSAGE_WITH_SOURCE (reply immediately)
5Response: 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

ProviderAlgorithmSigned messageAssert
DiscordEd25519{timestamp}{raw_body}verify_signature
SlackHMAC-256v0:{timestamp}:{raw_body}is_valid_hmac
GitHubHMAC-256{raw_body}is_valid_hmac
StripeHMAC-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);