We ran the same workload through 42 models via AI Gateway and compared costs
/Neon Functions/Discord bot

How to host a Discord bot on Neon Functions

new

Receive slash commands at a public function URL with no Gateway connection

Beta

The Neon Functions is in Beta. Share your feedback on Discord or via the Neon Console.

Discord can deliver slash commands over HTTP. There's no Gateway connection and no discord.js client. Discord POSTs each interaction to the Interactions Endpoint URL on your app. This guide uses a Neon Function as that URL.

The template verifies Discord's request signatures and answers the PING/PONG handshake. Discord's Interactions Overview is optional background if you want the crypto details.

Not for Gateway bots

Neon Functions are not the right primitive for a Discord Gateway bot yet. Gateway bots need a long-lived, stateful process with session resume, presence and sharding. This guide is HTTP only: Discord POSTs slash commands to your function URL.

Prerequisites

  • A Neon project in AWS US East (Ohio) (aws-us-east-2) or AWS Europe (Frankfurt) (aws-eu-central-1). Support is expanding toward all regions. See Get started with Neon Functions.
  • The latest Neon CLI, installed and authenticated. Upgrade with npm install -g neon@latest, then see CLI auth.
  • Node.js 24 (node -v). Deployed functions run on nodejs24, so 24 locally is the closest match. Node.js 20+ works.
  • A Discord account.
  • A Discord server you own. Create one if you need to. You often can't add a bot to someone else's server.

This guide uses npm commands, matching the template.

Create a Discord application

Open the Discord Developer Portal and click New Application. Give it a name (for example Neon HTTP bot), accept the Developer Terms, then click Create.

Discord Developer Portal New Application modal

On General Information, copy the Application ID and Public Key. Stash them somewhere local. They go into .env.local after you scaffold the project.

Discord General Information showing Application ID and Public Key

Open the Bot tab and click Reset Token. Copy the token. Discord shows it once. Resetting invalidates any previous token, so if this app already had one, update .env.local after you create it.

Discord Bot tab showing the Reset Token button

warning

Treat the bot token like a password. Anyone who has it can act as your bot. Never commit it or paste it into screenshots, tickets or chat.

The public key is what the function uses to verify that a POST really came from Discord. The application ID and bot token are what npm run register:commands uses to declare slash commands. /help uses the token at request time for command mentions.

Invite the bot to a test server

Create a Discord server you own if you don't already have one.

This walkthrough uses OAuth2URL Generator, not the Installation tab.

Under Scopes, check bot and applications.commands.

Discord OAuth2 URL Generator with bot and applications.commands scopes

Under Bot Permissions, check Send Messages and Use Slash Commands.

Discord OAuth2 URL Generator with Send Messages and Use Slash Commands

The screenshots are cropped to the checkboxes. Copy the generated URL at the bottom of URL Generator, below Bot Permissions. Open it, pick your test server, click Continue, then Authorize.

Slash commands show up after you register them.

Scaffold the project

neon bootstrap copies the HTTP example into an empty directory and prompts you to install dependencies and set up the project. Accept the prompts. Pass --no-link to skip linking for now; you'll link in the next step, after creating .env.local, so Neon writes its variables straight into that file. The template id is discord-bot-http:

neon bootstrap my-discord-bot --template discord-bot-http --no-link
cd my-discord-bot

See neon bootstrap for flags. Later commands in this guide assume you're in that directory.

To start from the source instead, copy bots/discord-bot-http from the examples repo, cd into it, then install dependencies and neon link.

neon.ts declares the discord function, the Discord env vars and a local dev port:

neon.ts
import { defineConfig } from "@neon/config/v1";

export default defineConfig({
  preview: {
    functions: {
      discord: {
        name: "Discord interactions",
        source: "./functions/discord.ts",
        env: {
          DISCORD_PUBLIC_KEY: process.env.DISCORD_PUBLIC_KEY!,
          DISCORD_APPLICATION_ID: process.env.DISCORD_APPLICATION_ID!,
          DISCORD_BOT_TOKEN: process.env.DISCORD_BOT_TOKEN!,
          ...(process.env.DISCORD_GUILD_ID ? { DISCORD_GUILD_ID: process.env.DISCORD_GUILD_ID } : {}),
        },
        dev: {
          port: 8787,
        },
      },
    },
  },
});

The key discord is the function slug. It's permanent after the first deploy and appears in CLI commands and the invocation URL. See the neon.ts reference.

This walkthrough is deploy-only. Discord needs a public HTTPS URL, so we don't use npm run dev here. The scaffold still sets dev.port to 8787 to match the template.

