A Discord webhook URL looks like an address, so it feels fine to put it in your page's JavaScript. It works more like a key. Discord's developer docs list three calls that need nothing but that URL: Get Webhook with Token, Modify Webhook with Token and Delete Webhook with Token. Each one "does not require authentication." So anyone who opens your page source can post to your channel, rename the webhook and give it a new avatar, or delete it. Once it's deleted your form stops delivering, and you find out when a customer asks why you never answered.

That's the whole problem. The rest of this piece is how common it is and how to fix it.

We're Formgong, and we make a form backend that posts to Discord. Our product is in the last section, and you can skip it.

How common it is

On 9 November we searched GitHub code for HTML files containing discord.com/api/webhooks. GitHub reports about 4,900 of them. We read the first 200 results in full. 111 of the 200 contain a complete webhook URL, meaning the numeric ID plus the long token after it. Across those files there are 212 different URLs in 99 repositories, because some pages carry several. We didn't call any of them, so we don't know how many still work. These are the first 200 results GitHub returned, not a random sample.

We also asked ChatGPT for the same form five times, in fresh temporary chats, with this prompt: "Write me a simple HTML contact form for my website that sends each message to my Discord channel."

Five ChatGPT answers and the GitHub count

ChatGPT did better here than with the same request for Telegram, where four of five answers put the bot token in the page. For Discord, three of five answers put the webhook URL in an environment variable on a small Node.js server. The other two put it in the page's <script> and warned about it underneath the code: "Anyone who inspects your website can copy it and spam your Discord channel." Neither warning mentions that the same person can delete the webhook.

The server versions have their own catch. They are Express apps that need a Node.js host running all the time. Two of the three ended by asking where your site is hosted, with GitHub Pages, Netlify and Vercel among the choices. GitHub Pages can't run server code at all, and for the other two you'd need a different version of the code, which ChatGPT offered to write next.

If your webhook URL has been public

Open Server Settings, then Integrations, then Webhooks, and delete the webhook. Then create a new one for the same channel. Taking the old URL out of your code doesn't help, because whoever copied it still has a working URL until you delete it.

To catch it before the next deploy, run npx -y github:formgong-hq/bot-token-check dist after your build. It's a small check we put on GitHub (bot-token-check) that fails when a full Discord webhook URL or a Telegram bot token is in the files you're about to upload.

Keep the URL on a server

The page posts the form to your code, and your code posts to Discord. On static hosting the easiest place for that code is a Cloudflare Worker, with the webhook URL stored as a secret:

// wrangler secret put DISCORD_WEBHOOK_URL const SITE = "https://example.com"; export default { async fetch(request, env) { const cors = { "Access-Control-Allow-Origin": SITE }; if (request.method !== "POST") return new Response("Use POST", { status: 405, headers: cors }); const form = await request.formData(); if (form.get("website")) return Response.json({ ok: true }, { headers: cors }); // honeypot const field = (key, max) => String(form.get(key) || "").trim().slice(0, max) || "-"; if (field("message", 1000) === "-") return Response.json({ ok: false, error: "empty message" }, { status: 400, headers: cors }); const res = await fetch(`${env.DISCORD_WEBHOOK_URL}?wait=true`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "Website form", allowed_mentions: { parse: [] }, // "@everyone" in a message pings nobody embeds: [{ title: "New message from the site", fields: [ { name: "Name", value: field("name", 100) }, { name: "Email", value: field("email", 200) }, { name: "Message", value: field("message", 1000) }, ], timestamp: new Date().toISOString(), }], }), }); return Response.json({ ok: res.ok }, { status: res.ok ? 200 : 502, headers: cors }); }, }; 

Some details in it are easy to miss.

  • allowed_mentions with an empty parse list means a visitor who types @everyone into your form doesn't ping your whole server. One of the two in-page versions ChatGPT gave us left it out.
  • An empty embed field value makes Discord reject the whole message, so blank optional fields become -.
  • Each field is cut below Discord's 1,024-character limit for a field value.
  • ?wait=true makes Discord confirm the message was created, so ok means it actually got there.
  • The hidden website field catches bots that fill in every input.

We ran the Worker with fetch stubbed: a normal message produced the embed with allowed_mentions set, a filled honeypot sent nothing, an empty message returned 400.

This Worker still has two weak spots. Its own URL is public, so someone can post junk through it, though they can no longer rename or delete your webhook. Add Turnstile if the junk becomes a problem. Also, nothing keeps a copy of a message if Discord is down.

Or paste the webhook into a form backend

Formgong takes a Discord webhook URL in the form's settings: My forms, the form, Form settings, Webhook (click Open expert mode if you don't see the tab). The URL stays on our side. Your page only carries a form key, which can add a submission and nothing else:

<form action="https://formgong.com/submit" method="POST"> <input type="hidden" name="access_key" value="YOUR_FORM_KEY"> <input type="text" name="botcheck" tabindex="-1" autocomplete="off" style="display:none"> <input name="name" required> <input name="email" type="email" required> <textarea name="message" required></textarea> <button>Send</button> </form> 

Each submission arrives as one embed. The title is the form name and links to the submission in your dashboard. Every field is its own entry, cut at 1,000 characters, and uploaded files show by name. Mentions are switched off, and @everyone is broken before sending as well. Spam is caught before it reaches Discord: the honeypot always, Turnstile if you turn it on. Spam still lands in your inbox, so you can check what was blocked. There is a Send test button. If Discord doesn't answer, Formgong retries five times over about 42 minutes, then emails you once, and the message stays in your inbox the whole time. Use a text channel: forum channels need a thread name on every post, and Formgong doesn't send one. Discord delivery works on the free plan.

Tell us what broke for you

Have you had a webhook spammed or deleted after it showed up in a page? Or did you find a cleaner fix for a static site than a Worker? Leave it in the comments or write to support@formgong.com.