← All guides
DiscordJuly 12, 20266 min read

Test Discord Interactions on Localhost with Relayers

Set your Discord interactions endpoint URL to localhost, pass Ed25519 verification, and debug slash commands live using a webhook tunnel to your machine.

Setting a Discord Interactions Endpoint URL is one of the more unforgiving steps in bot development. Discord won't let you save the URL unless it can immediately reach a live endpoint, send it a PING, and get back a correctly signed PONG. There is no "save now, wire it up later" — your verification logic has to be running and reachable the moment you click save.

That's a problem when your handler lives on localhost:3000, especially when the whole thing hinges on an Ed25519 signature computed over the raw request body. This guide shows how to point Discord's Interactions Endpoint URL at your local machine using Relayers, pass verification on the first try, and iterate on slash commands with a real payload inspector.

A quick disambiguation: Discord has two "webhook" flavors. Outgoing webhooks let you send messages into a channel — a different direction. This post is about receiving interactions (slash commands, buttons) that Discord POSTs to you.

What you'll build

Discord sends every interaction to your public Relayers endpoint. Relayers forwards the request — body and headers untouched — down a tunnel to your dev server, where your existing verification code runs unmodified.

Prerequisites

1Create an endpoint in Relayers

In the Relayers panel, open Endpoints and create a new one. You'll get a public ingest URL shaped like this:

https://api.relayers.app/v1/webhooks/wep_9f3a2c1d

Copy it — this is the URL you'll hand to Discord. You can also use Send test later to replay traffic without touching Discord at all.

2Set the Interactions Endpoint URL in Discord

In the Developer Portal, open your application, go to General Information, paste your Relayers URL into the Interactions Endpoint URL field, and save.

On save, Discord immediately POSTs a PING ({ "type": 1 }). Your handler must respond with a PONG ({ "type": 1 }) — but only after validating the request signature. Discord signs every request with Ed25519 and sends two headers:

X-Signature-Ed25519: <hex signature>
X-Signature-Timestamp: <unix timestamp>

Your handler verifies timestamp + rawBody against your application's Public Key. If the signature is invalid, you must return 401 — Discord's own probe sends a deliberately bad request and checks that it's rejected.

The catch: this happens live, at save time, so your local handler must be reachable before you click save. That means the tunnel from step 3 has to be running already. Because Relayers forwards the original body and both signature headers byte-for-byte, the signature you compute locally matches exactly what Discord signed — verification passes on localhost just as it would in production.

3Forward events to localhost

Start the tunnel before you save the URL in Discord. Log in and listen:

relayers login
relayers listen --forward localhost:3000

relayers listen opens a WebSocket tunnel and streams every request hitting your endpoint to localhost:3000, preserving method, path, headers, and body. With this running, go back to step 2 and save the URL — the PING arrives at your handler, your code verifies it, returns the PONG, and Discord accepts the endpoint. Prefer a GUI? The desktop app does the same and shows requests as they arrive.

4Route and filter with JQ

Once verification is done, you'll get two kinds of traffic: the occasional PING (type 1) and real interactions. For slash commands you only care about APPLICATION_COMMAND interactions, which are type: 2. In the Rules view, add a JQ filter so only command interactions reach your handler:

.type == 2

Set the rule's destination to your tunnel (localhost:3000). Rules match top to bottom and first match wins, so you can add a rule above it to route PINGs or button interactions (type: 3) elsewhere, then reorder as needed. Anything that doesn't match simply isn't delivered.

5Transform the payload

Discord interaction payloads are deep. If a downstream logger or tool only needs the essentials, use a Transformation to flatten it. In the Transformations view, pick jq and map the fields you care about:

{
  command: .data.name,
  user: .member.user.username,
  guild: .guild_id
}

The test playground lets you paste a sample interaction and see the output instantly before wiring it into a rule. Relayers also supports jsonata, javascript, and go_template if you'd rather transform in another dialect.

One important note: apply transformations only to copies bound for logging or analytics. Your bot handler must receive the raw, untransformed body — otherwise the Ed25519 signature won't verify (see below).

A note on signatures

Ed25519 verification runs over timestamp + rawBody, so any change to a single byte breaks it. Relayers forwards the original body and the X-Signature-Ed25519 / X-Signature-Timestamp headers exactly as Discord sent them — the code you run locally is the code you ship, with no "skip verification in dev" branch and no drift between environments.

Going to production

When your handler is deployed, swap the Interactions Endpoint URL in the Developer Portal from your Relayers URL to your production URL. Nothing else changes — same verification code, same PING/PONG contract.

Want to keep Relayers in front of production for routing, filtering, and analytics? Point the endpoint's rules at your public URL instead of the tunnel, and export your endpoint, rules, and transformations as a JSON bundle to import into another workspace and keep dev and prod in sync.

Troubleshooting


Discord's interactions verification is strict by design, but that strictness is exactly why testing on localhost is worth getting right — you verify the real thing, with the real signature, on your own machine. Relayers gives you a stable public URL, byte-perfect forwarding, and a payload inspector, so the PING passes on the first save and every slash command after it lands where you can debug it.

Create a free endpoint and start forwarding at relayers.app.