Add Discord secrets

Template scripts read .env.local (for example neon deploy --env .env.local and node --env-file=.env.local), the same convention as Next.js and vercel env pull. Bootstrap scaffolds a .env.example but no .env.local yet. Copy the example first, then link so Neon merges the branch's variables into it:

cp .env.example .env.local
neon link

Linking merges DATABASE_URL, DATABASE_URL_UNPOOLED, NEON_BRANCH, and NEON_FUNCTION_DISCORD_BASE_URL (your function's public URL, ready before you deploy) into .env.local, leaving the Discord keys untouched. Uncomment and fill the three required DISCORD_* keys:

.env.local
# Required. Add real values before deploying; a missing key throws at deploy, an empty one uploads "".
# DISCORD_PUBLIC_KEY=
# DISCORD_APPLICATION_ID=
# DISCORD_BOT_TOKEN=

# Optional: your test server's ID for fast guild commands; blank registers global commands.
DISCORD_GUILD_ID=

# Written into `.env.local` by `neon link`.
NEON_BRANCH=
DATABASE_URL=
DATABASE_URL_UNPOOLED=
NEON_FUNCTION_DISCORD_BASE_URL=

For this guide, also set DISCORD_GUILD_ID to your test server. In Discord, turn on Developer Mode (User Settings → App Settings → Advanced), right-click the server name in the sidebar and click Copy Server ID. A guild is a Discord server.

Leave the pulled NEON_* and DATABASE_URL* values as written. See Environment variables.

If you omit DISCORD_GUILD_ID, the register script creates global commands, which can take up to an hour to show up. Skip that until you want a production-wide install.

Deploy the function

If you used neon bootstrap and let it install dependencies, skip that step. If you cloned by hand, run npm install first.

npm run deploy runs neon deploy --env .env.local, which evaluates neon.ts with your Discord secrets in process.env:

npm run deploy

The CLI applies the neon.ts policy, bundles the function and waits until that apply finishes. You'll see Applied changes and a Function URLs list. If the command fails, check function logs. Flags are in Deploy and manage functions.

Deployed env is a snapshot of .env.local at apply time. Run npm run deploy again after any change to .env.local.

Set the Interactions Endpoint URL

Your interactions endpoint is NEON_FUNCTION_DISCORD_BASE_URL (from .env.local) with /api/interactions appended:

https://br-cool-darkness-123456-discord.compute.us-east-2.aws.neon.tech/api/interactions

Your URL will differ. Use the exact NEON_FUNCTION_DISCORD_BASE_URL value from .env.local, with /api/interactions appended; the host is specific to your branch.

In the Developer Portal, open General Information. Paste that URL into Interactions Endpoint URL and save.

Discord immediately POSTs a PING (type: 1). The handler verifies the signature and returns a PONG. If that handshake succeeds, Discord saves the URL (the Developer Portal shows a green success message).

If save fails, retry once. Confirm the public key in .env.local matches General Information, you redeployed after editing .env.local and the path ends with /api/interactions. Then open or curl the function URL (NEON_FUNCTION_DISCORD_BASE_URL, or that URL plus /api/interactions). GET returns JSON with these keys:

{
  "ok": true,
  "service": "discord-interactions",
  "interactionsPath": "/api/interactions",
  "interactionsUrl": "https://br-cool-darkness-123456-discord.compute.us-east-2.aws.neon.tech/api/interactions"
}

ok: true proves the isolate is up, and interactionsUrl echoes the public endpoint you paste into Discord, so it should match the URL you saved. GET does not use the public key, so a wrong key or a stale deploy still fails Discord's PING. Check function logs; signature failures show up there. Fix .env.local and redeploy. The first PING can miss a cold start.

Register slash commands

Discord doesn't read commands from your handler. Register them once with the Discord API:

npm run register:commands

That compiles scripts/registerCommands.ts and calls Discord's HTTP API with your bot token. It runs locally. It isn't part of the Neon Function. On success it prints the registered command list as JSON.

The hosted function still needs DISCORD_BOT_TOKEN, DISCORD_APPLICATION_ID and DISCORD_PUBLIC_KEY in the deployed env. /help uses the token at request time for command mentions. The public key is for request verification.

If it throws, check that .env.local exists and the token is set (Discord returns 401 Unauthorized for a bad token). If the bot isn't in the target server, Discord returns 403 Missing Access; invite it first (see Invite the bot to a test server). A wrong DISCORD_GUILD_ID returns 404 Unknown Guild.

With DISCORD_GUILD_ID set, the script registers guild commands on that server.

The template registers /ping, /info, /help, /buttons, /name and /profile. Each command has an optional ephemeral boolean in Discord's / picker when you invoke it. Set it so only you see the reply. It isn't an env var or a code edit.

Restart the Discord client after registering so the / picker picks up the new commands.

Try /ping

In your test server, type / and pick ping from Discord's command picker, then send it. The bot should reply with a Pong embed and an estimated interaction latency.

HTTP-only bots stay offline in the member list. That's expected. They still handle slash commands.

You must send Discord an initial response within about 3 seconds, or Discord invalidates the interaction token. This template answers in the request.

If nothing appears, or Discord says "The application did not respond":

  • The bot is in the server with the applications.commands scope.
  • Discord saved the Interactions Endpoint URL after a successful PING/PONG.
  • npm run deploy finished (redeploy after any .env.local change).
  • npm run register:commands printed JSON (then you restarted Discord).
  • DISCORD_GUILD_ID is this server.
  • Check function logs for a failed PING or a slow cold start. Try /ping again once the branch is warm.

How it works

functions/discord.ts default-exports async function handler(request: Request) and returns a Response. GET returns { ok, service, interactionsPath, interactionsUrl }. POST reads the raw body, verifies the Discord signature, then dispatches on interaction type.

Verify the raw body

Call request.text() and verify before JSON.parse. Discord signs timestamp + body as the exact bytes it sent. Parsing JSON first can change whitespace and fail verification. See Interactions Overview.

The GET and POST PING path looks like this. ApplicationCommand and MessageComponent dispatch is in the source.

functions/discord.ts
import { InteractionResponseType, InteractionType } from "discord-api-types/v10";
import { DISCORD_INTERACTIONS_PATH } from "../src/constants/discord.js";
import { getDiscordEnv } from "../src/env.js";
import { discordInteractionSchema } from "../src/schemas/discord.js";
import { jsonResponse } from "../src/utils/jsonResponse.js";
import { verifyDiscordRequest } from "../src/utils/verifyDiscordRequest.js";

export default async function handler(request: Request): Promise<Response> {
  const url = new URL(request.url);

  switch (request.method) {
    case "GET":
      return jsonResponse({
        ok: true,
        service: "discord-interactions",
        interactionsPath: DISCORD_INTERACTIONS_PATH,
        interactionsUrl: `${url.origin}${DISCORD_INTERACTIONS_PATH}`,
      });
    case "POST":
      break;
    default:
      return jsonResponse({ error: "method not allowed" }, { status: 405 });
  }

  const body = await request.text();
  const env = getDiscordEnv();
  const isVerified = verifyDiscordRequest({
    body,
    publicKey: env.DISCORD_PUBLIC_KEY,
    signature: request.headers.get("x-signature-ed25519"),
    timestamp: request.headers.get("x-signature-timestamp"),
  });

  if (!isVerified) {
    return jsonResponse({ error: "invalid request signature" }, { status: 401 });
  }

  let payloadBody: unknown;

  try {
    payloadBody = JSON.parse(body);
  } catch {
    return jsonResponse({ error: "invalid json" }, { status: 400 });
  }

  const parsedPayload = discordInteractionSchema.safeParse(payloadBody);

  if (!parsedPayload.success) {
    return jsonResponse({ error: "invalid interaction payload" }, { status: 400 });
  }

  const payload = parsedPayload.data;

  if (payload.type === InteractionType.Ping) {
    return jsonResponse({ type: InteractionResponseType.Pong });
  }

  // ApplicationCommand and MessageComponent dispatch is in the source.
}

Next steps

The template already implements more than /ping:

  • /info and /help: Components v2 panels (Discord's newer message layout) with runtime details and slash-command mentions.
  • /buttons: Components v2 buttons (primary, secondary, success and danger).
  • /name and /profile: Postgres via Drizzle (profiles and command_usage tables). After the project is linked and deployed so DATABASE_URL exists, run npm run db:push once. If you skip that, those commands reply that they could not reach the Neon database. On a cold branch they can also fall back to a warming-up reply; run the command again once the branch is warm.

To add LLM chat and image generation, see the Community Guide. That's a separate from-scratch walkthrough.

Sibling HTTP bot templates: Telegram and WhatsApp.

Example

Create a copy of the example with the Neon CLI:

neon bootstrap my-discord-bot --template discord-bot-http
cd my-discord-bot

You can find the example in bots/discord-bot-http.

Need help?

Join our Discord Server to ask questions or see what others are doing with Neon. For paid plan support options, see Support.

Was this page helpful?
Edit on GitHub