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
- A Discord application (Developer Portal → Applications) with its Public Key on hand.
- A local interactions handler running on
localhost:3000that verifies Ed25519 and answersPINGwithPONG. - The Relayers desktop app or CLI installed. Grab it from the download page.
- A free Relayers account.
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
- "Interactions Endpoint URL could not be validated." Discord's
PINGverification failed. Confirm the tunnel is connected before you save, and that your handler returnsPONGfortype: 1. - Signature always invalid. You're likely verifying against a parsed/re-serialized body. Verify against the raw bytes, and make sure no transformation is applied on the path to your handler.
- Bad signatures aren't rejected. Discord's probe sends a deliberately invalid request and expects a
401— return401on failure, not200. - Interaction "failed." Discord requires an ACK within 3 seconds. If your work takes longer, return a deferred response first, then follow up. The payload inspector shows exactly what arrived and how long your handler took.
- Nothing shows up locally. Confirm the
public_idin the URL matches the panel, then check the Events view — if requests appear there but not on localhost, the tunnel or a rule filter is the culprit.
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.